mirror of
https://github.com/suitenumerique/docs.git
synced 2026-04-25 17:15:01 +02:00
Compare commits
107 Commits
v2.3.0-pre
...
feature/do
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b2a579d9f | ||
|
|
615ab564cc | ||
|
|
aadd6d9ec3 | ||
|
|
0a6502a77d | ||
|
|
a32ee20249 | ||
|
|
48db42f385 | ||
|
|
05b14b2948 | ||
|
|
12f4a72f5e | ||
|
|
ef7cc67387 | ||
|
|
a8529e434a | ||
|
|
f8203a1766 | ||
|
|
ce8b98e256 | ||
|
|
4243519eee | ||
|
|
1abf529891 | ||
|
|
69ca4af539 | ||
|
|
14b2adedfb | ||
|
|
a7edb382a7 | ||
|
|
fb5400c26b | ||
|
|
8473facbee | ||
|
|
5db446e8a8 | ||
|
|
34dfb3fd66 | ||
|
|
f9a91eda2d | ||
|
|
eba926dea4 | ||
|
|
3839a2e8b1 | ||
|
|
a88d62e07d | ||
|
|
b61a7a4961 | ||
|
|
20d32ecc4e | ||
|
|
313acf4f78 | ||
|
|
3a6105cc7e | ||
|
|
bbe17156be | ||
|
|
51cc26b916 | ||
|
|
cab8ef51df | ||
|
|
6627518017 | ||
|
|
12c18bc4e9 | ||
|
|
aff330eb5b | ||
|
|
bcdaedba9b | ||
|
|
799814e3e3 | ||
|
|
02c9b2ea2e | ||
|
|
eb23aefd55 | ||
|
|
0c49019490 | ||
|
|
170dbe07bb | ||
|
|
70136f2415 | ||
|
|
2a8fc97f2f | ||
|
|
9570701bc3 | ||
|
|
4b28b3c23b | ||
|
|
f26fc43df0 | ||
|
|
05a6818439 | ||
|
|
8056fd7d66 | ||
|
|
c85224af42 | ||
|
|
70f1b6a8e8 | ||
|
|
0f07fdcb65 | ||
|
|
2e13dfb9bc | ||
|
|
a026435eb7 | ||
|
|
7007d56c38 | ||
|
|
0405e6a3f6 | ||
|
|
cb8bd4b937 | ||
|
|
4316b4e67d | ||
|
|
534085439f | ||
|
|
da02d3d756 | ||
|
|
87960d3773 | ||
|
|
e0af6d36e1 | ||
|
|
cbf9091d1c | ||
|
|
9176328200 | ||
|
|
6efc2377fe | ||
|
|
1c02b0ad8e | ||
|
|
007854a877 | ||
|
|
57cead448d | ||
|
|
f20d256cd1 | ||
|
|
76c01df3ae | ||
|
|
20315e9b60 | ||
|
|
2203d49a52 | ||
|
|
56aa69f56a | ||
|
|
0aabf26694 | ||
|
|
fcf8b38021 | ||
|
|
757d7f35cd | ||
|
|
fdc49dc002 | ||
|
|
197ba47f73 | ||
|
|
d5997ba9d5 | ||
|
|
1c6d18fdf3 | ||
|
|
24d126f410 | ||
|
|
a5e1751cf3 | ||
|
|
0cabb655ad | ||
|
|
38eb6d45b7 | ||
|
|
5bb7ad643a | ||
|
|
57b8881fc6 | ||
|
|
89ad610ba6 | ||
|
|
251787b835 | ||
|
|
f95173e096 | ||
|
|
a7944cce80 | ||
|
|
7941fc91d5 | ||
|
|
7fc83a4fcd | ||
|
|
2bf47b7705 | ||
|
|
23b0214a2a | ||
|
|
f244509de3 | ||
|
|
fda5f8f008 | ||
|
|
9a79b09b07 | ||
|
|
b24acd14e2 | ||
|
|
1531846115 | ||
|
|
ebf6d46e37 | ||
|
|
b9b5f86cf4 | ||
|
|
56412b0be5 | ||
|
|
af052cd06b | ||
|
|
8927635c5f | ||
|
|
76bce4313b | ||
|
|
5ac71bfac1 | ||
|
|
cb4e148afc | ||
|
|
2d24825be0 |
9
.github/workflows/docker-hub.yml
vendored
9
.github/workflows/docker-hub.yml
vendored
@@ -125,8 +125,7 @@ jobs:
|
||||
- build-and-push-frontend
|
||||
- build-and-push-backend
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.event_name != 'pull_request'
|
||||
if: github.event_name != 'pull_request'
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
@@ -134,6 +133,6 @@ jobs:
|
||||
-
|
||||
name: Call argocd github webhook
|
||||
run: |
|
||||
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}'
|
||||
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${{ secrets.ARGOCD_PREPROD_WEBHOOK_SECRET}}'' | awk '{print "X-Hub-Signature: sha1="$2}')
|
||||
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" ${{ vars.ARGOCD_PREPROD_WEBHOOK_URL }}
|
||||
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/${{ secrets.DEPLOYMENT_REPO_URL }}"}}'
|
||||
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac "${{ secrets.ARGOCD_PREPROD_WEBHOOK_SECRET }}" | awk '{print "X-Hub-Signature: sha1="$2}')
|
||||
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" ${{ vars.ARGOCD_PREPROD_WEBHOOK_URL }}
|
||||
6
.github/workflows/helmfile-linter.yaml
vendored
6
.github/workflows/helmfile-linter.yaml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
helmfile-lint:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/helmfile/helmfile:latest
|
||||
image: ghcr.io/helmfile/helmfile:v0.171.0
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
@@ -22,9 +22,9 @@ jobs:
|
||||
run: |
|
||||
set -e
|
||||
HELMFILE=src/helm/helmfile.yaml
|
||||
environments=$(awk '/environments:/ {flag=1; next} flag && NF {print} !NF {flag=0}' "$HELMFILE" | grep -E '^[[:space:]]{2}[a-zA-Z]+' | sed 's/^[[:space:]]*//;s/:.*//')
|
||||
environments=$(awk 'BEGIN {in_env=0} /^environments:/ {in_env=1; next} /^---/ {in_env=0} in_env && /^ [^ ]/ {gsub(/^ /,""); gsub(/:.*$/,""); print}' "$HELMFILE")
|
||||
for env in $environments; do
|
||||
echo "################### $env lint ###################"
|
||||
helmfile -e $env -f $HELMFILE lint || exit 1
|
||||
echo -e "\n"
|
||||
done
|
||||
done
|
||||
87
CHANGELOG.md
87
CHANGELOG.md
@@ -10,10 +10,90 @@ and this project adheres to
|
||||
|
||||
## Added
|
||||
|
||||
- 📄(legal) Require contributors to sign a DCO #779
|
||||
|
||||
## Changed
|
||||
|
||||
- ♻️(frontend) Integrate UI kit #783
|
||||
|
||||
## [2.6.0] - 2025-03-21
|
||||
|
||||
## Added
|
||||
|
||||
- 📝(doc) add publiccode.yml #770
|
||||
|
||||
## Changed
|
||||
|
||||
- 🚸(frontend) ctrl+k modal not when editor is focused #712
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🐛(back) allow only images to be used with the cors-proxy #781
|
||||
- 🐛(backend) stop returning inactive users on the list endpoint #636
|
||||
- 🔒️(backend) require at least 5 characters to search for users #636
|
||||
- 🔒️(back) throttle user list endpoint #636
|
||||
- 🔒️(back) remove pagination and limit to 5 for user list endpoint #636
|
||||
|
||||
|
||||
## [2.5.0] - 2025-03-18
|
||||
|
||||
## Added
|
||||
|
||||
- 📝(doc) Added GNU Make link to README #750
|
||||
- ✨(frontend) add pinning on doc detail #711
|
||||
- 🚩(frontend) feature flag analytic on copy as html #649
|
||||
- ✨(frontend) Custom block divider with export #698
|
||||
- 🌐(i18n) activate dutch language #742
|
||||
- ✨(frontend) add Beautify action to AI transform #478
|
||||
- ✨(frontend) add Emojify action to AI transform #478
|
||||
|
||||
## Changed
|
||||
|
||||
- 🧑💻(frontend) change literal section open source #702
|
||||
- ♻️(frontend) replace cors proxy for export #695
|
||||
- 🚨(gitlint) Allow uppercase in commit messages #756
|
||||
- ♻️(frontend) Improve AI translations #478
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🐛(frontend) SVG export #706
|
||||
- 🐛(frontend) remove scroll listener table content #688
|
||||
- 🔒️(back) restrict access to favorite_list endpoint #690
|
||||
- 🐛(backend) refactor to fix filtering on children
|
||||
and descendants views #695
|
||||
- 🐛(action) fix notify-argocd workflow #713
|
||||
- 🚨(helm) fix helmfile lint #736
|
||||
- 🚚(frontend) redirect to 401 page when 401 error #759
|
||||
|
||||
|
||||
## [2.4.0] - 2025-03-06
|
||||
|
||||
## Added
|
||||
|
||||
- ✨(frontend) synchronize language-choice #401
|
||||
|
||||
## Changed
|
||||
|
||||
- Use sentry tags instead of extra scope
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🐛(frontend) fix collaboration error #684
|
||||
|
||||
|
||||
## [2.3.0] - 2025-03-03
|
||||
|
||||
## Added
|
||||
|
||||
- ✨(backend) limit link reach/role select options depending on ancestors #645
|
||||
- ✨(backend) add new "descendants" action to document API endpoint #645
|
||||
- ✨(backend) new "tree" action on document detail endpoint #645
|
||||
- ✨(backend) allow forcing page size within limits #645
|
||||
- 💄(frontend) add error pages #643
|
||||
- 🔒️ Manage unsafe attachments #663
|
||||
- ✨(frontend) Custom block quote with export #646
|
||||
- ✨(frontend) add open source section homepage #666
|
||||
- ✨(frontend) synchronize language-choice #401
|
||||
|
||||
## Changed
|
||||
|
||||
@@ -26,6 +106,7 @@ and this project adheres to
|
||||
|
||||
- 🐛(backend) allow any type of extensions for media download #671
|
||||
- ♻️(frontend) improve table pdf rendering
|
||||
- 🐛(email) invitation emails in receivers language
|
||||
|
||||
|
||||
## [2.2.0] - 2025-02-10
|
||||
@@ -414,7 +495,11 @@ and this project adheres to
|
||||
- ✨(frontend) Coming Soon page (#67)
|
||||
- 🚀 Impress, project to manage your documents easily and collaboratively.
|
||||
|
||||
[unreleased]: https://github.com/numerique-gouv/impress/compare/v2.2.0...main
|
||||
[unreleased]: https://github.com/numerique-gouv/impress/compare/v2.6.0...main
|
||||
[v2.5.0]: https://github.com/numerique-gouv/impress/releases/v2.6.0
|
||||
[v2.5.0]: https://github.com/numerique-gouv/impress/releases/v2.5.0
|
||||
[v2.4.0]: https://github.com/numerique-gouv/impress/releases/v2.4.0
|
||||
[v2.3.0]: https://github.com/numerique-gouv/impress/releases/v2.3.0
|
||||
[v2.2.0]: https://github.com/numerique-gouv/impress/releases/v2.2.0
|
||||
[v2.1.0]: https://github.com/numerique-gouv/impress/releases/v2.1.0
|
||||
[v2.0.1]: https://github.com/numerique-gouv/impress/releases/v2.0.1
|
||||
|
||||
@@ -4,6 +4,8 @@ Thank you for taking the time to contribute! Please follow these guidelines to e
|
||||
|
||||
To get started with the project, please refer to the [README.md](https://github.com/suitenumerique/docs/blob/main/README.md) for detailed instructions.
|
||||
|
||||
Contributors are required to sign off their commits with `git commit --sign-off`: this confirms that they have read and accepted the [Developer's Certificate of Origin 1.1](https://developercertificate.org/).
|
||||
|
||||
Please also check out our [dev handbook](https://suitenumerique.gitbook.io/handbook) to learn our best practices.
|
||||
|
||||
## Help us with translations
|
||||
@@ -35,7 +37,7 @@ All commit messages must adhere to the following format:
|
||||
|
||||
* <**gitmoji**>: Use a gitmoji to represent the purpose of the commit. For example, ✨ for adding a new feature or 🔥 for removing something, see the list here: <https://gitmoji.dev/>.
|
||||
* **(type)**: Describe the type of change. Common types include `backend`, `frontend`, `CI`, `docker` etc...
|
||||
* **title**: A short, descriptive title for the change, starting with a lowercase character.
|
||||
* **title**: A short, descriptive title for the change.
|
||||
* **description**: Include additional details about what was changed and why.
|
||||
|
||||
### Example Commit Message
|
||||
|
||||
2
Makefile
2
Makefile
@@ -93,7 +93,6 @@ build: cache ?= --no-cache
|
||||
build: ## build the project containers
|
||||
@$(MAKE) build-backend cache=$(cache)
|
||||
@$(MAKE) build-yjs-provider cache=$(cache)
|
||||
@$(MAKE) build-frontend cache=$(cache)
|
||||
.PHONY: build
|
||||
|
||||
build-backend: cache ?=
|
||||
@@ -128,7 +127,6 @@ run-backend: ## Start only the backend application and all needed services
|
||||
run: ## start the wsgi (production) and development server
|
||||
run:
|
||||
@$(MAKE) run-backend
|
||||
@$(COMPOSE) up --force-recreate -d frontend
|
||||
.PHONY: run
|
||||
|
||||
status: ## an alias for "docker compose ps"
|
||||
|
||||
@@ -46,7 +46,7 @@ Docs is a collaborative text editor designed to address common challenges in kno
|
||||
|
||||
### Test it
|
||||
|
||||
Test Docs on your browser by logging in on this [environment](https://impress-preprod.beta.numerique.gouv.fr/docs/0aa856e9-da41-4d59-b73d-a61cb2c1245f/)
|
||||
Test Docs on your browser by logging in on this [environment](https://impress-preprod.beta.numerique.gouv.fr/)
|
||||
|
||||
```
|
||||
email: test.docs@yopmail.com
|
||||
@@ -55,7 +55,7 @@ password: I'd<3ToTestDocs
|
||||
|
||||
### Run it locally
|
||||
|
||||
> ⚠️ Running Docs locally using the methods described below is for testing purposes only. It is based on building Docs using Minio as the S3 storage solution: if you want to use Minio for production deployment of Docs, you will need to comply with Minio's AGPL-3.0 licence.
|
||||
> ⚠️ Running Docs locally using the methods described below is for testing purposes only. It is based on building Docs using Minio as the S3 storage solution but you can choose any S3 compatible object storage of your choice.
|
||||
|
||||
**Prerequisite**
|
||||
|
||||
@@ -75,7 +75,7 @@ Docker Compose version v2.32.4
|
||||
|
||||
**Project bootstrap**
|
||||
|
||||
The easiest way to start working on the project is to use GNU Make:
|
||||
The easiest way to start working on the project is to use [GNU Make](https://www.gnu.org/software/make/):
|
||||
|
||||
```shellscript
|
||||
$ make bootstrap FLUSH_ARGS='--no-input'
|
||||
|
||||
@@ -4,7 +4,7 @@ services:
|
||||
postgresql:
|
||||
image: postgres:16
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready"]
|
||||
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
|
||||
interval: 1s
|
||||
timeout: 2s
|
||||
retries: 300
|
||||
@@ -194,7 +194,7 @@ services:
|
||||
kc_postgresql:
|
||||
image: postgres:14.3
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready"]
|
||||
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
|
||||
interval: 1s
|
||||
timeout: 2s
|
||||
retries: 300
|
||||
|
||||
@@ -61,4 +61,4 @@ COLLABORATION_SERVER_SECRET=my-secret
|
||||
COLLABORATION_WS_URL=ws://localhost:8083/collaboration/ws/
|
||||
|
||||
# Frontend
|
||||
FRONTEND_THEME=dsfr
|
||||
FRONTEND_THEME=default
|
||||
|
||||
@@ -31,7 +31,7 @@ class GitmojiTitle(LineRule):
|
||||
"https://raw.githubusercontent.com/carloscuesta/gitmoji/master/packages/gitmojis/src/gitmojis.json"
|
||||
).json()["gitmojis"]
|
||||
emojis = [item["emoji"] for item in gitmojis]
|
||||
pattern = r"^({:s})\(.*\)\s[a-z].*$".format("|".join(emojis))
|
||||
pattern = r"^({:s})\(.*\)\s[a-zA-Z].*$".format("|".join(emojis))
|
||||
if not re.search(pattern, title):
|
||||
violation_msg = 'Title does not match regex "<gitmoji>(<scope>) <subject>"'
|
||||
return [RuleViolation(self.id, violation_msg, title)]
|
||||
|
||||
27
publiccode.yml
Normal file
27
publiccode.yml
Normal file
@@ -0,0 +1,27 @@
|
||||
publiccodeYmlVersion: "2.4.0"
|
||||
name: Docs
|
||||
url: https://github.com/suitenumerique/docs
|
||||
landingURL: https://github.com/suitenumerique/docs
|
||||
creationDate: 2023-12-10
|
||||
logo: https://raw.githubusercontent.com/suitenumerique/docs/main/docs/assets/docs-logo.png
|
||||
usedBy:
|
||||
- Direction interministériel du numérique (DINUM)
|
||||
fundedBy:
|
||||
- name: Direction interministériel du numérique (DINUM)
|
||||
url: https://www.numerique.gouv.fr
|
||||
roadmap: "https://github.com/orgs/suitenumerique/projects/2/views/1"
|
||||
softwareType: "standalone/other"
|
||||
description:
|
||||
en:
|
||||
shortDescription: "The open source document editor where your notes can become knowledge through live collaboration"
|
||||
fr:
|
||||
shortDescription: "L'éditeur de documents open source où vos notes peuvent devenir des connaissances grâce à la collaboration en direct."
|
||||
legal:
|
||||
license: MIT
|
||||
maintenance:
|
||||
type: internal
|
||||
contacts:
|
||||
- name: "Virgile Deville"
|
||||
email: "virgile.deville@numerique.gouv.fr"
|
||||
- name: "samuel.paccoud"
|
||||
email: "samuel.paccoud@numerique.gouv.fr"
|
||||
@@ -14,15 +14,10 @@
|
||||
"groupName": "ignored js dependencies",
|
||||
"matchManagers": ["npm"],
|
||||
"matchPackageNames": [
|
||||
"@openfun/cunningham-react",
|
||||
"@types/react",
|
||||
"@types/react-dom",
|
||||
"eslint",
|
||||
"fetch-mock",
|
||||
"node",
|
||||
"node-fetch",
|
||||
"react",
|
||||
"react-dom",
|
||||
"workbox-webpack-plugin"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -17,9 +17,10 @@ def exception_handler(exc, context):
|
||||
https://gist.github.com/twidi/9d55486c36b6a51bdcb05ce3a763e79f
|
||||
"""
|
||||
if isinstance(exc, ValidationError):
|
||||
detail = exc.message_dict
|
||||
|
||||
if hasattr(exc, "message"):
|
||||
detail = None
|
||||
if hasattr(exc, "message_dict"):
|
||||
detail = exc.message_dict
|
||||
elif hasattr(exc, "message"):
|
||||
detail = exc.message
|
||||
elif hasattr(exc, "messages"):
|
||||
detail = exc.messages
|
||||
|
||||
@@ -12,15 +12,26 @@ class DocumentFilter(django_filters.FilterSet):
|
||||
Custom filter for filtering documents.
|
||||
"""
|
||||
|
||||
title = django_filters.CharFilter(
|
||||
field_name="title", lookup_expr="icontains", label=_("Title")
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = models.Document
|
||||
fields = ["title"]
|
||||
|
||||
|
||||
class ListDocumentFilter(DocumentFilter):
|
||||
"""
|
||||
Custom filter for filtering documents.
|
||||
"""
|
||||
|
||||
is_creator_me = django_filters.BooleanFilter(
|
||||
method="filter_is_creator_me", label=_("Creator is me")
|
||||
)
|
||||
is_favorite = django_filters.BooleanFilter(
|
||||
method="filter_is_favorite", label=_("Favorite")
|
||||
)
|
||||
title = django_filters.CharFilter(
|
||||
field_name="title", lookup_expr="icontains", label=_("Title")
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = models.Document
|
||||
|
||||
@@ -23,7 +23,7 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ["id", "email", "full_name", "short_name"]
|
||||
fields = ["id", "email", "full_name", "short_name", "language"]
|
||||
read_only_fields = ["id", "email", "full_name", "short_name"]
|
||||
|
||||
|
||||
@@ -128,26 +128,14 @@ class TemplateAccessSerializer(BaseAccessSerializer):
|
||||
read_only_fields = ["id", "abilities"]
|
||||
|
||||
|
||||
class BaseResourceSerializer(serializers.ModelSerializer):
|
||||
"""Serialize documents."""
|
||||
|
||||
abilities = serializers.SerializerMethodField(read_only=True)
|
||||
accesses = TemplateAccessSerializer(many=True, read_only=True)
|
||||
|
||||
def get_abilities(self, document) -> dict:
|
||||
"""Return abilities of the logged-in user on the instance."""
|
||||
request = self.context.get("request")
|
||||
if request:
|
||||
return document.get_abilities(request.user)
|
||||
return {}
|
||||
|
||||
|
||||
class ListDocumentSerializer(BaseResourceSerializer):
|
||||
class ListDocumentSerializer(serializers.ModelSerializer):
|
||||
"""Serialize documents with limited fields for display in lists."""
|
||||
|
||||
is_favorite = serializers.BooleanField(read_only=True)
|
||||
nb_accesses = serializers.IntegerField(read_only=True)
|
||||
nb_accesses_ancestors = serializers.IntegerField(read_only=True)
|
||||
nb_accesses_direct = serializers.IntegerField(read_only=True)
|
||||
user_roles = serializers.SerializerMethodField(read_only=True)
|
||||
abilities = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Document
|
||||
@@ -161,7 +149,8 @@ class ListDocumentSerializer(BaseResourceSerializer):
|
||||
"is_favorite",
|
||||
"link_role",
|
||||
"link_reach",
|
||||
"nb_accesses",
|
||||
"nb_accesses_ancestors",
|
||||
"nb_accesses_direct",
|
||||
"numchild",
|
||||
"path",
|
||||
"title",
|
||||
@@ -178,13 +167,30 @@ class ListDocumentSerializer(BaseResourceSerializer):
|
||||
"is_favorite",
|
||||
"link_role",
|
||||
"link_reach",
|
||||
"nb_accesses",
|
||||
"nb_accesses_ancestors",
|
||||
"nb_accesses_direct",
|
||||
"numchild",
|
||||
"path",
|
||||
"updated_at",
|
||||
"user_roles",
|
||||
]
|
||||
|
||||
def get_abilities(self, document) -> dict:
|
||||
"""Return abilities of the logged-in user on the instance."""
|
||||
request = self.context.get("request")
|
||||
|
||||
if request:
|
||||
paths_links_mapping = self.context.get("paths_links_mapping", None)
|
||||
# Retrieve ancestor links from paths_links_mapping (if provided)
|
||||
ancestors_links = (
|
||||
paths_links_mapping.get(document.path[: -document.steplen])
|
||||
if paths_links_mapping
|
||||
else None
|
||||
)
|
||||
return document.get_abilities(request.user, ancestors_links=ancestors_links)
|
||||
|
||||
return {}
|
||||
|
||||
def get_user_roles(self, document):
|
||||
"""
|
||||
Return roles of the logged-in user for the current document,
|
||||
@@ -214,7 +220,8 @@ class DocumentSerializer(ListDocumentSerializer):
|
||||
"is_favorite",
|
||||
"link_role",
|
||||
"link_reach",
|
||||
"nb_accesses",
|
||||
"nb_accesses_ancestors",
|
||||
"nb_accesses_direct",
|
||||
"numchild",
|
||||
"path",
|
||||
"title",
|
||||
@@ -230,7 +237,8 @@ class DocumentSerializer(ListDocumentSerializer):
|
||||
"is_favorite",
|
||||
"link_role",
|
||||
"link_reach",
|
||||
"nb_accesses",
|
||||
"nb_accesses_ancestors",
|
||||
"nb_accesses_direct",
|
||||
"numchild",
|
||||
"path",
|
||||
"updated_at",
|
||||
@@ -359,7 +367,7 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
|
||||
raise NotImplementedError("Update is not supported for this serializer.")
|
||||
|
||||
|
||||
class LinkDocumentSerializer(BaseResourceSerializer):
|
||||
class LinkDocumentSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serialize link configuration for documents.
|
||||
We expose it separately from document in order to simplify and secure access control.
|
||||
@@ -431,9 +439,12 @@ class FileUploadSerializer(serializers.Serializer):
|
||||
return attrs
|
||||
|
||||
|
||||
class TemplateSerializer(BaseResourceSerializer):
|
||||
class TemplateSerializer(serializers.ModelSerializer):
|
||||
"""Serialize templates."""
|
||||
|
||||
abilities = serializers.SerializerMethodField(read_only=True)
|
||||
accesses = TemplateAccessSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Template
|
||||
fields = [
|
||||
@@ -447,6 +458,13 @@ class TemplateSerializer(BaseResourceSerializer):
|
||||
]
|
||||
read_only_fields = ["id", "accesses", "abilities"]
|
||||
|
||||
def get_abilities(self, document) -> dict:
|
||||
"""Return abilities of the logged-in user on the instance."""
|
||||
request = self.context.get("request")
|
||||
if request:
|
||||
return document.get_abilities(request.user)
|
||||
return {}
|
||||
|
||||
|
||||
# pylint: disable=abstract-method
|
||||
class DocumentGenerationSerializer(serializers.Serializer):
|
||||
|
||||
@@ -11,6 +11,35 @@ import botocore
|
||||
from rest_framework.throttling import BaseThrottle
|
||||
|
||||
|
||||
def nest_tree(flat_list, steplen):
|
||||
"""
|
||||
Convert a flat list of serialized documents into a nested tree making advantage
|
||||
of the`path` field and its step length.
|
||||
"""
|
||||
node_dict = {}
|
||||
roots = []
|
||||
|
||||
# Sort the flat list by path to ensure parent nodes are processed first
|
||||
flat_list.sort(key=lambda x: x["path"])
|
||||
|
||||
for node in flat_list:
|
||||
node["children"] = [] # Initialize children list
|
||||
node_dict[node["path"]] = node
|
||||
|
||||
# Determine parent path
|
||||
parent_path = node["path"][:-steplen]
|
||||
|
||||
if parent_path in node_dict:
|
||||
node_dict[parent_path]["children"].append(node)
|
||||
else:
|
||||
roots.append(node) # Collect root nodes
|
||||
|
||||
if len(roots) > 1:
|
||||
raise ValueError("More than one root element detected.")
|
||||
|
||||
return roots[0] if roots else None
|
||||
|
||||
|
||||
def filter_root_paths(paths, skip_sorting=False):
|
||||
"""
|
||||
Filters root paths from a list of paths representing a tree structure.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
@@ -16,21 +16,22 @@ from django.db import models as db
|
||||
from django.db import transaction
|
||||
from django.db.models.expressions import RawSQL
|
||||
from django.db.models.functions import Left, Length
|
||||
from django.http import Http404
|
||||
from django.http import Http404, StreamingHttpResponse
|
||||
|
||||
import requests
|
||||
import rest_framework as drf
|
||||
from botocore.exceptions import ClientError
|
||||
from django_filters import rest_framework as drf_filters
|
||||
from rest_framework import filters, status, viewsets
|
||||
from rest_framework import response as drf_response
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.throttling import UserRateThrottle
|
||||
|
||||
from core import authentication, enums, models
|
||||
from core.services.ai_services import AIService
|
||||
from core.services.collaboration_services import CollaborationService
|
||||
|
||||
from . import permissions, serializers, utils
|
||||
from .filters import DocumentFilter
|
||||
from .filters import DocumentFilter, ListDocumentFilter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -135,14 +136,35 @@ class Pagination(drf.pagination.PageNumberPagination):
|
||||
page_size_query_param = "page_size"
|
||||
|
||||
|
||||
class UserListThrottleBurst(UserRateThrottle):
|
||||
"""Throttle for the user list endpoint."""
|
||||
|
||||
scope = "user_list_burst"
|
||||
|
||||
|
||||
class UserListThrottleSustained(UserRateThrottle):
|
||||
"""Throttle for the user list endpoint."""
|
||||
|
||||
scope = "user_list_sustained"
|
||||
|
||||
|
||||
class UserViewSet(
|
||||
drf.mixins.UpdateModelMixin, viewsets.GenericViewSet, drf.mixins.ListModelMixin
|
||||
):
|
||||
"""User ViewSet"""
|
||||
|
||||
permission_classes = [permissions.IsSelf]
|
||||
queryset = models.User.objects.all()
|
||||
queryset = models.User.objects.filter(is_active=True)
|
||||
serializer_class = serializers.UserSerializer
|
||||
pagination_class = None
|
||||
throttle_classes = []
|
||||
|
||||
def get_throttles(self):
|
||||
self.throttle_classes = []
|
||||
if self.action == "list":
|
||||
self.throttle_classes = [UserListThrottleBurst, UserListThrottleSustained]
|
||||
|
||||
return super().get_throttles()
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
@@ -157,11 +179,11 @@ class UserViewSet(
|
||||
return queryset
|
||||
|
||||
# Exclude all users already in the given document
|
||||
if document_id := self.request.GET.get("document_id", ""):
|
||||
if document_id := self.request.query_params.get("document_id", ""):
|
||||
queryset = queryset.exclude(documentaccess__document_id=document_id)
|
||||
|
||||
if not (query := self.request.GET.get("q", "")):
|
||||
return queryset
|
||||
if not (query := self.request.query_params.get("q", "")) or len(query) < 5:
|
||||
return queryset.none()
|
||||
|
||||
# For emails, match emails by Levenstein distance to prevent typing errors
|
||||
if "@" in query:
|
||||
@@ -170,7 +192,7 @@ class UserViewSet(
|
||||
distance=RawSQL("levenshtein(email::text, %s::text)", (query,))
|
||||
)
|
||||
.filter(distance__lte=3)
|
||||
.order_by("distance", "email")
|
||||
.order_by("distance", "email")[: settings.API_USERS_LIST_LIMIT]
|
||||
)
|
||||
|
||||
# Use trigram similarity for non-email-like queries
|
||||
@@ -180,7 +202,7 @@ class UserViewSet(
|
||||
queryset.filter(email__trigram_word_similar=query)
|
||||
.annotate(similarity=TrigramSimilarity("email", query))
|
||||
.filter(similarity__gt=0.2)
|
||||
.order_by("-similarity", "email")
|
||||
.order_by("-similarity", "email")[: settings.API_USERS_LIST_LIMIT]
|
||||
)
|
||||
|
||||
@drf.decorators.action(
|
||||
@@ -315,7 +337,6 @@ class DocumentViewSet(
|
||||
SerializerPerActionMixin,
|
||||
drf.mixins.CreateModelMixin,
|
||||
drf.mixins.DestroyModelMixin,
|
||||
drf.mixins.ListModelMixin,
|
||||
drf.mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
@@ -413,20 +434,21 @@ class DocumentViewSet(
|
||||
- Implements soft delete logic to retain document tree structures.
|
||||
"""
|
||||
|
||||
filter_backends = [drf_filters.DjangoFilterBackend]
|
||||
filterset_class = DocumentFilter
|
||||
metadata_class = DocumentMetadata
|
||||
ordering = ["-updated_at"]
|
||||
ordering_fields = ["created_at", "updated_at", "title"]
|
||||
pagination_class = Pagination
|
||||
permission_classes = [
|
||||
permissions.DocumentAccessPermission,
|
||||
]
|
||||
queryset = models.Document.objects.all()
|
||||
serializer_class = serializers.DocumentSerializer
|
||||
ai_translate_serializer_class = serializers.AITranslateSerializer
|
||||
children_serializer_class = serializers.ListDocumentSerializer
|
||||
descendants_serializer_class = serializers.ListDocumentSerializer
|
||||
list_serializer_class = serializers.ListDocumentSerializer
|
||||
trashbin_serializer_class = serializers.ListDocumentSerializer
|
||||
children_serializer_class = serializers.ListDocumentSerializer
|
||||
ai_translate_serializer_class = serializers.AITranslateSerializer
|
||||
tree_serializer_class = serializers.ListDocumentSerializer
|
||||
|
||||
def annotate_is_favorite(self, queryset):
|
||||
"""
|
||||
@@ -499,11 +521,42 @@ class DocumentViewSet(
|
||||
)
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
"""Apply annotations and filters sequentially."""
|
||||
filterset = DocumentFilter(
|
||||
"""Override to apply annotations to generic views."""
|
||||
queryset = super().filter_queryset(queryset)
|
||||
queryset = self.annotate_is_favorite(queryset)
|
||||
queryset = self.annotate_user_roles(queryset)
|
||||
return queryset
|
||||
|
||||
def get_response_for_queryset(self, queryset):
|
||||
"""Return paginated response for the queryset if requested."""
|
||||
page = self.paginate_queryset(queryset)
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return drf.response.Response(serializer.data)
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""
|
||||
Returns a DRF response containing the filtered, annotated and ordered document list.
|
||||
|
||||
This method applies filtering based on request parameters using `ListDocumentFilter`.
|
||||
It performs early filtering on model fields, annotates user roles, and removes
|
||||
descendant documents to keep only the highest ancestors readable by the current user.
|
||||
|
||||
Additional annotations (e.g., `is_highest_ancestor_for_user`, favorite status) are
|
||||
applied before ordering and returning the response.
|
||||
"""
|
||||
queryset = (
|
||||
self.get_queryset()
|
||||
) # Not calling filter_queryset. We do our own cooking.
|
||||
|
||||
filterset = ListDocumentFilter(
|
||||
self.request.GET, queryset=queryset, request=self.request
|
||||
)
|
||||
filterset.is_valid()
|
||||
if not filterset.is_valid():
|
||||
raise drf.exceptions.ValidationError(filterset.errors)
|
||||
filter_data = filterset.form.cleaned_data
|
||||
|
||||
# Filter as early as possible on fields that are available on the model
|
||||
@@ -512,22 +565,19 @@ class DocumentViewSet(
|
||||
|
||||
queryset = self.annotate_user_roles(queryset)
|
||||
|
||||
if self.action == "list":
|
||||
# Among the results, we may have documents that are ancestors/descendants
|
||||
# of each other. In this case we want to keep only the highest ancestors.
|
||||
root_paths = utils.filter_root_paths(
|
||||
queryset.order_by("path").values_list("path", flat=True),
|
||||
skip_sorting=True,
|
||||
)
|
||||
queryset = queryset.filter(path__in=root_paths)
|
||||
# Among the results, we may have documents that are ancestors/descendants
|
||||
# of each other. In this case we want to keep only the highest ancestors.
|
||||
root_paths = utils.filter_root_paths(
|
||||
queryset.order_by("path").values_list("path", flat=True),
|
||||
skip_sorting=True,
|
||||
)
|
||||
queryset = queryset.filter(path__in=root_paths)
|
||||
|
||||
# Annotate the queryset with an attribute marking instances as highest ancestor
|
||||
# in order to save some time while computing abilities in the instance
|
||||
queryset = queryset.annotate(
|
||||
is_highest_ancestor_for_user=db.Value(
|
||||
True, output_field=db.BooleanField()
|
||||
)
|
||||
)
|
||||
# Annotate the queryset with an attribute marking instances as highest ancestor
|
||||
# in order to save some time while computing abilities on the instance
|
||||
queryset = queryset.annotate(
|
||||
is_highest_ancestor_for_user=db.Value(True, output_field=db.BooleanField())
|
||||
)
|
||||
|
||||
# Annotate favorite status and filter if applicable as late as possible
|
||||
queryset = self.annotate_is_favorite(queryset)
|
||||
@@ -536,18 +586,11 @@ class DocumentViewSet(
|
||||
)
|
||||
|
||||
# Apply ordering only now that everyting is filtered and annotated
|
||||
return filters.OrderingFilter().filter_queryset(self.request, queryset, self)
|
||||
queryset = filters.OrderingFilter().filter_queryset(
|
||||
self.request, queryset, self
|
||||
)
|
||||
|
||||
def get_response_for_queryset(self, queryset):
|
||||
"""Return paginated response for the queryset if requested."""
|
||||
page = self.paginate_queryset(queryset)
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
result = self.get_paginated_response(serializer.data)
|
||||
return result
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return drf.response.Response(serializer.data)
|
||||
return self.get_response_for_queryset(queryset)
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
"""
|
||||
@@ -591,6 +634,7 @@ class DocumentViewSet(
|
||||
@drf.decorators.action(
|
||||
detail=False,
|
||||
methods=["get"],
|
||||
permission_classes=[permissions.IsAuthenticated],
|
||||
)
|
||||
def favorite_list(self, request, *args, **kwargs):
|
||||
"""Get list of favorite documents for the current user."""
|
||||
@@ -600,7 +644,7 @@ class DocumentViewSet(
|
||||
user=user
|
||||
).values_list("document_id", flat=True)
|
||||
|
||||
queryset = self.get_queryset()
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
queryset = queryset.filter(id__in=favorite_documents_ids)
|
||||
return self.get_response_for_queryset(queryset)
|
||||
|
||||
@@ -727,7 +771,6 @@ class DocumentViewSet(
|
||||
detail=True,
|
||||
methods=["get", "post"],
|
||||
ordering=["path"],
|
||||
url_path="children",
|
||||
)
|
||||
def children(self, request, *args, **kwargs):
|
||||
"""Handle listing and creating children of a document"""
|
||||
@@ -759,12 +802,106 @@ class DocumentViewSet(
|
||||
)
|
||||
|
||||
# GET: List children
|
||||
queryset = document.get_children().filter(deleted_at__isnull=True)
|
||||
queryset = document.get_children().filter(ancestors_deleted_at__isnull=True)
|
||||
queryset = self.filter_queryset(queryset)
|
||||
queryset = self.annotate_is_favorite(queryset)
|
||||
queryset = self.annotate_user_roles(queryset)
|
||||
|
||||
filterset = DocumentFilter(request.GET, queryset=queryset)
|
||||
if not filterset.is_valid():
|
||||
raise drf.exceptions.ValidationError(filterset.errors)
|
||||
|
||||
queryset = filterset.qs
|
||||
|
||||
return self.get_response_for_queryset(queryset)
|
||||
|
||||
@drf.decorators.action(
|
||||
detail=True,
|
||||
methods=["get"],
|
||||
ordering=["path"],
|
||||
)
|
||||
def descendants(self, request, *args, **kwargs):
|
||||
"""Handle listing descendants of a document"""
|
||||
document = self.get_object()
|
||||
|
||||
queryset = document.get_descendants().filter(ancestors_deleted_at__isnull=True)
|
||||
queryset = self.filter_queryset(queryset)
|
||||
|
||||
filterset = DocumentFilter(request.GET, queryset=queryset)
|
||||
if not filterset.is_valid():
|
||||
raise drf.exceptions.ValidationError(filterset.errors)
|
||||
|
||||
queryset = filterset.qs
|
||||
|
||||
return self.get_response_for_queryset(queryset)
|
||||
|
||||
@drf.decorators.action(
|
||||
detail=True,
|
||||
methods=["get"],
|
||||
ordering=["path"],
|
||||
)
|
||||
def tree(self, request, pk, *args, **kwargs):
|
||||
"""
|
||||
List ancestors tree above the document.
|
||||
What we need to display is the tree structure opened for the current document.
|
||||
"""
|
||||
try:
|
||||
current_document = self.queryset.only("depth", "path").get(pk=pk)
|
||||
except models.Document.DoesNotExist as excpt:
|
||||
raise drf.exceptions.NotFound from excpt
|
||||
|
||||
ancestors = (
|
||||
(current_document.get_ancestors() | self.queryset.filter(pk=pk))
|
||||
.filter(ancestors_deleted_at__isnull=True)
|
||||
.order_by("path")
|
||||
)
|
||||
|
||||
# Get the highest readable ancestor
|
||||
highest_readable = ancestors.readable_per_se(request.user).only("depth").first()
|
||||
if highest_readable is None:
|
||||
raise (
|
||||
drf.exceptions.PermissionDenied()
|
||||
if request.user.is_authenticated
|
||||
else drf.exceptions.NotAuthenticated()
|
||||
)
|
||||
|
||||
paths_links_mapping = {}
|
||||
ancestors_links = []
|
||||
children_clause = db.Q()
|
||||
for ancestor in ancestors:
|
||||
if ancestor.depth < highest_readable.depth:
|
||||
continue
|
||||
|
||||
children_clause |= db.Q(
|
||||
path__startswith=ancestor.path, depth=ancestor.depth + 1
|
||||
)
|
||||
|
||||
# Compute cache for ancestors links to avoid many queries while computing
|
||||
# abilties for his documents in the tree!
|
||||
ancestors_links.append(
|
||||
{"link_reach": ancestor.link_reach, "link_role": ancestor.link_role}
|
||||
)
|
||||
paths_links_mapping[ancestor.path] = ancestors_links.copy()
|
||||
|
||||
children = self.queryset.filter(children_clause, deleted_at__isnull=True)
|
||||
|
||||
queryset = ancestors.filter(depth__gte=highest_readable.depth) | children
|
||||
queryset = queryset.order_by("path")
|
||||
queryset = self.annotate_user_roles(queryset)
|
||||
queryset = self.annotate_is_favorite(queryset)
|
||||
|
||||
# Pass ancestors' links definitions to the serializer as a context variable
|
||||
# in order to allow saving time while computing abilities on the instance
|
||||
serializer = self.get_serializer(
|
||||
queryset,
|
||||
many=True,
|
||||
context={
|
||||
"request": request,
|
||||
"paths_links_mapping": paths_links_mapping,
|
||||
},
|
||||
)
|
||||
return drf.response.Response(
|
||||
utils.nest_tree(serializer.data, self.queryset.model.steplen)
|
||||
)
|
||||
|
||||
@drf.decorators.action(detail=True, methods=["get"], url_path="versions")
|
||||
def versions_list(self, request, *args, **kwargs):
|
||||
"""
|
||||
@@ -806,7 +943,7 @@ class DocumentViewSet(
|
||||
@drf.decorators.action(
|
||||
detail=True,
|
||||
methods=["get", "delete"],
|
||||
url_path="versions/(?P<version_id>[0-9a-f-]{36})",
|
||||
url_path="versions/(?P<version_id>[0-9a-z-]+)",
|
||||
)
|
||||
# pylint: disable=unused-argument
|
||||
def versions_detail(self, request, pk, version_id, *args, **kwargs):
|
||||
@@ -1123,6 +1260,66 @@ class DocumentViewSet(
|
||||
|
||||
return drf.response.Response(response, status=drf.status.HTTP_200_OK)
|
||||
|
||||
@drf.decorators.action(
|
||||
detail=True,
|
||||
methods=["get"],
|
||||
name="",
|
||||
url_path="cors-proxy",
|
||||
)
|
||||
def cors_proxy(self, request, *args, **kwargs):
|
||||
"""
|
||||
GET /api/v1.0/documents/<resource_id>/cors-proxy
|
||||
Act like a proxy to fetch external resources and bypass CORS restrictions.
|
||||
"""
|
||||
url = request.query_params.get("url")
|
||||
if not url:
|
||||
return drf.response.Response(
|
||||
{"detail": "Missing 'url' query parameter"},
|
||||
status=drf.status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Check for permissions.
|
||||
self.get_object()
|
||||
|
||||
url = unquote(url)
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
url,
|
||||
stream=True,
|
||||
headers={
|
||||
"User-Agent": request.headers.get("User-Agent", ""),
|
||||
"Accept": request.headers.get("Accept", ""),
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
|
||||
if not content_type.startswith("image/"):
|
||||
return drf.response.Response(
|
||||
status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE
|
||||
)
|
||||
|
||||
# Use StreamingHttpResponse with the response's iter_content to properly stream the data
|
||||
proxy_response = StreamingHttpResponse(
|
||||
streaming_content=response.iter_content(chunk_size=8192),
|
||||
content_type=content_type,
|
||||
headers={
|
||||
"Content-Disposition": "attachment;",
|
||||
"Content-Security-Policy": "default-src 'none'; img-src 'none' data:;",
|
||||
},
|
||||
status=response.status_code,
|
||||
)
|
||||
|
||||
return proxy_response
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error("Proxy request failed: %s", str(e))
|
||||
return drf_response.Response(
|
||||
{"error": f"Failed to fetch resource: {e!s}"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
|
||||
class DocumentAccessViewSet(
|
||||
ResourceAccessViewsetMixin,
|
||||
@@ -1167,13 +1364,14 @@ class DocumentAccessViewSet(
|
||||
def perform_create(self, serializer):
|
||||
"""Add a new access to the document and send an email to the new added user."""
|
||||
access = serializer.save()
|
||||
language = self.request.headers.get("Content-Language", "en-us")
|
||||
|
||||
access.document.send_invitation_email(
|
||||
access.user.email,
|
||||
access.role,
|
||||
self.request.user,
|
||||
language,
|
||||
access.user.language
|
||||
or self.request.user.language
|
||||
or settings.LANGUAGE_CODE,
|
||||
)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
@@ -1399,10 +1597,11 @@ class InvitationViewset(
|
||||
"""Save invitation to a document then send an email to the invited user."""
|
||||
invitation = serializer.save()
|
||||
|
||||
language = self.request.headers.get("Content-Language", "en-us")
|
||||
|
||||
invitation.document.send_invitation_email(
|
||||
invitation.email, invitation.role, self.request.user, language
|
||||
invitation.email,
|
||||
invitation.role,
|
||||
self.request.user,
|
||||
self.request.user.language or settings.LANGUAGE_CODE,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Generated by Django 5.1.5 on 2025-03-04 12:23
|
||||
from django.db import migrations, models
|
||||
|
||||
import core.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("core", "0018_update_blank_title"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelManagers(
|
||||
name="user",
|
||||
managers=[
|
||||
("objects", core.models.UserManager()),
|
||||
],
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="language",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("en-us", "English"),
|
||||
("fr-fr", "Français"),
|
||||
("de-de", "Deutsch"),
|
||||
],
|
||||
default=None,
|
||||
help_text="The language in which the user wants to see the interface.",
|
||||
max_length=10,
|
||||
null=True,
|
||||
verbose_name="language",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-14 14:03
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("core", "0019_alter_user_language_default_to_null"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="document",
|
||||
name="has_deleted_children",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -6,6 +6,7 @@ Declare and configure the models for the impress core application
|
||||
import hashlib
|
||||
import smtplib
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from datetime import timedelta
|
||||
from logging import getLogger
|
||||
|
||||
@@ -29,7 +30,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from botocore.exceptions import ClientError
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from timezone_field import TimeZoneField
|
||||
from treebeard.mp_tree import MP_Node
|
||||
from treebeard.mp_tree import MP_Node, MP_NodeManager, MP_NodeQuerySet
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
@@ -80,6 +81,55 @@ class LinkReachChoices(models.TextChoices):
|
||||
) # Any authenticated user can access the document
|
||||
PUBLIC = "public", _("Public") # Even anonymous users can access the document
|
||||
|
||||
@classmethod
|
||||
def get_select_options(cls, ancestors_links):
|
||||
"""
|
||||
Determines the valid select options for link reach and link role depending on the
|
||||
list of ancestors' link reach/role.
|
||||
|
||||
Args:
|
||||
ancestors_links: List of dictionaries, each with 'link_reach' and 'link_role' keys
|
||||
representing the reach and role of ancestors links.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping possible reach levels to their corresponding possible roles.
|
||||
"""
|
||||
# If no ancestors, return all options
|
||||
if not ancestors_links:
|
||||
return dict.fromkeys(cls.values, LinkRoleChoices.values)
|
||||
|
||||
# Initialize result with all possible reaches and role options as sets
|
||||
result = {reach: set(LinkRoleChoices.values) for reach in cls.values}
|
||||
|
||||
# Group roles by reach level
|
||||
reach_roles = defaultdict(set)
|
||||
for link in ancestors_links:
|
||||
reach_roles[link["link_reach"]].add(link["link_role"])
|
||||
|
||||
# Apply constraints based on ancestor links
|
||||
if LinkRoleChoices.EDITOR in reach_roles[cls.RESTRICTED]:
|
||||
result[cls.RESTRICTED].discard(LinkRoleChoices.READER)
|
||||
|
||||
if LinkRoleChoices.EDITOR in reach_roles[cls.AUTHENTICATED]:
|
||||
result[cls.AUTHENTICATED].discard(LinkRoleChoices.READER)
|
||||
result.pop(cls.RESTRICTED, None)
|
||||
elif LinkRoleChoices.READER in reach_roles[cls.AUTHENTICATED]:
|
||||
result[cls.RESTRICTED].discard(LinkRoleChoices.READER)
|
||||
|
||||
if LinkRoleChoices.EDITOR in reach_roles[cls.PUBLIC]:
|
||||
result[cls.PUBLIC].discard(LinkRoleChoices.READER)
|
||||
result.pop(cls.AUTHENTICATED, None)
|
||||
result.pop(cls.RESTRICTED, None)
|
||||
elif LinkRoleChoices.READER in reach_roles[cls.PUBLIC]:
|
||||
result[cls.AUTHENTICATED].discard(LinkRoleChoices.READER)
|
||||
result.get(cls.RESTRICTED, set()).discard(LinkRoleChoices.READER)
|
||||
|
||||
# Convert roles sets to lists while maintaining the order from LinkRoleChoices
|
||||
for reach, roles in result.items():
|
||||
result[reach] = [role for role in LinkRoleChoices.values if role in roles]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class DuplicateEmailError(Exception):
|
||||
"""Raised when an email is already associated with a pre-existing user."""
|
||||
@@ -194,9 +244,11 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
language = models.CharField(
|
||||
max_length=10,
|
||||
choices=lazy(lambda: settings.LANGUAGES, tuple)(),
|
||||
default=settings.LANGUAGE_CODE,
|
||||
default=None,
|
||||
verbose_name=_("language"),
|
||||
help_text=_("The language in which the user wants to see the interface."),
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
timezone = TimeZoneField(
|
||||
choices_display="WITH_GMT_OFFSET",
|
||||
@@ -367,6 +419,51 @@ class BaseAccess(BaseModel):
|
||||
}
|
||||
|
||||
|
||||
class DocumentQuerySet(MP_NodeQuerySet):
|
||||
"""
|
||||
Custom queryset for the Document model, providing additional methods
|
||||
to filter documents based on user permissions.
|
||||
"""
|
||||
|
||||
def readable_per_se(self, user):
|
||||
"""
|
||||
Filters the queryset to return documents that the given user has
|
||||
permission to read.
|
||||
:param user: The user for whom readable documents are to be fetched.
|
||||
:return: A queryset of documents readable by the user.
|
||||
"""
|
||||
if user.is_authenticated:
|
||||
return self.filter(
|
||||
models.Q(accesses__user=user)
|
||||
| models.Q(accesses__team__in=user.teams)
|
||||
| ~models.Q(link_reach=LinkReachChoices.RESTRICTED)
|
||||
)
|
||||
|
||||
return self.filter(link_reach=LinkReachChoices.PUBLIC)
|
||||
|
||||
|
||||
class DocumentManager(MP_NodeManager):
|
||||
"""
|
||||
Custom manager for the Document model, enabling the use of the custom
|
||||
queryset methods directly from the model manager.
|
||||
"""
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Overrides the default get_queryset method to return a custom queryset.
|
||||
:return: An instance of DocumentQuerySet.
|
||||
"""
|
||||
return DocumentQuerySet(self.model, using=self._db)
|
||||
|
||||
def readable_per_se(self, user):
|
||||
"""
|
||||
Filters documents based on user permissions using the custom queryset.
|
||||
:param user: The user for whom readable documents are to be fetched.
|
||||
:return: A queryset of documents readable by the user.
|
||||
"""
|
||||
return self.get_queryset().readable_per_se(user)
|
||||
|
||||
|
||||
class Document(MP_Node, BaseModel):
|
||||
"""Pad document carrying the content."""
|
||||
|
||||
@@ -389,6 +486,7 @@ class Document(MP_Node, BaseModel):
|
||||
)
|
||||
deleted_at = models.DateTimeField(null=True, blank=True)
|
||||
ancestors_deleted_at = models.DateTimeField(null=True, blank=True)
|
||||
has_deleted_children = models.BooleanField(default=False)
|
||||
|
||||
_content = None
|
||||
|
||||
@@ -399,6 +497,8 @@ class Document(MP_Node, BaseModel):
|
||||
|
||||
path = models.CharField(max_length=7 * 36, unique=True, db_collation="C")
|
||||
|
||||
objects = DocumentManager()
|
||||
|
||||
class Meta:
|
||||
db_table = "impress_document"
|
||||
ordering = ("path",)
|
||||
@@ -447,6 +547,12 @@ class Document(MP_Node, BaseModel):
|
||||
content_file = ContentFile(bytes_content)
|
||||
default_storage.save(file_key, content_file)
|
||||
|
||||
def is_leaf(self):
|
||||
"""
|
||||
:returns: True if the node is has no children
|
||||
"""
|
||||
return not self.has_deleted_children and self.numchild == 0
|
||||
|
||||
@property
|
||||
def key_base(self):
|
||||
"""Key base of the location where the document is stored in object storage."""
|
||||
@@ -483,9 +589,13 @@ class Document(MP_Node, BaseModel):
|
||||
|
||||
def get_content_response(self, version_id=""):
|
||||
"""Get the content in a specific version of the document"""
|
||||
return default_storage.connection.meta.client.get_object(
|
||||
Bucket=default_storage.bucket_name, Key=self.file_key, VersionId=version_id
|
||||
)
|
||||
params = {
|
||||
"Bucket": default_storage.bucket_name,
|
||||
"Key": self.file_key,
|
||||
}
|
||||
if version_id:
|
||||
params["VersionId"] = version_id
|
||||
return default_storage.connection.meta.client.get_object(**params)
|
||||
|
||||
def get_versions_slice(self, from_version_id="", min_datetime=None, page_size=None):
|
||||
"""Get document versions from object storage with pagination and starting conditions"""
|
||||
@@ -555,24 +665,47 @@ class Document(MP_Node, BaseModel):
|
||||
"""Generate a unique cache key for each document."""
|
||||
return f"document_{self.id!s}_nb_accesses"
|
||||
|
||||
@property
|
||||
def nb_accesses(self):
|
||||
"""Calculate the number of accesses."""
|
||||
def get_nb_accesses(self):
|
||||
"""
|
||||
Calculate the number of accesses:
|
||||
- directly attached to the document
|
||||
- attached to any of the document's ancestors
|
||||
"""
|
||||
cache_key = self.get_nb_accesses_cache_key()
|
||||
nb_accesses = cache.get(cache_key)
|
||||
|
||||
if nb_accesses is None:
|
||||
nb_accesses = DocumentAccess.objects.filter(
|
||||
document__path=Left(models.Value(self.path), Length("document__path")),
|
||||
).count()
|
||||
nb_accesses = (
|
||||
DocumentAccess.objects.filter(document=self).count(),
|
||||
DocumentAccess.objects.filter(
|
||||
document__path=Left(
|
||||
models.Value(self.path), Length("document__path")
|
||||
),
|
||||
document__ancestors_deleted_at__isnull=True,
|
||||
).count(),
|
||||
)
|
||||
cache.set(cache_key, nb_accesses)
|
||||
|
||||
return nb_accesses
|
||||
|
||||
@property
|
||||
def nb_accesses_direct(self):
|
||||
"""Returns the number of accesses related to the document or one of its ancestors."""
|
||||
return self.get_nb_accesses()[0]
|
||||
|
||||
@property
|
||||
def nb_accesses_ancestors(self):
|
||||
"""Returns the number of accesses related to the document or one of its ancestors."""
|
||||
return self.get_nb_accesses()[1]
|
||||
|
||||
def invalidate_nb_accesses_cache(self):
|
||||
"""
|
||||
Invalidate the cache for number of accesses, including on affected descendants.
|
||||
Args:
|
||||
path: can optionally be passed as argument (useful when invalidating cache for a
|
||||
document we just deleted)
|
||||
"""
|
||||
|
||||
for document in Document.objects.filter(path__startswith=self.path).only("id"):
|
||||
cache_key = document.get_nb_accesses_cache_key()
|
||||
cache.delete(cache_key)
|
||||
@@ -596,25 +729,27 @@ class Document(MP_Node, BaseModel):
|
||||
roles = []
|
||||
return roles
|
||||
|
||||
@cached_property
|
||||
def links_definitions(self):
|
||||
def get_links_definitions(self, ancestors_links):
|
||||
"""Get links reach/role definitions for the current document and its ancestors."""
|
||||
links_definitions = {self.link_reach: {self.link_role}}
|
||||
|
||||
# Ancestors links definitions are only interesting if the document is not the highest
|
||||
# ancestor to which the current user has access. Look for the annotation:
|
||||
if self.depth > 1 and not getattr(self, "is_highest_ancestor_for_user", False):
|
||||
for ancestor in self.get_ancestors().values("link_reach", "link_role"):
|
||||
links_definitions.setdefault(ancestor["link_reach"], set()).add(
|
||||
ancestor["link_role"]
|
||||
)
|
||||
links_definitions = defaultdict(set)
|
||||
links_definitions[self.link_reach].add(self.link_role)
|
||||
|
||||
return links_definitions
|
||||
# Merge ancestor link definitions
|
||||
for ancestor in ancestors_links:
|
||||
links_definitions[ancestor["link_reach"]].add(ancestor["link_role"])
|
||||
|
||||
def get_abilities(self, user):
|
||||
return dict(links_definitions) # Convert defaultdict back to a normal dict
|
||||
|
||||
def get_abilities(self, user, ancestors_links=None):
|
||||
"""
|
||||
Compute and return abilities for a given user on the document.
|
||||
"""
|
||||
if self.depth <= 1 or getattr(self, "is_highest_ancestor_for_user", False):
|
||||
ancestors_links = []
|
||||
elif ancestors_links is None:
|
||||
ancestors_links = self.get_ancestors().values("link_reach", "link_role")
|
||||
|
||||
roles = set(
|
||||
self.get_roles(user)
|
||||
) # at this point only roles based on specific access
|
||||
@@ -634,9 +769,7 @@ class Document(MP_Node, BaseModel):
|
||||
) and not is_deleted
|
||||
|
||||
# Add roles provided by the document link, taking into account its ancestors
|
||||
|
||||
# Add roles provided by the document link
|
||||
links_definitions = self.links_definitions
|
||||
links_definitions = self.get_links_definitions(ancestors_links)
|
||||
public_roles = links_definitions.get(LinkReachChoices.PUBLIC, set())
|
||||
authenticated_roles = (
|
||||
links_definitions.get(LinkReachChoices.AUTHENTICATED, set())
|
||||
@@ -671,6 +804,8 @@ class Document(MP_Node, BaseModel):
|
||||
"children_list": can_get,
|
||||
"children_create": can_update and user.is_authenticated,
|
||||
"collaboration_auth": can_get,
|
||||
"cors_proxy": can_get,
|
||||
"descendants": can_get,
|
||||
"destroy": is_owner,
|
||||
"favorite": can_get and user.is_authenticated,
|
||||
"link_configuration": is_owner_or_admin,
|
||||
@@ -680,6 +815,8 @@ class Document(MP_Node, BaseModel):
|
||||
"restore": is_owner,
|
||||
"retrieve": can_get,
|
||||
"media_auth": can_get,
|
||||
"link_select_options": LinkReachChoices.get_select_options(ancestors_links),
|
||||
"tree": can_get,
|
||||
"update": can_update,
|
||||
"versions_destroy": is_owner_or_admin,
|
||||
"versions_list": has_access_role,
|
||||
@@ -697,6 +834,7 @@ class Document(MP_Node, BaseModel):
|
||||
"document": self,
|
||||
"domain": domain,
|
||||
"link": f"{domain}/docs/{self.id}/",
|
||||
"document_title": self.title or str(_("Untitled Document")),
|
||||
"logo_img": settings.EMAIL_LOGO_IMG,
|
||||
}
|
||||
)
|
||||
@@ -738,8 +876,12 @@ class Document(MP_Node, BaseModel):
|
||||
'{name} invited you with the role "{role}" on the following document:'
|
||||
).format(name=sender_name_email, role=role.lower()),
|
||||
}
|
||||
subject = _("{name} shared a document with you: {title}").format(
|
||||
name=sender_name, title=self.title
|
||||
subject = (
|
||||
context["title"]
|
||||
if not self.title
|
||||
else _("{name} shared a document with you: {title}").format(
|
||||
name=sender_name, title=self.title
|
||||
)
|
||||
)
|
||||
|
||||
self.send_email(subject, [email], context, language)
|
||||
@@ -750,19 +892,27 @@ class Document(MP_Node, BaseModel):
|
||||
Soft delete the document, marking the deletion on descendants.
|
||||
We still keep the .delete() method untouched for programmatic purposes.
|
||||
"""
|
||||
if self.deleted_at or self.ancestors_deleted_at:
|
||||
if (
|
||||
self._meta.model.objects.filter(
|
||||
models.Q(deleted_at__isnull=False)
|
||||
| models.Q(ancestors_deleted_at__isnull=False),
|
||||
pk=self.pk,
|
||||
).exists()
|
||||
or self.get_ancestors().filter(deleted_at__isnull=False).exists()
|
||||
):
|
||||
raise RuntimeError(
|
||||
"This document is already deleted or has deleted ancestors."
|
||||
)
|
||||
|
||||
# Check if any ancestors are deleted
|
||||
if self.get_ancestors().filter(deleted_at__isnull=False).exists():
|
||||
raise RuntimeError(
|
||||
"Cannot delete this document because one or more ancestors are already deleted."
|
||||
)
|
||||
|
||||
self.ancestors_deleted_at = self.deleted_at = timezone.now()
|
||||
self.save()
|
||||
self.invalidate_nb_accesses_cache()
|
||||
|
||||
if self.depth > 1:
|
||||
self._meta.model.objects.filter(pk=self.get_parent().pk).update(
|
||||
numchild=models.F("numchild") - 1,
|
||||
has_deleted_children=True,
|
||||
)
|
||||
|
||||
# Mark all descendants as soft deleted
|
||||
self.get_descendants().filter(ancestors_deleted_at__isnull=True).update(
|
||||
@@ -773,20 +923,19 @@ class Document(MP_Node, BaseModel):
|
||||
def restore(self):
|
||||
"""Cancelling a soft delete with checks."""
|
||||
# This should not happen
|
||||
if self.deleted_at is None:
|
||||
raise ValidationError({"deleted_at": [_("This document is not deleted.")]})
|
||||
if self._meta.model.objects.filter(
|
||||
pk=self.pk, deleted_at__isnull=True
|
||||
).exists():
|
||||
raise RuntimeError("This document is not deleted.")
|
||||
|
||||
if self.deleted_at < get_trashbin_cutoff():
|
||||
raise ValidationError(
|
||||
{
|
||||
"deleted_at": [
|
||||
_(
|
||||
"This document was permanently deleted and cannot be restored."
|
||||
)
|
||||
]
|
||||
}
|
||||
raise RuntimeError(
|
||||
"This document was permanently deleted and cannot be restored."
|
||||
)
|
||||
|
||||
# save the current deleted_at value to exclude it from the descendants update
|
||||
current_deleted_at = self.deleted_at
|
||||
|
||||
# Restore the current document
|
||||
self.deleted_at = None
|
||||
|
||||
@@ -799,23 +948,18 @@ class Document(MP_Node, BaseModel):
|
||||
.first()
|
||||
)
|
||||
self.ancestors_deleted_at = ancestors_deleted_at
|
||||
self.save()
|
||||
self.save(update_fields=["deleted_at", "ancestors_deleted_at"])
|
||||
self.invalidate_nb_accesses_cache()
|
||||
|
||||
# Update descendants excluding those who were deleted prior to the deletion of the
|
||||
# current document (the ancestor_deleted_at date for those should already by good)
|
||||
# The number of deleted descendants should not be too big so we can handcraft a union
|
||||
# clause for them:
|
||||
deleted_descendants_paths = (
|
||||
self.get_descendants()
|
||||
.filter(deleted_at__isnull=False)
|
||||
.values_list("path", flat=True)
|
||||
)
|
||||
exclude_condition = models.Q(
|
||||
*(models.Q(path__startswith=path) for path in deleted_descendants_paths)
|
||||
)
|
||||
self.get_descendants().exclude(exclude_condition).update(
|
||||
ancestors_deleted_at=self.ancestors_deleted_at
|
||||
)
|
||||
self.get_descendants().exclude(
|
||||
models.Q(deleted_at__isnull=False)
|
||||
| models.Q(ancestors_deleted_at__lt=current_deleted_at)
|
||||
).update(ancestors_deleted_at=self.ancestors_deleted_at)
|
||||
|
||||
if self.depth > 1:
|
||||
self._meta.model.objects.filter(pk=self.get_parent().pk).update(
|
||||
numchild=models.F("numchild") + 1
|
||||
)
|
||||
|
||||
|
||||
class LinkTrace(BaseModel):
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
"""AI services."""
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
@@ -12,32 +9,44 @@ from core import enums
|
||||
|
||||
AI_ACTIONS = {
|
||||
"prompt": (
|
||||
"Answer the prompt in markdown format. Return JSON: "
|
||||
'{"answer": "Your markdown answer"}. '
|
||||
"Do not provide any other information."
|
||||
"Answer the prompt in markdown format. "
|
||||
"Preserve the language and markdown formatting. "
|
||||
"Do not provide any other information. "
|
||||
"Preserve the language."
|
||||
),
|
||||
"correct": (
|
||||
"Correct grammar and spelling of the markdown text, "
|
||||
"preserving language and markdown formatting. "
|
||||
'Return JSON: {"answer": "your corrected markdown text"}. '
|
||||
"Do not provide any other information."
|
||||
"Do not provide any other information. "
|
||||
"Preserve the language."
|
||||
),
|
||||
"rephrase": (
|
||||
"Rephrase the given markdown text, "
|
||||
"preserving language and markdown formatting. "
|
||||
'Return JSON: {"answer": "your rephrased markdown text"}. '
|
||||
"Do not provide any other information."
|
||||
"Do not provide any other information. "
|
||||
"Preserve the language."
|
||||
),
|
||||
"summarize": (
|
||||
"Summarize the markdown text, preserving language and markdown formatting. "
|
||||
'Return JSON: {"answer": "your markdown summary"}. '
|
||||
"Do not provide any other information."
|
||||
"Do not provide any other information. "
|
||||
"Preserve the language."
|
||||
),
|
||||
"beautify": (
|
||||
"Add formatting to the text to make it more readable. "
|
||||
"Do not provide any other information. "
|
||||
"Preserve the language."
|
||||
),
|
||||
"emojify": (
|
||||
"Add emojis to the important parts of the text. "
|
||||
"Do not provide any other information. "
|
||||
"Preserve the language."
|
||||
),
|
||||
}
|
||||
|
||||
AI_TRANSLATE = (
|
||||
"Translate the markdown text to {language:s}, preserving markdown formatting. "
|
||||
'Return JSON: {{"answer": "your translated markdown text in {language:s}"}}. '
|
||||
"Keep the same html stucture and formatting. "
|
||||
"Translate the content in the html to the specified language {language:s}. "
|
||||
"Check the translation for accuracy and make any necessary corrections. "
|
||||
"Do not provide any other information."
|
||||
)
|
||||
|
||||
@@ -59,32 +68,18 @@ class AIService:
|
||||
"""Helper method to call the OpenAI API and process the response."""
|
||||
response = self.client.chat.completions.create(
|
||||
model=settings.AI_MODEL,
|
||||
response_format={"type": "json_object"},
|
||||
messages=[
|
||||
{"role": "system", "content": system_content},
|
||||
{"role": "user", "content": json.dumps({"markdown_input": text})},
|
||||
{"role": "user", "content": text},
|
||||
],
|
||||
)
|
||||
|
||||
content = response.choices[0].message.content
|
||||
|
||||
try:
|
||||
sanitized_content = re.sub(r'\s*"answer"\s*:\s*', '"answer": ', content)
|
||||
sanitized_content = re.sub(r"\s*\}", "}", sanitized_content)
|
||||
sanitized_content = re.sub(r"(?<!\\)\n", "\\\\n", sanitized_content)
|
||||
sanitized_content = re.sub(r"(?<!\\)\t", "\\\\t", sanitized_content)
|
||||
|
||||
json_response = json.loads(sanitized_content)
|
||||
except (json.JSONDecodeError, IndexError):
|
||||
try:
|
||||
json_response = json.loads(content)
|
||||
except json.JSONDecodeError as err:
|
||||
raise RuntimeError("AI response is not valid JSON", content) from err
|
||||
|
||||
if "answer" not in json_response:
|
||||
if not content:
|
||||
raise RuntimeError("AI response does not contain an answer")
|
||||
|
||||
return json_response
|
||||
return {"answer": content}
|
||||
|
||||
def transform(self, text, action):
|
||||
"""Transform text based on specified action."""
|
||||
|
||||
@@ -16,6 +16,9 @@ from core.tests.conftest import TEAM, USER, VIA
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
# Create
|
||||
|
||||
|
||||
def test_api_document_accesses_create_anonymous():
|
||||
"""Anonymous users should not be allowed to create document accesses."""
|
||||
document = factories.DocumentFactory()
|
||||
@@ -123,7 +126,7 @@ def test_api_document_accesses_create_authenticated_administrator(via, mock_user
|
||||
document=document, team="lasuite", role="administrator"
|
||||
)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
other_user = factories.UserFactory(language="en-us")
|
||||
|
||||
# It should not be allowed to create an owner access
|
||||
response = client.post(
|
||||
@@ -199,7 +202,7 @@ def test_api_document_accesses_create_authenticated_owner(via, mock_user_teams):
|
||||
document=document, team="lasuite", role="owner"
|
||||
)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
other_user = factories.UserFactory(language="en-us")
|
||||
|
||||
role = random.choice([role[0] for role in models.RoleChoices.choices])
|
||||
|
||||
@@ -235,3 +238,73 @@ def test_api_document_accesses_create_authenticated_owner(via, mock_user_teams):
|
||||
f"on the following document: {document.title}"
|
||||
) in email_content
|
||||
assert "docs/" + str(document.id) + "/" in email_content
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_document_accesses_create_email_in_receivers_language(via, mock_user_teams):
|
||||
"""
|
||||
The email sent to the accesses to notify them of the adding, should be in their language.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="lasuite", role="owner"
|
||||
)
|
||||
|
||||
role = random.choice([role[0] for role in models.RoleChoices.choices])
|
||||
|
||||
assert len(mail.outbox) == 0
|
||||
|
||||
other_users = (
|
||||
factories.UserFactory(language="en-us"),
|
||||
factories.UserFactory(language="fr-fr"),
|
||||
)
|
||||
|
||||
for index, other_user in enumerate(other_users):
|
||||
expected_language = other_user.language
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/accesses/",
|
||||
{
|
||||
"user_id": str(other_user.id),
|
||||
"role": role,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert models.DocumentAccess.objects.filter(user=other_user).count() == 1
|
||||
new_document_access = models.DocumentAccess.objects.filter(
|
||||
user=other_user
|
||||
).get()
|
||||
other_user_data = serializers.UserSerializer(instance=other_user).data
|
||||
assert response.json() == {
|
||||
"id": str(new_document_access.id),
|
||||
"user": other_user_data,
|
||||
"team": "",
|
||||
"role": role,
|
||||
"abilities": new_document_access.get_abilities(user),
|
||||
}
|
||||
assert len(mail.outbox) == index + 1
|
||||
email = mail.outbox[index]
|
||||
assert email.to == [other_user_data["email"]]
|
||||
email_content = " ".join(email.body.split())
|
||||
email_subject = " ".join(email.subject.split())
|
||||
if expected_language == "en-us":
|
||||
assert (
|
||||
f"{user.full_name} shared a document with you: {document.title}".lower()
|
||||
in email_subject.lower()
|
||||
)
|
||||
elif expected_language == "fr-fr":
|
||||
assert (
|
||||
f"{user.full_name} a partagé un document avec vous: {document.title}".lower()
|
||||
in email_subject.lower()
|
||||
)
|
||||
assert "docs/" + str(document.id) + "/" in email_content.lower()
|
||||
|
||||
@@ -370,7 +370,7 @@ def test_api_document_invitations_create_privileged_members(
|
||||
Only owners and administrators should be able to invite new users.
|
||||
Only owners can invite owners.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(language="en-us")
|
||||
document = factories.DocumentFactory()
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role=inviting)
|
||||
@@ -422,11 +422,12 @@ def test_api_document_invitations_create_privileged_members(
|
||||
}
|
||||
|
||||
|
||||
def test_api_document_invitations_create_email_from_content_language():
|
||||
def test_api_document_invitations_create_email_from_senders_language():
|
||||
"""
|
||||
The email generated is from the language set in the Content-Language header
|
||||
When inviting on a document a user who does not exist yet in our database,
|
||||
the invitation email should be sent in the language of the sending user.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user = factories.UserFactory(language="fr-fr")
|
||||
document = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
|
||||
|
||||
@@ -444,7 +445,6 @@ def test_api_document_invitations_create_email_from_content_language():
|
||||
f"/api/v1.0/documents/{document.id!s}/invitations/",
|
||||
invitation_values,
|
||||
format="json",
|
||||
headers={"Content-Language": "fr-fr"},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
@@ -464,50 +464,11 @@ def test_api_document_invitations_create_email_from_content_language():
|
||||
)
|
||||
|
||||
|
||||
def test_api_document_invitations_create_email_from_content_language_not_supported():
|
||||
"""
|
||||
If the language from the Content-Language is not supported
|
||||
it will display the default language, English.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
|
||||
|
||||
invitation_values = {
|
||||
"email": "guest@example.com",
|
||||
"role": "reader",
|
||||
}
|
||||
|
||||
assert len(mail.outbox) == 0
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/invitations/",
|
||||
invitation_values,
|
||||
format="json",
|
||||
headers={"Content-Language": "not-supported"},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json()["email"] == "guest@example.com"
|
||||
assert models.Invitation.objects.count() == 1
|
||||
assert len(mail.outbox) == 1
|
||||
|
||||
email = mail.outbox[0]
|
||||
|
||||
assert email.to == ["guest@example.com"]
|
||||
|
||||
email_content = " ".join(email.body.split())
|
||||
assert f"{user.full_name} shared a document with you!" in email_content
|
||||
|
||||
|
||||
def test_api_document_invitations_create_email_full_name_empty():
|
||||
"""
|
||||
If the full name of the user is empty, it will display the email address.
|
||||
"""
|
||||
user = factories.UserFactory(full_name="")
|
||||
user = factories.UserFactory(full_name="", language="en-us")
|
||||
document = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
|
||||
|
||||
@@ -599,9 +560,11 @@ def test_api_document_invitations_create_cannot_duplicate_invitation():
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == [
|
||||
"Document invitation with this Email address and Document already exists."
|
||||
]
|
||||
assert response.json() == {
|
||||
"__all__": [
|
||||
"Document invitation with this Email address and Document already exists."
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_api_document_invitations_create_cannot_invite_existing_users():
|
||||
|
||||
@@ -71,9 +71,8 @@ def test_api_documents_ai_transform_anonymous_success(mock_create):
|
||||
"""
|
||||
document = factories.DocumentFactory(link_reach="public", link_role="editor")
|
||||
|
||||
answer = '{"answer": "Salut"}'
|
||||
mock_create.return_value = MagicMock(
|
||||
choices=[MagicMock(message=MagicMock(content=answer))]
|
||||
choices=[MagicMock(message=MagicMock(content="Salut"))]
|
||||
)
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
|
||||
@@ -83,17 +82,15 @@ def test_api_documents_ai_transform_anonymous_success(mock_create):
|
||||
assert response.json() == {"answer": "Salut"}
|
||||
mock_create.assert_called_once_with(
|
||||
model="llama",
|
||||
response_format={"type": "json_object"},
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Summarize the markdown text, preserving language and markdown formatting. "
|
||||
'Return JSON: {"answer": "your markdown summary"}. Do not provide any other '
|
||||
"information."
|
||||
"Do not provide any other information. Preserve the language."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": '{"markdown_input": "Hello"}'},
|
||||
{"role": "user", "content": "Hello"},
|
||||
],
|
||||
)
|
||||
|
||||
@@ -170,9 +167,8 @@ def test_api_documents_ai_transform_authenticated_success(mock_create, reach, ro
|
||||
|
||||
document = factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
|
||||
answer = '{"answer": "Salut"}'
|
||||
mock_create.return_value = MagicMock(
|
||||
choices=[MagicMock(message=MagicMock(content=answer))]
|
||||
choices=[MagicMock(message=MagicMock(content="Salut"))]
|
||||
)
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
|
||||
@@ -182,16 +178,15 @@ def test_api_documents_ai_transform_authenticated_success(mock_create, reach, ro
|
||||
assert response.json() == {"answer": "Salut"}
|
||||
mock_create.assert_called_once_with(
|
||||
model="llama",
|
||||
response_format={"type": "json_object"},
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
'Answer the prompt in markdown format. Return JSON: {"answer": '
|
||||
'"Your markdown answer"}. Do not provide any other information.'
|
||||
"Answer the prompt in markdown format. Preserve the language and markdown "
|
||||
"formatting. Do not provide any other information. Preserve the language."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": '{"markdown_input": "Hello"}'},
|
||||
{"role": "user", "content": "Hello"},
|
||||
],
|
||||
)
|
||||
|
||||
@@ -246,9 +241,8 @@ def test_api_documents_ai_transform_success(mock_create, via, role, mock_user_te
|
||||
document=document, team="lasuite", role=role
|
||||
)
|
||||
|
||||
answer = '{"answer": "Salut"}'
|
||||
mock_create.return_value = MagicMock(
|
||||
choices=[MagicMock(message=MagicMock(content=answer))]
|
||||
choices=[MagicMock(message=MagicMock(content="Salut"))]
|
||||
)
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
|
||||
@@ -258,16 +252,15 @@ def test_api_documents_ai_transform_success(mock_create, via, role, mock_user_te
|
||||
assert response.json() == {"answer": "Salut"}
|
||||
mock_create.assert_called_once_with(
|
||||
model="llama",
|
||||
response_format={"type": "json_object"},
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
'Answer the prompt in markdown format. Return JSON: {"answer": '
|
||||
'"Your markdown answer"}. Do not provide any other information.'
|
||||
"Answer the prompt in markdown format. Preserve the language and markdown "
|
||||
"formatting. Do not provide any other information. Preserve the language."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": '{"markdown_input": "Hello"}'},
|
||||
{"role": "user", "content": "Hello"},
|
||||
],
|
||||
)
|
||||
|
||||
@@ -315,9 +308,8 @@ def test_api_documents_ai_transform_throttling_document(mock_create):
|
||||
client = APIClient()
|
||||
document = factories.DocumentFactory(link_reach="public", link_role="editor")
|
||||
|
||||
answer = '{"answer": "Salut"}'
|
||||
mock_create.return_value = MagicMock(
|
||||
choices=[MagicMock(message=MagicMock(content=answer))]
|
||||
choices=[MagicMock(message=MagicMock(content="Salut"))]
|
||||
)
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
|
||||
@@ -350,9 +342,8 @@ def test_api_documents_ai_transform_throttling_user(mock_create):
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
answer = '{"answer": "Salut"}'
|
||||
mock_create.return_value = MagicMock(
|
||||
choices=[MagicMock(message=MagicMock(content=answer))]
|
||||
choices=[MagicMock(message=MagicMock(content="Salut"))]
|
||||
)
|
||||
|
||||
for _ in range(3):
|
||||
|
||||
@@ -91,29 +91,28 @@ def test_api_documents_ai_translate_anonymous_success(mock_create):
|
||||
"""
|
||||
document = factories.DocumentFactory(link_reach="public", link_role="editor")
|
||||
|
||||
answer = '{"answer": "Salut"}'
|
||||
mock_create.return_value = MagicMock(
|
||||
choices=[MagicMock(message=MagicMock(content=answer))]
|
||||
choices=[MagicMock(message=MagicMock(content="Ola"))]
|
||||
)
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
|
||||
response = APIClient().post(url, {"text": "Hello", "language": "es"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"answer": "Salut"}
|
||||
assert response.json() == {"answer": "Ola"}
|
||||
mock_create.assert_called_once_with(
|
||||
model="llama",
|
||||
response_format={"type": "json_object"},
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Translate the markdown text to Spanish, preserving markdown formatting. "
|
||||
'Return JSON: {"answer": "your translated markdown text in Spanish"}. '
|
||||
"Keep the same html stucture and formatting. "
|
||||
"Translate the content in the html to the specified language Spanish. "
|
||||
"Check the translation for accuracy and make any necessary corrections. "
|
||||
"Do not provide any other information."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": '{"markdown_input": "Hello"}'},
|
||||
{"role": "user", "content": "Hello"},
|
||||
],
|
||||
)
|
||||
|
||||
@@ -190,9 +189,8 @@ def test_api_documents_ai_translate_authenticated_success(mock_create, reach, ro
|
||||
|
||||
document = factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
|
||||
answer = '{"answer": "Salut"}'
|
||||
mock_create.return_value = MagicMock(
|
||||
choices=[MagicMock(message=MagicMock(content=answer))]
|
||||
choices=[MagicMock(message=MagicMock(content="Salut"))]
|
||||
)
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
|
||||
@@ -202,18 +200,18 @@ def test_api_documents_ai_translate_authenticated_success(mock_create, reach, ro
|
||||
assert response.json() == {"answer": "Salut"}
|
||||
mock_create.assert_called_once_with(
|
||||
model="llama",
|
||||
response_format={"type": "json_object"},
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Translate the markdown text to Colombian Spanish, "
|
||||
"preserving markdown formatting. Return JSON: "
|
||||
'{"answer": "your translated markdown text in Colombian Spanish"}. '
|
||||
"Keep the same html stucture and formatting. "
|
||||
"Translate the content in the html to the "
|
||||
"specified language Colombian Spanish. "
|
||||
"Check the translation for accuracy and make any necessary corrections. "
|
||||
"Do not provide any other information."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": '{"markdown_input": "Hello"}'},
|
||||
{"role": "user", "content": "Hello"},
|
||||
],
|
||||
)
|
||||
|
||||
@@ -268,9 +266,8 @@ def test_api_documents_ai_translate_success(mock_create, via, role, mock_user_te
|
||||
document=document, team="lasuite", role=role
|
||||
)
|
||||
|
||||
answer = '{"answer": "Salut"}'
|
||||
mock_create.return_value = MagicMock(
|
||||
choices=[MagicMock(message=MagicMock(content=answer))]
|
||||
choices=[MagicMock(message=MagicMock(content="Salut"))]
|
||||
)
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
|
||||
@@ -280,18 +277,18 @@ def test_api_documents_ai_translate_success(mock_create, via, role, mock_user_te
|
||||
assert response.json() == {"answer": "Salut"}
|
||||
mock_create.assert_called_once_with(
|
||||
model="llama",
|
||||
response_format={"type": "json_object"},
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Translate the markdown text to Colombian Spanish, "
|
||||
"preserving markdown formatting. Return JSON: "
|
||||
'{"answer": "your translated markdown text in Colombian Spanish"}. '
|
||||
"Keep the same html stucture and formatting. "
|
||||
"Translate the content in the html to the "
|
||||
"specified language Colombian Spanish. "
|
||||
"Check the translation for accuracy and make any necessary corrections. "
|
||||
"Do not provide any other information."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": '{"markdown_input": "Hello"}'},
|
||||
{"role": "user", "content": "Hello"},
|
||||
],
|
||||
)
|
||||
|
||||
@@ -339,9 +336,8 @@ def test_api_documents_ai_translate_throttling_document(mock_create):
|
||||
client = APIClient()
|
||||
document = factories.DocumentFactory(link_reach="public", link_role="editor")
|
||||
|
||||
answer = '{"answer": "Salut"}'
|
||||
mock_create.return_value = MagicMock(
|
||||
choices=[MagicMock(message=MagicMock(content=answer))]
|
||||
choices=[MagicMock(message=MagicMock(content="Salut"))]
|
||||
)
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
|
||||
@@ -374,9 +370,8 @@ def test_api_documents_ai_translate_throttling_user(mock_create):
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
answer = '{"answer": "Salut"}'
|
||||
mock_create.return_value = MagicMock(
|
||||
choices=[MagicMock(message=MagicMock(content=answer))]
|
||||
choices=[MagicMock(message=MagicMock(content="Salut"))]
|
||||
)
|
||||
|
||||
for _ in range(3):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Tests for Documents API endpoint in impress's core app: create
|
||||
Tests for Documents API endpoint in impress's core app: children create
|
||||
"""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Tests for Documents API endpoint in impress's core app: retrieve
|
||||
Tests for Documents API endpoint in impress's core app: children list
|
||||
"""
|
||||
|
||||
import random
|
||||
@@ -15,7 +15,7 @@ pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_documents_children_list_anonymous_public_standalone():
|
||||
"""Anonymous users should be allowed to retrieve the children of a public documents."""
|
||||
"""Anonymous users should be allowed to retrieve the children of a public document."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
@@ -39,7 +39,8 @@ def test_api_documents_children_list_anonymous_public_standalone():
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -56,7 +57,8 @@ def test_api_documents_children_list_anonymous_public_standalone():
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -100,7 +102,8 @@ def test_api_documents_children_list_anonymous_public_parent():
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -117,7 +120,8 @@ def test_api_documents_children_list_anonymous_public_parent():
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -179,7 +183,8 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -196,7 +201,8 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -244,7 +250,8 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -261,7 +268,8 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -331,7 +339,8 @@ def test_api_documents_children_list_authenticated_related_direct():
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses": 3,
|
||||
"nb_accesses_ancestors": 3,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -348,7 +357,8 @@ def test_api_documents_children_list_authenticated_related_direct():
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses": 2,
|
||||
"nb_accesses_ancestors": 2,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -399,7 +409,8 @@ def test_api_documents_children_list_authenticated_related_parent():
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses": 2,
|
||||
"nb_accesses_ancestors": 2,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -416,7 +427,8 @@ def test_api_documents_children_list_authenticated_related_parent():
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -514,7 +526,8 @@ def test_api_documents_children_list_authenticated_related_team_members(
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -531,7 +544,8 @@ def test_api_documents_children_list_authenticated_related_team_members(
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Test on the CORS proxy API for documents."""
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_docs_cors_proxy_valid_url():
|
||||
"""Test the CORS proxy API for documents with a valid URL."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
|
||||
client = APIClient()
|
||||
url_to_fetch = "https://external-url.com/assets/logo-gouv.png"
|
||||
responses.get(url_to_fetch, body=b"", status=200, content_type="image/png")
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.headers["Content-Type"] == "image/png"
|
||||
assert response.headers["Content-Disposition"] == "attachment;"
|
||||
assert (
|
||||
response.headers["Content-Security-Policy"]
|
||||
== "default-src 'none'; img-src 'none' data:;"
|
||||
)
|
||||
assert response.streaming_content
|
||||
|
||||
|
||||
def test_api_docs_cors_proxy_without_url_query_string():
|
||||
"""Test the CORS proxy API for documents without a URL query string."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
|
||||
client = APIClient()
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/cors-proxy/")
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"detail": "Missing 'url' query parameter"}
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_docs_cors_proxy_anonymous_document_not_public():
|
||||
"""Test the CORS proxy API for documents with an anonymous user and a non-public document."""
|
||||
document = factories.DocumentFactory(link_reach="authenticated")
|
||||
|
||||
client = APIClient()
|
||||
url_to_fetch = "https://external-url.com/assets/logo-gouv.png"
|
||||
responses.get(url_to_fetch, body=b"", status=200, content_type="image/png")
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_docs_cors_proxy_authenticated_user_accessing_protected_doc():
|
||||
"""
|
||||
Test the CORS proxy API for documents with an authenticated user accessing a protected
|
||||
document.
|
||||
"""
|
||||
document = factories.DocumentFactory(link_reach="authenticated")
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
url_to_fetch = "https://external-url.com/assets/logo-gouv.png"
|
||||
responses.get(url_to_fetch, body=b"", status=200, content_type="image/png")
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.headers["Content-Type"] == "image/png"
|
||||
assert response.headers["Content-Disposition"] == "attachment;"
|
||||
assert (
|
||||
response.headers["Content-Security-Policy"]
|
||||
== "default-src 'none'; img-src 'none' data:;"
|
||||
)
|
||||
assert response.streaming_content
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_docs_cors_proxy_authenticated_not_accessing_restricted_doc():
|
||||
"""
|
||||
Test the CORS proxy API for documents with an authenticated user not accessing a restricted
|
||||
document.
|
||||
"""
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
url_to_fetch = "https://external-url.com/assets/logo-gouv.png"
|
||||
responses.get(url_to_fetch, body=b"", status=200, content_type="image/png")
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_docs_cors_proxy_unsupported_media_type():
|
||||
"""Test the CORS proxy API for documents with an unsupported media type."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
|
||||
client = APIClient()
|
||||
url_to_fetch = "https://external-url.com/assets/index.html"
|
||||
responses.get(url_to_fetch, body=b"", status=200, content_type="text/html")
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
|
||||
)
|
||||
assert response.status_code == 415
|
||||
@@ -0,0 +1,696 @@
|
||||
"""
|
||||
Tests for Documents API endpoint in impress's core app: descendants
|
||||
"""
|
||||
|
||||
import random
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_documents_descendants_list_anonymous_public_standalone():
|
||||
"""Anonymous users should be allowed to retrieve the descendants of a public document."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/descendants/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 3,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"abilities": child1.get_abilities(AnonymousUser()),
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"depth": 2,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [],
|
||||
},
|
||||
{
|
||||
"abilities": grand_child.get_abilities(AnonymousUser()),
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_child.creator.id),
|
||||
"depth": 3,
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": grand_child.path,
|
||||
"title": grand_child.title,
|
||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [],
|
||||
},
|
||||
{
|
||||
"abilities": child2.get_abilities(AnonymousUser()),
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"depth": 2,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_descendants_list_anonymous_public_parent():
|
||||
"""
|
||||
Anonymous users should be allowed to retrieve the descendants of a document who
|
||||
has a public ancestor.
|
||||
"""
|
||||
grand_parent = factories.DocumentFactory(link_reach="public")
|
||||
parent = factories.DocumentFactory(
|
||||
parent=grand_parent, link_reach=random.choice(["authenticated", "restricted"])
|
||||
)
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=random.choice(["authenticated", "restricted"]), parent=parent
|
||||
)
|
||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/descendants/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 3,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"abilities": child1.get_abilities(AnonymousUser()),
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"depth": 4,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [],
|
||||
},
|
||||
{
|
||||
"abilities": grand_child.get_abilities(AnonymousUser()),
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_child.creator.id),
|
||||
"depth": 5,
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": grand_child.path,
|
||||
"title": grand_child.title,
|
||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [],
|
||||
},
|
||||
{
|
||||
"abilities": child2.get_abilities(AnonymousUser()),
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"depth": 4,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", ["restricted", "authenticated"])
|
||||
def test_api_documents_descendants_list_anonymous_restricted_or_authenticated(reach):
|
||||
"""
|
||||
Anonymous users should not be able to retrieve descendants of a document that is not public.
|
||||
"""
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
child = factories.DocumentFactory(parent=document)
|
||||
_grand_child = factories.DocumentFactory(parent=child)
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/descendants/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", ["public", "authenticated"])
|
||||
def test_api_documents_descendants_list_authenticated_unrelated_public_or_authenticated(
|
||||
reach,
|
||||
):
|
||||
"""
|
||||
Authenticated users should be able to retrieve the descendants of a public/authenticated
|
||||
document to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/descendants/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 3,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"abilities": child1.get_abilities(user),
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"depth": 2,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [],
|
||||
},
|
||||
{
|
||||
"abilities": grand_child.get_abilities(user),
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_child.creator.id),
|
||||
"depth": 3,
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": grand_child.path,
|
||||
"title": grand_child.title,
|
||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [],
|
||||
},
|
||||
{
|
||||
"abilities": child2.get_abilities(user),
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"depth": 2,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", ["public", "authenticated"])
|
||||
def test_api_documents_descendants_list_authenticated_public_or_authenticated_parent(
|
||||
reach,
|
||||
):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve the descendants of a document who
|
||||
has a public or authenticated ancestor.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
grand_parent = factories.DocumentFactory(link_reach=reach)
|
||||
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
|
||||
document = factories.DocumentFactory(link_reach="restricted", parent=parent)
|
||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/descendants/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 3,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"abilities": child1.get_abilities(user),
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"depth": 4,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [],
|
||||
},
|
||||
{
|
||||
"abilities": grand_child.get_abilities(user),
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_child.creator.id),
|
||||
"depth": 5,
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": grand_child.path,
|
||||
"title": grand_child.title,
|
||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [],
|
||||
},
|
||||
{
|
||||
"abilities": child2.get_abilities(user),
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"depth": 4,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_descendants_list_authenticated_unrelated_restricted():
|
||||
"""
|
||||
Authenticated users should not be allowed to retrieve the descendants of a document that is
|
||||
restricted and to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
_grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/descendants/",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_descendants_list_authenticated_related_direct():
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve the descendants of a document
|
||||
to which they are directly related whatever the role.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
access = factories.UserDocumentAccessFactory(document=document, user=user)
|
||||
factories.UserDocumentAccessFactory(document=document)
|
||||
|
||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/descendants/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 3,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"abilities": child1.get_abilities(user),
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"depth": 2,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
"nb_accesses_ancestors": 3,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [access.role],
|
||||
},
|
||||
{
|
||||
"abilities": grand_child.get_abilities(user),
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_child.creator.id),
|
||||
"depth": 3,
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 3,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": grand_child.path,
|
||||
"title": grand_child.title,
|
||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [access.role],
|
||||
},
|
||||
{
|
||||
"abilities": child2.get_abilities(user),
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"depth": 2,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 2,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [access.role],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_descendants_list_authenticated_related_parent():
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve the descendants of a document if they
|
||||
are related to one of its ancestors whatever the role.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
grand_parent = factories.DocumentFactory(link_reach="restricted")
|
||||
grand_parent_access = factories.UserDocumentAccessFactory(
|
||||
document=grand_parent, user=user
|
||||
)
|
||||
|
||||
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
|
||||
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
|
||||
|
||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/descendants/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 3,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"abilities": child1.get_abilities(user),
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"depth": 4,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
"nb_accesses_ancestors": 2,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [grand_parent_access.role],
|
||||
},
|
||||
{
|
||||
"abilities": grand_child.get_abilities(user),
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_child.creator.id),
|
||||
"depth": 5,
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 2,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": grand_child.path,
|
||||
"title": grand_child.title,
|
||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [grand_parent_access.role],
|
||||
},
|
||||
{
|
||||
"abilities": child2.get_abilities(user),
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"depth": 4,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [grand_parent_access.role],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_descendants_list_authenticated_related_child():
|
||||
"""
|
||||
Authenticated users should not be allowed to retrieve all the descendants of a document
|
||||
as a result of being related to one of its children.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
_grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1, user=user)
|
||||
factories.UserDocumentAccessFactory(document=document)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/descendants/",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_descendants_list_authenticated_related_team_none(
|
||||
mock_user_teams,
|
||||
):
|
||||
"""
|
||||
Authenticated users should not be able to retrieve the descendants of a restricted document
|
||||
related to teams in which the user is not.
|
||||
"""
|
||||
mock_user_teams.return_value = []
|
||||
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
factories.DocumentFactory.create_batch(2, parent=document)
|
||||
|
||||
factories.TeamDocumentAccessFactory(document=document, team="myteam")
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/descendants/")
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_descendants_list_authenticated_related_team_members(
|
||||
mock_user_teams,
|
||||
):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve the descendants of a document to which they
|
||||
are related via a team whatever the role.
|
||||
"""
|
||||
mock_user_teams.return_value = ["myteam"]
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
access = factories.TeamDocumentAccessFactory(document=document, team="myteam")
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/descendants/")
|
||||
|
||||
# pylint: disable=R0801
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 3,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"abilities": child1.get_abilities(user),
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"depth": 2,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [access.role],
|
||||
},
|
||||
{
|
||||
"abilities": grand_child.get_abilities(user),
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_child.creator.id),
|
||||
"depth": 3,
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": grand_child.path,
|
||||
"title": grand_child.title,
|
||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [access.role],
|
||||
},
|
||||
{
|
||||
"abilities": child2.get_abilities(user),
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"depth": 2,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": [access.role],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
Tests for Documents API endpoint in impress's core app: list
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from faker import Faker
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
|
||||
fake = Faker()
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
# Filters: unknown field
|
||||
|
||||
|
||||
def test_api_documents_descendants_filter_unknown_field():
|
||||
"""
|
||||
Trying to filter by an unknown field should be ignored.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory()
|
||||
|
||||
document = factories.DocumentFactory(users=[user])
|
||||
expected_ids = {
|
||||
str(document.id)
|
||||
for document in factories.DocumentFactory.create_batch(2, parent=document)
|
||||
}
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/descendants/?unknown=true"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 2
|
||||
assert {result["id"] for result in results} == expected_ids
|
||||
|
||||
|
||||
# Filters: title
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"query,nb_results",
|
||||
[
|
||||
("Project Alpha", 1), # Exact match
|
||||
("project", 2), # Partial match (case-insensitive)
|
||||
("Guide", 1), # Word match within a title
|
||||
("Special", 0), # No match (nonexistent keyword)
|
||||
("2024", 2), # Match by numeric keyword
|
||||
("", 5), # Empty string
|
||||
],
|
||||
)
|
||||
def test_api_documents_descendants_filter_title(query, nb_results):
|
||||
"""Authenticated users should be able to search documents by their title."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(users=[user])
|
||||
|
||||
# Create documents with predefined titles
|
||||
titles = [
|
||||
"Project Alpha Documentation",
|
||||
"Project Beta Overview",
|
||||
"User Guide",
|
||||
"Financial Report 2024",
|
||||
"Annual Review 2024",
|
||||
]
|
||||
for title in titles:
|
||||
factories.DocumentFactory(title=title, parent=document)
|
||||
|
||||
# Perform the search query
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/descendants/?title={query:s}"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == nb_results
|
||||
|
||||
# Ensure all results contain the query in their title
|
||||
for result in results:
|
||||
assert query.lower().strip() in result["title"].lower()
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Test for the document favorite_list endpoint."""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_document_favorite_list_anonymous():
|
||||
"""Anonymous users should receive a 401 error."""
|
||||
client = APIClient()
|
||||
|
||||
response = client.get("/api/v1.0/documents/favorite_list/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_api_document_favorite_list_authenticated_no_favorite():
|
||||
"""Authenticated users should receive an empty list."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/documents/favorite_list/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 0,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [],
|
||||
}
|
||||
|
||||
|
||||
def test_api_document_favorite_list_authenticated_with_favorite():
|
||||
"""Authenticated users with a favorite should receive the favorite."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# User don't have access to this document, let say it had access and this access has been
|
||||
# removed. It should not be in the favorite list anymore.
|
||||
factories.DocumentFactory(favorited_by=[user])
|
||||
|
||||
document = factories.UserDocumentAccessFactory(
|
||||
user=user, role=models.RoleChoices.READER, document__favorited_by=[user]
|
||||
).document
|
||||
|
||||
response = client.get("/api/v1.0/documents/favorite_list/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 1,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"abilities": document.get_abilities(user),
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"content": document.content,
|
||||
"depth": document.depth,
|
||||
"excerpt": document.excerpt,
|
||||
"id": str(document.id),
|
||||
"is_favorite": True,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"numchild": document.numchild,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_roles": ["reader"],
|
||||
}
|
||||
],
|
||||
}
|
||||
@@ -70,7 +70,8 @@ def test_api_documents_list_format():
|
||||
"is_favorite": True,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses": 3,
|
||||
"nb_accesses_ancestors": 3,
|
||||
"nb_accesses_direct": 3,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
@@ -147,7 +148,7 @@ def test_api_documents_list_authenticated_direct(django_assert_num_queries):
|
||||
str(child4_with_access.id),
|
||||
}
|
||||
|
||||
with django_assert_num_queries(8):
|
||||
with django_assert_num_queries(12):
|
||||
response = client.get("/api/v1.0/documents/")
|
||||
|
||||
# nb_accesses should now be cached
|
||||
@@ -185,7 +186,7 @@ def test_api_documents_list_authenticated_via_team(
|
||||
|
||||
expected_ids = {str(document.id) for document in documents_team1 + documents_team2}
|
||||
|
||||
with django_assert_num_queries(9):
|
||||
with django_assert_num_queries(14):
|
||||
response = client.get("/api/v1.0/documents/")
|
||||
|
||||
# nb_accesses should now be cached
|
||||
@@ -218,7 +219,7 @@ def test_api_documents_list_authenticated_link_reach_restricted(
|
||||
other_document = factories.DocumentFactory(link_reach="public")
|
||||
models.LinkTrace.objects.create(document=other_document, user=user)
|
||||
|
||||
with django_assert_num_queries(5):
|
||||
with django_assert_num_queries(6):
|
||||
response = client.get("/api/v1.0/documents/")
|
||||
|
||||
# nb_accesses should now be cached
|
||||
@@ -267,7 +268,7 @@ def test_api_documents_list_authenticated_link_reach_public_or_authenticated(
|
||||
|
||||
expected_ids = {str(document1.id), str(document2.id), str(visible_child.id)}
|
||||
|
||||
with django_assert_num_queries(7):
|
||||
with django_assert_num_queries(10):
|
||||
response = client.get("/api/v1.0/documents/")
|
||||
|
||||
# nb_accesses should now be cached
|
||||
@@ -328,6 +329,35 @@ def test_api_documents_list_pagination(
|
||||
assert document_ids == []
|
||||
|
||||
|
||||
def test_api_documents_list_pagination_force_page_size():
|
||||
"""Page size can be set via querystring."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document_ids = [
|
||||
str(access.document_id)
|
||||
for access in factories.UserDocumentAccessFactory.create_batch(3, user=user)
|
||||
]
|
||||
|
||||
# Force page size
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/?page_size=2",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == 3
|
||||
assert content["next"] == "http://testserver/api/v1.0/documents/?page=2&page_size=2"
|
||||
assert content["previous"] is None
|
||||
|
||||
assert len(content["results"]) == 2
|
||||
for item in content["results"]:
|
||||
document_ids.remove(item["id"])
|
||||
|
||||
|
||||
def test_api_documents_list_authenticated_distinct():
|
||||
"""A document with several related users should only be listed once."""
|
||||
user = factories.UserFactory()
|
||||
@@ -362,7 +392,7 @@ def test_api_documents_list_favorites_no_extra_queries(django_assert_num_queries
|
||||
factories.DocumentFactory.create_batch(2, users=[user])
|
||||
|
||||
url = "/api/v1.0/documents/"
|
||||
with django_assert_num_queries(9):
|
||||
with django_assert_num_queries(14):
|
||||
response = client.get(url)
|
||||
|
||||
# nb_accesses should now be cached
|
||||
|
||||
@@ -34,16 +34,24 @@ def test_api_documents_retrieve_anonymous_public_standalone():
|
||||
"children_create": False,
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"cors_proxy": True,
|
||||
"descendants": True,
|
||||
"destroy": False,
|
||||
# Anonymous user can't favorite a document even with read access
|
||||
"favorite": False,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": ["reader", "editor"],
|
||||
},
|
||||
"media_auth": True,
|
||||
"move": False,
|
||||
"partial_update": document.link_role == "editor",
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
"update": document.link_role == "editor",
|
||||
"versions_destroy": False,
|
||||
"versions_list": False,
|
||||
@@ -57,7 +65,8 @@ def test_api_documents_retrieve_anonymous_public_standalone():
|
||||
"is_favorite": False,
|
||||
"link_reach": "public",
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
@@ -79,6 +88,7 @@ def test_api_documents_retrieve_anonymous_public_parent():
|
||||
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
links = document.get_ancestors().values("link_reach", "link_role")
|
||||
assert response.json() == {
|
||||
"id": str(document.id),
|
||||
"abilities": {
|
||||
@@ -90,16 +100,20 @@ def test_api_documents_retrieve_anonymous_public_parent():
|
||||
"children_create": False,
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"descendants": True,
|
||||
"cors_proxy": True,
|
||||
"destroy": False,
|
||||
# Anonymous user can't favorite a document even with read access
|
||||
"favorite": False,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"link_select_options": models.LinkReachChoices.get_select_options(links),
|
||||
"media_auth": True,
|
||||
"move": False,
|
||||
"partial_update": grand_parent.link_role == "editor",
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
"update": grand_parent.link_role == "editor",
|
||||
"versions_destroy": False,
|
||||
"versions_list": False,
|
||||
@@ -113,7 +127,8 @@ def test_api_documents_retrieve_anonymous_public_parent():
|
||||
"is_favorite": False,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
@@ -180,15 +195,23 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
|
||||
"children_create": document.link_role == "editor",
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"descendants": True,
|
||||
"cors_proxy": True,
|
||||
"destroy": False,
|
||||
"favorite": True,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": ["reader", "editor"],
|
||||
},
|
||||
"media_auth": True,
|
||||
"move": False,
|
||||
"partial_update": document.link_role == "editor",
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
"update": document.link_role == "editor",
|
||||
"versions_destroy": False,
|
||||
"versions_list": False,
|
||||
@@ -202,7 +225,8 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
|
||||
"is_favorite": False,
|
||||
"link_reach": reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
@@ -232,6 +256,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
links = document.get_ancestors().values("link_reach", "link_role")
|
||||
assert response.json() == {
|
||||
"id": str(document.id),
|
||||
"abilities": {
|
||||
@@ -243,15 +268,19 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
|
||||
"children_create": grand_parent.link_role == "editor",
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"descendants": True,
|
||||
"cors_proxy": True,
|
||||
"destroy": False,
|
||||
"favorite": True,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"link_select_options": models.LinkReachChoices.get_select_options(links),
|
||||
"move": False,
|
||||
"media_auth": True,
|
||||
"partial_update": grand_parent.link_role == "editor",
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
"update": grand_parent.link_role == "editor",
|
||||
"versions_destroy": False,
|
||||
"versions_list": False,
|
||||
@@ -265,7 +294,8 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
|
||||
"is_favorite": False,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
@@ -374,7 +404,8 @@ def test_api_documents_retrieve_authenticated_related_direct():
|
||||
"is_favorite": False,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses": 2,
|
||||
"nb_accesses_ancestors": 2,
|
||||
"nb_accesses_direct": 2,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
@@ -404,6 +435,7 @@ def test_api_documents_retrieve_authenticated_related_parent():
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
links = document.get_ancestors().values("link_reach", "link_role")
|
||||
assert response.json() == {
|
||||
"id": str(document.id),
|
||||
"abilities": {
|
||||
@@ -415,15 +447,19 @@ def test_api_documents_retrieve_authenticated_related_parent():
|
||||
"children_create": access.role != "reader",
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"descendants": True,
|
||||
"cors_proxy": True,
|
||||
"destroy": access.role == "owner",
|
||||
"favorite": True,
|
||||
"invite_owner": access.role == "owner",
|
||||
"link_configuration": access.role in ["administrator", "owner"],
|
||||
"link_select_options": models.LinkReachChoices.get_select_options(links),
|
||||
"media_auth": True,
|
||||
"move": access.role in ["administrator", "owner"],
|
||||
"partial_update": access.role != "reader",
|
||||
"restore": access.role == "owner",
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
"update": access.role != "reader",
|
||||
"versions_destroy": access.role in ["administrator", "owner"],
|
||||
"versions_list": True,
|
||||
@@ -437,7 +473,8 @@ def test_api_documents_retrieve_authenticated_related_parent():
|
||||
"is_favorite": False,
|
||||
"link_reach": "restricted",
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses": 2,
|
||||
"nb_accesses_ancestors": 2,
|
||||
"nb_accesses_direct": 0,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
@@ -465,7 +502,8 @@ def test_api_documents_retrieve_authenticated_related_nb_accesses():
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["nb_accesses"] == 3
|
||||
assert response.json()["nb_accesses_ancestors"] == 3
|
||||
assert response.json()["nb_accesses_direct"] == 1
|
||||
|
||||
factories.UserDocumentAccessFactory(document=grand_parent)
|
||||
|
||||
@@ -473,7 +511,8 @@ def test_api_documents_retrieve_authenticated_related_nb_accesses():
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["nb_accesses"] == 4
|
||||
assert response.json()["nb_accesses_ancestors"] == 4
|
||||
assert response.json()["nb_accesses_direct"] == 1
|
||||
|
||||
|
||||
def test_api_documents_retrieve_authenticated_related_child():
|
||||
@@ -554,12 +593,10 @@ def test_api_documents_retrieve_authenticated_related_team_members(
|
||||
mock_user_teams.return_value = teams
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="readers", role="reader"
|
||||
)
|
||||
@@ -588,7 +625,8 @@ def test_api_documents_retrieve_authenticated_related_team_members(
|
||||
"is_favorite": False,
|
||||
"link_reach": "restricted",
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses": 5,
|
||||
"nb_accesses_ancestors": 5,
|
||||
"nb_accesses_direct": 5,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
@@ -649,7 +687,8 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
|
||||
"is_favorite": False,
|
||||
"link_reach": "restricted",
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses": 5,
|
||||
"nb_accesses_ancestors": 5,
|
||||
"nb_accesses_direct": 5,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
@@ -710,7 +749,8 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
|
||||
"is_favorite": False,
|
||||
"link_reach": "restricted",
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses": 5,
|
||||
"nb_accesses_ancestors": 5,
|
||||
"nb_accesses_direct": 5,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
@@ -719,7 +759,7 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_retrieve_user_roles(django_assert_num_queries):
|
||||
def test_api_documents_retrieve_user_roles(django_assert_max_num_queries):
|
||||
"""
|
||||
Roles should be annotated on querysets taking into account all documents ancestors.
|
||||
"""
|
||||
@@ -744,7 +784,7 @@ def test_api_documents_retrieve_user_roles(django_assert_num_queries):
|
||||
)
|
||||
expected_roles = {access.role for access in accesses}
|
||||
|
||||
with django_assert_num_queries(10):
|
||||
with django_assert_max_num_queries(12):
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -761,7 +801,7 @@ def test_api_documents_retrieve_numqueries_with_link_trace(django_assert_num_que
|
||||
|
||||
document = factories.DocumentFactory(users=[user], link_traces=[user])
|
||||
|
||||
with django_assert_num_queries(4):
|
||||
with django_assert_num_queries(5):
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
|
||||
|
||||
with django_assert_num_queries(3):
|
||||
|
||||
@@ -78,15 +78,23 @@ def test_api_documents_trashbin_format():
|
||||
"children_create": True,
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"descendants": True,
|
||||
"cors_proxy": True,
|
||||
"destroy": True,
|
||||
"favorite": True,
|
||||
"invite_owner": True,
|
||||
"link_configuration": True,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": ["reader", "editor"],
|
||||
},
|
||||
"media_auth": True,
|
||||
"move": False, # Can't move a deleted document
|
||||
"partial_update": True,
|
||||
"restore": True,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
"update": True,
|
||||
"versions_destroy": True,
|
||||
"versions_list": True,
|
||||
@@ -98,7 +106,8 @@ def test_api_documents_trashbin_format():
|
||||
"excerpt": document.excerpt,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses": 3,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 3,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
@@ -147,7 +156,7 @@ def test_api_documents_trashbin_authenticated_direct(django_assert_num_queries):
|
||||
|
||||
expected_ids = {str(document1.id), str(document2.id), str(document3.id)}
|
||||
|
||||
with django_assert_num_queries(7):
|
||||
with django_assert_num_queries(10):
|
||||
response = client.get("/api/v1.0/documents/trashbin/")
|
||||
|
||||
with django_assert_num_queries(4):
|
||||
@@ -189,7 +198,7 @@ def test_api_documents_trashbin_authenticated_via_team(
|
||||
|
||||
expected_ids = {str(deleted_document_team1.id), str(deleted_document_team2.id)}
|
||||
|
||||
with django_assert_num_queries(5):
|
||||
with django_assert_num_queries(7):
|
||||
response = client.get("/api/v1.0/documents/trashbin/")
|
||||
|
||||
with django_assert_num_queries(3):
|
||||
|
||||
1031
src/backend/core/tests/documents/test_api_documents_tree.py
Normal file
1031
src/backend/core/tests/documents/test_api_documents_tree.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -275,7 +275,8 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
|
||||
"depth",
|
||||
"link_reach",
|
||||
"link_role",
|
||||
"nb_accesses",
|
||||
"nb_accesses_ancestors",
|
||||
"nb_accesses_direct",
|
||||
"numchild",
|
||||
"path",
|
||||
]:
|
||||
|
||||
@@ -33,7 +33,7 @@ def test_openapi_client_schema():
|
||||
)
|
||||
assert output.getvalue() == ""
|
||||
|
||||
response = Client().get("/v1.0/swagger.json")
|
||||
response = Client().get("/api/v1.0/swagger.json")
|
||||
|
||||
assert response.status_code == 200
|
||||
with open(
|
||||
|
||||
@@ -39,7 +39,12 @@ def test_api_config(is_authenticated):
|
||||
"CRISP_WEBSITE_ID": "123",
|
||||
"ENVIRONMENT": "test",
|
||||
"FRONTEND_THEME": "test-theme",
|
||||
"LANGUAGES": [["en-us", "English"], ["fr-fr", "French"], ["de-de", "German"]],
|
||||
"LANGUAGES": [
|
||||
["en-us", "English"],
|
||||
["fr-fr", "Français"],
|
||||
["de-de", "Deutsch"],
|
||||
["nl-nl", "Nederlands"],
|
||||
],
|
||||
"LANGUAGE_CODE": "en-us",
|
||||
"MEDIA_BASE_URL": "http://testserver/",
|
||||
"POSTHOG_KEY": {"id": "132456", "host": "https://eu.i.posthog-test.com"},
|
||||
|
||||
@@ -24,7 +24,7 @@ def test_api_users_list_anonymous():
|
||||
|
||||
def test_api_users_list_authenticated():
|
||||
"""
|
||||
Authenticated users should be able to list users.
|
||||
Authenticated users should not be able to list users without a query.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
@@ -37,7 +37,7 @@ def test_api_users_list_authenticated():
|
||||
)
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert len(content["results"]) == 3
|
||||
assert content == []
|
||||
|
||||
|
||||
def test_api_users_list_query_email():
|
||||
@@ -58,24 +58,76 @@ def test_api_users_list_query_email():
|
||||
"/api/v1.0/users/?q=david.bowman@work.com",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
user_ids = [user["id"] for user in response.json()]
|
||||
assert user_ids == [str(dave.id)]
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=davig.bovman@worm.com",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
user_ids = [user["id"] for user in response.json()]
|
||||
assert user_ids == [str(dave.id)]
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=davig.bovman@worm.cop",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
user_ids = [user["id"] for user in response.json()]
|
||||
assert user_ids == []
|
||||
|
||||
|
||||
def test_api_users_list_limit(settings):
|
||||
"""
|
||||
Authenticated users should be able to list users and the number of results
|
||||
should be limited to 10.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Use a base name with a length equal 5 to test that the limit is applied
|
||||
base_name = "alice"
|
||||
for i in range(15):
|
||||
factories.UserFactory(email=f"{base_name}.{i}@example.com")
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=alice",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) == 5
|
||||
|
||||
# if the limit is changed, all users should be returned
|
||||
settings.API_USERS_LIST_LIMIT = 100
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=alice",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) == 15
|
||||
|
||||
|
||||
def test_api_users_list_throttling_authenticated(settings):
|
||||
"""
|
||||
Authenticated users should be throttled.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["user_list_burst"] = "3/minute"
|
||||
|
||||
for _i in range(3):
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=alice",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=alice",
|
||||
)
|
||||
assert response.status_code == 429
|
||||
|
||||
|
||||
def test_api_users_list_query_email_matching():
|
||||
"""While filtering by email, results should be filtered and sorted by Levenstein distance."""
|
||||
user = factories.UserFactory()
|
||||
@@ -94,13 +146,13 @@ def test_api_users_list_query_email_matching():
|
||||
"/api/v1.0/users/?q=alice.johnson@example.gouv.fr",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
user_ids = [user["id"] for user in response.json()]
|
||||
assert user_ids == [str(user1.id), str(user2.id), str(user3.id), str(user4.id)]
|
||||
|
||||
response = client.get("/api/v1.0/users/?q=alicia.johnnson@example.gouv.fr")
|
||||
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
user_ids = [user["id"] for user in response.json()]
|
||||
assert user_ids == [str(user4.id), str(user2.id), str(user1.id), str(user5.id)]
|
||||
|
||||
|
||||
@@ -126,10 +178,50 @@ def test_api_users_list_query_email_exclude_doc_user():
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
user_ids = [user["id"] for user in response.json()]
|
||||
assert user_ids == [str(nicole_fool.id)]
|
||||
|
||||
|
||||
def test_api_users_list_query_short_queries():
|
||||
"""
|
||||
Queries shorter than 5 characters should return an empty result set.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.UserFactory(email="john.doe@example.com")
|
||||
factories.UserFactory(email="john.lennon@example.com")
|
||||
|
||||
response = client.get("/api/v1.0/users/?q=jo")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
||||
response = client.get("/api/v1.0/users/?q=john")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
||||
response = client.get("/api/v1.0/users/?q=john.")
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) == 2
|
||||
|
||||
|
||||
def test_api_users_list_query_inactive():
|
||||
"""Inactive users should not be listed."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.UserFactory(email="john.doe@example.com", is_active=False)
|
||||
lennon = factories.UserFactory(email="john.lennon@example.com")
|
||||
|
||||
response = client.get("/api/v1.0/users/?q=john.")
|
||||
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()]
|
||||
assert user_ids == [str(lennon.id)]
|
||||
|
||||
|
||||
def test_api_users_retrieve_me_anonymous():
|
||||
"""Anonymous users should not be allowed to list users."""
|
||||
factories.UserFactory.create_batch(2)
|
||||
@@ -158,6 +250,7 @@ def test_api_users_retrieve_me_authenticated():
|
||||
"id": str(user.id),
|
||||
"email": user.email,
|
||||
"full_name": user.full_name,
|
||||
"language": user.language,
|
||||
"short_name": user.short_name,
|
||||
}
|
||||
|
||||
|
||||
107
src/backend/core/tests/test_api_utils_nest_tree.py
Normal file
107
src/backend/core/tests/test_api_utils_nest_tree.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Unit tests for the nest_tree utility function."""
|
||||
|
||||
import pytest
|
||||
|
||||
from core.api.utils import nest_tree
|
||||
|
||||
|
||||
def test_api_utils_nest_tree_empty_list():
|
||||
"""Test that an empty list returns an empty nested structure."""
|
||||
# pylint: disable=use-implicit-booleaness-not-comparison
|
||||
assert nest_tree([], 4) is None
|
||||
|
||||
|
||||
def test_api_utils_nest_tree_single_document():
|
||||
"""Test that a single document is returned as the only root element."""
|
||||
documents = [{"id": "1", "path": "0001"}]
|
||||
expected = {"id": "1", "path": "0001", "children": []}
|
||||
assert nest_tree(documents, 4) == expected
|
||||
|
||||
|
||||
def test_api_utils_nest_tree_multiple_root_documents():
|
||||
"""Test that multiple root-level documents are correctly added to the root."""
|
||||
documents = [
|
||||
{"id": "1", "path": "0001"},
|
||||
{"id": "2", "path": "0002"},
|
||||
]
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="More than one root element detected.",
|
||||
):
|
||||
nest_tree(documents, 4)
|
||||
|
||||
|
||||
def test_api_utils_nest_tree_nested_structure():
|
||||
"""Test that documents are correctly nested based on path levels."""
|
||||
documents = [
|
||||
{"id": "1", "path": "0001"},
|
||||
{"id": "2", "path": "00010001"},
|
||||
{"id": "3", "path": "000100010001"},
|
||||
{"id": "4", "path": "00010002"},
|
||||
]
|
||||
expected = {
|
||||
"id": "1",
|
||||
"path": "0001",
|
||||
"children": [
|
||||
{
|
||||
"id": "2",
|
||||
"path": "00010001",
|
||||
"children": [{"id": "3", "path": "000100010001", "children": []}],
|
||||
},
|
||||
{"id": "4", "path": "00010002", "children": []},
|
||||
],
|
||||
}
|
||||
assert nest_tree(documents, 4) == expected
|
||||
|
||||
|
||||
def test_api_utils_nest_tree_siblings_at_same_path():
|
||||
"""
|
||||
Test that sibling documents with the same path are correctly grouped under the same parent.
|
||||
"""
|
||||
documents = [
|
||||
{"id": "1", "path": "0001"},
|
||||
{"id": "2", "path": "00010001"},
|
||||
{"id": "3", "path": "00010002"},
|
||||
]
|
||||
expected = {
|
||||
"id": "1",
|
||||
"path": "0001",
|
||||
"children": [
|
||||
{"id": "2", "path": "00010001", "children": []},
|
||||
{"id": "3", "path": "00010002", "children": []},
|
||||
],
|
||||
}
|
||||
assert nest_tree(documents, 4) == expected
|
||||
|
||||
|
||||
def test_api_utils_nest_tree_decreasing_path_resets_parent():
|
||||
"""Test that a document at a lower path resets the parent assignment correctly."""
|
||||
documents = [
|
||||
{"id": "1", "path": "0001"},
|
||||
{"id": "6", "path": "00010001"},
|
||||
{"id": "2", "path": "00010002"}, # unordered
|
||||
{"id": "5", "path": "000100010001"},
|
||||
{"id": "3", "path": "000100010002"},
|
||||
{"id": "4", "path": "00010003"},
|
||||
]
|
||||
expected = {
|
||||
"id": "1",
|
||||
"path": "0001",
|
||||
"children": [
|
||||
{
|
||||
"id": "6",
|
||||
"path": "00010001",
|
||||
"children": [
|
||||
{"id": "5", "path": "000100010001", "children": []},
|
||||
{"id": "3", "path": "000100010002", "children": []},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"path": "00010002",
|
||||
"children": [],
|
||||
},
|
||||
{"id": "4", "path": "00010003", "children": []},
|
||||
],
|
||||
}
|
||||
assert nest_tree(documents, 4) == expected
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Unit tests for the Document model
|
||||
"""
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import random
|
||||
import smtplib
|
||||
@@ -157,15 +158,23 @@ def test_models_documents_get_abilities_forbidden(
|
||||
"children_create": False,
|
||||
"children_list": False,
|
||||
"collaboration_auth": False,
|
||||
"descendants": False,
|
||||
"cors_proxy": False,
|
||||
"destroy": False,
|
||||
"favorite": False,
|
||||
"invite_owner": False,
|
||||
"media_auth": False,
|
||||
"move": False,
|
||||
"link_configuration": False,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": ["reader", "editor"],
|
||||
},
|
||||
"partial_update": False,
|
||||
"restore": False,
|
||||
"retrieve": False,
|
||||
"tree": False,
|
||||
"update": False,
|
||||
"versions_destroy": False,
|
||||
"versions_list": False,
|
||||
@@ -208,15 +217,23 @@ def test_models_documents_get_abilities_reader(
|
||||
"children_create": False,
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"descendants": True,
|
||||
"cors_proxy": True,
|
||||
"destroy": False,
|
||||
"favorite": is_authenticated,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": ["reader", "editor"],
|
||||
},
|
||||
"media_auth": True,
|
||||
"move": False,
|
||||
"partial_update": False,
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
"update": False,
|
||||
"versions_destroy": False,
|
||||
"versions_list": False,
|
||||
@@ -225,9 +242,14 @@ def test_models_documents_get_abilities_reader(
|
||||
nb_queries = 1 if is_authenticated else 0
|
||||
with django_assert_num_queries(nb_queries):
|
||||
assert document.get_abilities(user) == expected_abilities
|
||||
|
||||
document.soft_delete()
|
||||
document.refresh_from_db()
|
||||
assert all(value is False for value in document.get_abilities(user).values())
|
||||
assert all(
|
||||
value is False
|
||||
for key, value in document.get_abilities(user).items()
|
||||
if key != "link_select_options"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -256,15 +278,23 @@ def test_models_documents_get_abilities_editor(
|
||||
"children_create": is_authenticated,
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"descendants": True,
|
||||
"cors_proxy": True,
|
||||
"destroy": False,
|
||||
"favorite": is_authenticated,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": ["reader", "editor"],
|
||||
},
|
||||
"media_auth": True,
|
||||
"move": False,
|
||||
"partial_update": True,
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
"update": True,
|
||||
"versions_destroy": False,
|
||||
"versions_list": False,
|
||||
@@ -275,7 +305,11 @@ def test_models_documents_get_abilities_editor(
|
||||
assert document.get_abilities(user) == expected_abilities
|
||||
document.soft_delete()
|
||||
document.refresh_from_db()
|
||||
assert all(value is False for value in document.get_abilities(user).values())
|
||||
assert all(
|
||||
value is False
|
||||
for key, value in document.get_abilities(user).items()
|
||||
if key != "link_select_options"
|
||||
)
|
||||
|
||||
|
||||
@override_settings(
|
||||
@@ -294,15 +328,23 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
|
||||
"children_create": True,
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"descendants": True,
|
||||
"cors_proxy": True,
|
||||
"destroy": True,
|
||||
"favorite": True,
|
||||
"invite_owner": True,
|
||||
"link_configuration": True,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": ["reader", "editor"],
|
||||
},
|
||||
"media_auth": True,
|
||||
"move": True,
|
||||
"partial_update": True,
|
||||
"restore": True,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
"update": True,
|
||||
"versions_destroy": True,
|
||||
"versions_list": True,
|
||||
@@ -333,15 +375,23 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
|
||||
"children_create": True,
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"descendants": True,
|
||||
"cors_proxy": True,
|
||||
"destroy": False,
|
||||
"favorite": True,
|
||||
"invite_owner": False,
|
||||
"link_configuration": True,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": ["reader", "editor"],
|
||||
},
|
||||
"media_auth": True,
|
||||
"move": True,
|
||||
"partial_update": True,
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
"update": True,
|
||||
"versions_destroy": True,
|
||||
"versions_list": True,
|
||||
@@ -352,7 +402,11 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
|
||||
|
||||
document.soft_delete()
|
||||
document.refresh_from_db()
|
||||
assert all(value is False for value in document.get_abilities(user).values())
|
||||
assert all(
|
||||
value is False
|
||||
for key, value in document.get_abilities(user).items()
|
||||
if key != "link_select_options"
|
||||
)
|
||||
|
||||
|
||||
@override_settings(
|
||||
@@ -371,15 +425,23 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
|
||||
"children_create": True,
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"descendants": True,
|
||||
"cors_proxy": True,
|
||||
"destroy": False,
|
||||
"favorite": True,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": ["reader", "editor"],
|
||||
},
|
||||
"media_auth": True,
|
||||
"move": False,
|
||||
"partial_update": True,
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
"update": True,
|
||||
"versions_destroy": False,
|
||||
"versions_list": True,
|
||||
@@ -390,7 +452,11 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
|
||||
|
||||
document.soft_delete()
|
||||
document.refresh_from_db()
|
||||
assert all(value is False for value in document.get_abilities(user).values())
|
||||
assert all(
|
||||
value is False
|
||||
for key, value in document.get_abilities(user).items()
|
||||
if key != "link_select_options"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ai_access_setting", ["public", "authenticated", "restricted"])
|
||||
@@ -416,15 +482,23 @@ def test_models_documents_get_abilities_reader_user(
|
||||
"children_create": access_from_link,
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"descendants": True,
|
||||
"cors_proxy": True,
|
||||
"destroy": False,
|
||||
"favorite": True,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": ["reader", "editor"],
|
||||
},
|
||||
"media_auth": True,
|
||||
"move": False,
|
||||
"partial_update": access_from_link,
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
"update": access_from_link,
|
||||
"versions_destroy": False,
|
||||
"versions_list": True,
|
||||
@@ -437,7 +511,11 @@ def test_models_documents_get_abilities_reader_user(
|
||||
|
||||
document.soft_delete()
|
||||
document.refresh_from_db()
|
||||
assert all(value is False for value in document.get_abilities(user).values())
|
||||
assert all(
|
||||
value is False
|
||||
for key, value in document.get_abilities(user).items()
|
||||
if key != "link_select_options"
|
||||
)
|
||||
|
||||
|
||||
def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
|
||||
@@ -459,15 +537,23 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
|
||||
"children_create": False,
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"descendants": True,
|
||||
"cors_proxy": True,
|
||||
"destroy": False,
|
||||
"favorite": True,
|
||||
"invite_owner": False,
|
||||
"link_configuration": False,
|
||||
"link_select_options": {
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
"restricted": ["reader", "editor"],
|
||||
},
|
||||
"media_auth": True,
|
||||
"move": False,
|
||||
"partial_update": False,
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"tree": True,
|
||||
"update": False,
|
||||
"versions_destroy": False,
|
||||
"versions_list": True,
|
||||
@@ -636,6 +722,37 @@ def test_models_documents__email_invitation__success():
|
||||
assert f"docs/{document.id}/" in email_content
|
||||
|
||||
|
||||
def test_models_documents__email_invitation__success_empty_title():
|
||||
"""
|
||||
The email invitation is sent successfully.
|
||||
"""
|
||||
document = factories.DocumentFactory(title=None)
|
||||
|
||||
# pylint: disable-next=no-member
|
||||
assert len(mail.outbox) == 0
|
||||
|
||||
sender = factories.UserFactory(full_name="Test Sender", email="sender@example.com")
|
||||
document.send_invitation_email(
|
||||
"guest@example.com", models.RoleChoices.EDITOR, sender, "en"
|
||||
)
|
||||
|
||||
# pylint: disable-next=no-member
|
||||
assert len(mail.outbox) == 1
|
||||
|
||||
# pylint: disable-next=no-member
|
||||
email = mail.outbox[0]
|
||||
|
||||
assert email.to == ["guest@example.com"]
|
||||
email_content = " ".join(email.body.split())
|
||||
|
||||
assert "Test sender shared a document with you!" in email.subject
|
||||
assert (
|
||||
"Test Sender (sender@example.com) invited you with the role "editor" "
|
||||
"on the following document: Untitled Document" in email_content
|
||||
)
|
||||
assert f"docs/{document.id}/" in email_content
|
||||
|
||||
|
||||
def test_models_documents__email_invitation__success_fr():
|
||||
"""
|
||||
The email invitation is sent successfully in french.
|
||||
@@ -711,40 +828,89 @@ def test_models_documents__email_invitation__failed(mock_logger, _mock_send_mail
|
||||
# Document number of accesses
|
||||
|
||||
|
||||
def test_models_documents_nb_accesses_cache_is_set_and_retrieved(
|
||||
def test_models_documents_nb_accesses_cache_is_set_and_retrieved_ancestors(
|
||||
django_assert_num_queries,
|
||||
):
|
||||
"""Test that nb_accesses is cached after the first computation."""
|
||||
document = factories.DocumentFactory()
|
||||
"""Test that nb_accesses is cached when calling nb_accesses_ancestors."""
|
||||
parent = factories.DocumentFactory()
|
||||
document = factories.DocumentFactory(parent=parent)
|
||||
key = f"document_{document.id!s}_nb_accesses"
|
||||
nb_accesses = random.randint(1, 4)
|
||||
factories.UserDocumentAccessFactory.create_batch(nb_accesses, document=document)
|
||||
nb_accesses_parent = random.randint(1, 4)
|
||||
factories.UserDocumentAccessFactory.create_batch(
|
||||
nb_accesses_parent, document=parent
|
||||
)
|
||||
nb_accesses_direct = random.randint(1, 4)
|
||||
factories.UserDocumentAccessFactory.create_batch(
|
||||
nb_accesses_direct, document=document
|
||||
)
|
||||
factories.UserDocumentAccessFactory() # An unrelated access should not be counted
|
||||
|
||||
# Initially, the nb_accesses should not be cached
|
||||
assert cache.get(key) is None
|
||||
|
||||
# Compute the nb_accesses for the first time (this should set the cache)
|
||||
with django_assert_num_queries(1):
|
||||
assert document.nb_accesses == nb_accesses
|
||||
nb_accesses_ancestors = nb_accesses_parent + nb_accesses_direct
|
||||
with django_assert_num_queries(2):
|
||||
assert document.nb_accesses_ancestors == nb_accesses_ancestors
|
||||
|
||||
# Ensure that the nb_accesses is now cached
|
||||
with django_assert_num_queries(0):
|
||||
assert document.nb_accesses == nb_accesses
|
||||
assert cache.get(key) == nb_accesses
|
||||
assert document.nb_accesses_ancestors == nb_accesses_ancestors
|
||||
assert cache.get(key) == (nb_accesses_direct, nb_accesses_ancestors)
|
||||
|
||||
# The cache value should be invalidated when a document access is created
|
||||
models.DocumentAccess.objects.create(
|
||||
document=document, user=factories.UserFactory(), role="reader"
|
||||
)
|
||||
assert cache.get(key) is None # Cache should be invalidated
|
||||
with django_assert_num_queries(1):
|
||||
new_nb_accesses = document.nb_accesses
|
||||
assert new_nb_accesses == nb_accesses + 1
|
||||
assert cache.get(key) == new_nb_accesses # Cache should now contain the new value
|
||||
with django_assert_num_queries(2):
|
||||
assert document.nb_accesses_ancestors == nb_accesses_ancestors + 1
|
||||
assert cache.get(key) == (nb_accesses_direct + 1, nb_accesses_ancestors + 1)
|
||||
|
||||
|
||||
def test_models_documents_nb_accesses_cache_is_set_and_retrieved_direct(
|
||||
django_assert_num_queries,
|
||||
):
|
||||
"""Test that nb_accesses is cached when calling nb_accesses_direct."""
|
||||
parent = factories.DocumentFactory()
|
||||
document = factories.DocumentFactory(parent=parent)
|
||||
key = f"document_{document.id!s}_nb_accesses"
|
||||
nb_accesses_parent = random.randint(1, 4)
|
||||
factories.UserDocumentAccessFactory.create_batch(
|
||||
nb_accesses_parent, document=parent
|
||||
)
|
||||
nb_accesses_direct = random.randint(1, 4)
|
||||
factories.UserDocumentAccessFactory.create_batch(
|
||||
nb_accesses_direct, document=document
|
||||
)
|
||||
factories.UserDocumentAccessFactory() # An unrelated access should not be counted
|
||||
|
||||
# Initially, the nb_accesses should not be cached
|
||||
assert cache.get(key) is None
|
||||
|
||||
# Compute the nb_accesses for the first time (this should set the cache)
|
||||
nb_accesses_ancestors = nb_accesses_parent + nb_accesses_direct
|
||||
with django_assert_num_queries(2):
|
||||
assert document.nb_accesses_direct == nb_accesses_direct
|
||||
|
||||
# Ensure that the nb_accesses is now cached
|
||||
with django_assert_num_queries(0):
|
||||
assert document.nb_accesses_direct == nb_accesses_direct
|
||||
assert cache.get(key) == (nb_accesses_direct, nb_accesses_ancestors)
|
||||
|
||||
# The cache value should be invalidated when a document access is created
|
||||
models.DocumentAccess.objects.create(
|
||||
document=document, user=factories.UserFactory(), role="reader"
|
||||
)
|
||||
assert cache.get(key) is None # Cache should be invalidated
|
||||
with django_assert_num_queries(2):
|
||||
assert document.nb_accesses_direct == nb_accesses_direct + 1
|
||||
assert cache.get(key) == (nb_accesses_direct + 1, nb_accesses_ancestors + 1)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["nb_accesses_ancestors", "nb_accesses_direct"])
|
||||
def test_models_documents_nb_accesses_cache_is_invalidated_on_access_removal(
|
||||
field,
|
||||
django_assert_num_queries,
|
||||
):
|
||||
"""Test that the cache is invalidated when a document access is deleted."""
|
||||
@@ -753,15 +919,425 @@ def test_models_documents_nb_accesses_cache_is_invalidated_on_access_removal(
|
||||
access = factories.UserDocumentAccessFactory(document=document)
|
||||
|
||||
# Initially, the nb_accesses should be cached
|
||||
assert document.nb_accesses == 1
|
||||
assert cache.get(key) == 1
|
||||
assert getattr(document, field) == 1
|
||||
assert cache.get(key) == (1, 1)
|
||||
|
||||
# Remove the access and check if cache is invalidated
|
||||
access.delete()
|
||||
assert cache.get(key) is None # Cache should be invalidated
|
||||
|
||||
# Recompute the nb_accesses (this should trigger a cache set)
|
||||
with django_assert_num_queries(1):
|
||||
new_nb_accesses = document.nb_accesses
|
||||
with django_assert_num_queries(2):
|
||||
new_nb_accesses = getattr(document, field)
|
||||
assert new_nb_accesses == 0
|
||||
assert cache.get(key) == 0 # Cache should now contain the new value
|
||||
assert cache.get(key) == (0, 0) # Cache should now contain the new value
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["nb_accesses_ancestors", "nb_accesses_direct"])
|
||||
def test_models_documents_nb_accesses_cache_is_invalidated_on_document_soft_delete_restore(
|
||||
field,
|
||||
django_assert_num_queries,
|
||||
):
|
||||
"""Test that the cache is invalidated when a document access is deleted."""
|
||||
document = factories.DocumentFactory()
|
||||
key = f"document_{document.id!s}_nb_accesses"
|
||||
factories.UserDocumentAccessFactory(document=document)
|
||||
|
||||
# Initially, the nb_accesses should be cached
|
||||
assert getattr(document, field) == 1
|
||||
assert cache.get(key) == (1, 1)
|
||||
|
||||
# Soft delete the document and check if cache is invalidated
|
||||
document.soft_delete()
|
||||
assert cache.get(key) is None # Cache should be invalidated
|
||||
|
||||
# Recompute the nb_accesses (this should trigger a cache set)
|
||||
with django_assert_num_queries(2):
|
||||
new_nb_accesses = getattr(document, field)
|
||||
assert new_nb_accesses == (1 if field == "nb_accesses_direct" else 0)
|
||||
assert cache.get(key) == (1, 0) # Cache should now contain the new value
|
||||
|
||||
document.restore()
|
||||
|
||||
# Recompute the nb_accesses (this should trigger a cache set)
|
||||
with django_assert_num_queries(2):
|
||||
new_nb_accesses = getattr(document, field)
|
||||
assert new_nb_accesses == 1
|
||||
assert cache.get(key) == (1, 1) # Cache should now contain the new value
|
||||
|
||||
|
||||
def test_models_documents_numchild_deleted_from_instance():
|
||||
"""the "numchild" field should not include documents deleted from the instance."""
|
||||
document = factories.DocumentFactory()
|
||||
child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
assert document.numchild == 2
|
||||
|
||||
child1.delete()
|
||||
|
||||
document.refresh_from_db()
|
||||
assert document.numchild == 1
|
||||
|
||||
|
||||
def test_models_documents_numchild_deleted_from_queryset():
|
||||
"""the "numchild" field should not include documents deleted from a queryset."""
|
||||
document = factories.DocumentFactory()
|
||||
child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
assert document.numchild == 2
|
||||
|
||||
models.Document.objects.filter(pk=child1.pk).delete()
|
||||
|
||||
document.refresh_from_db()
|
||||
assert document.numchild == 1
|
||||
|
||||
|
||||
def test_models_documents_numchild_soft_deleted_and_restore():
|
||||
"""the "numchild" field should not include soft deleted documents."""
|
||||
document = factories.DocumentFactory()
|
||||
child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
|
||||
assert document.numchild == 2
|
||||
|
||||
child1.soft_delete()
|
||||
|
||||
document.refresh_from_db()
|
||||
assert document.numchild == 1
|
||||
|
||||
child1.restore()
|
||||
|
||||
document.refresh_from_db()
|
||||
assert document.numchild == 2
|
||||
|
||||
|
||||
def test_models_documents_soft_delete_tempering_with_instance():
|
||||
"""
|
||||
Soft deleting should fail if the document is already deleted in database even though the
|
||||
instance "deleted_at" attributes where tempered with.
|
||||
"""
|
||||
document = factories.DocumentFactory()
|
||||
document.soft_delete()
|
||||
|
||||
document.deleted_at = None
|
||||
document.ancestors_deleted_at = None
|
||||
with pytest.raises(
|
||||
RuntimeError, match="This document is already deleted or has deleted ancestors."
|
||||
):
|
||||
document.soft_delete()
|
||||
|
||||
|
||||
def test_models_documents_restore_tempering_with_instance():
|
||||
"""
|
||||
Soft deleting should fail if the document is already deleted in database even though the
|
||||
instance "deleted_at" attributes where tempered with.
|
||||
"""
|
||||
document = factories.DocumentFactory()
|
||||
|
||||
if random.choice([False, True]):
|
||||
document.deleted_at = timezone.now()
|
||||
else:
|
||||
document.ancestors_deleted_at = timezone.now()
|
||||
|
||||
with pytest.raises(RuntimeError, match="This document is not deleted."):
|
||||
document.restore()
|
||||
|
||||
|
||||
def test_models_documents_restore(django_assert_num_queries):
|
||||
"""The restore method should restore a soft-deleted document."""
|
||||
document = factories.DocumentFactory()
|
||||
document.soft_delete()
|
||||
document.refresh_from_db()
|
||||
assert document.deleted_at is not None
|
||||
assert document.ancestors_deleted_at == document.deleted_at
|
||||
|
||||
with django_assert_num_queries(8):
|
||||
document.restore()
|
||||
document.refresh_from_db()
|
||||
assert document.deleted_at is None
|
||||
assert document.ancestors_deleted_at == document.deleted_at
|
||||
|
||||
|
||||
def test_models_documents_restore_complex(django_assert_num_queries):
|
||||
"""The restore method should restore a soft-deleted document and its ancestors."""
|
||||
grand_parent = factories.DocumentFactory()
|
||||
parent = factories.DocumentFactory(parent=grand_parent)
|
||||
document = factories.DocumentFactory(parent=parent)
|
||||
|
||||
child1 = factories.DocumentFactory(parent=document)
|
||||
child2 = factories.DocumentFactory(parent=document)
|
||||
|
||||
# Soft delete first the document
|
||||
document.soft_delete()
|
||||
document.refresh_from_db()
|
||||
child1.refresh_from_db()
|
||||
child2.refresh_from_db()
|
||||
assert document.deleted_at is not None
|
||||
assert document.ancestors_deleted_at == document.deleted_at
|
||||
assert child1.ancestors_deleted_at == document.deleted_at
|
||||
assert child2.ancestors_deleted_at == document.deleted_at
|
||||
|
||||
# Soft delete the grand parent
|
||||
grand_parent.soft_delete()
|
||||
grand_parent.refresh_from_db()
|
||||
parent.refresh_from_db()
|
||||
assert grand_parent.deleted_at is not None
|
||||
assert grand_parent.ancestors_deleted_at == grand_parent.deleted_at
|
||||
assert parent.ancestors_deleted_at == grand_parent.deleted_at
|
||||
# item, child1 and child2 should not be affected
|
||||
document.refresh_from_db()
|
||||
child1.refresh_from_db()
|
||||
child2.refresh_from_db()
|
||||
assert document.deleted_at is not None
|
||||
assert document.ancestors_deleted_at == document.deleted_at
|
||||
assert child1.ancestors_deleted_at == document.deleted_at
|
||||
assert child2.ancestors_deleted_at == document.deleted_at
|
||||
|
||||
# Restore the item
|
||||
with django_assert_num_queries(11):
|
||||
document.restore()
|
||||
document.refresh_from_db()
|
||||
child1.refresh_from_db()
|
||||
child2.refresh_from_db()
|
||||
grand_parent.refresh_from_db()
|
||||
assert document.deleted_at is None
|
||||
assert document.ancestors_deleted_at == grand_parent.deleted_at
|
||||
# child 1 and child 2 should now have the same ancestors_deleted_at as the grand parent
|
||||
assert child1.ancestors_deleted_at == grand_parent.deleted_at
|
||||
assert child2.ancestors_deleted_at == grand_parent.deleted_at
|
||||
|
||||
|
||||
def test_models_documents_restore_complex_bis(django_assert_num_queries):
|
||||
"""The restore method should restore a soft-deleted item and its ancestors."""
|
||||
grand_parent = factories.DocumentFactory()
|
||||
parent = factories.DocumentFactory(parent=grand_parent)
|
||||
document = factories.DocumentFactory(parent=parent)
|
||||
|
||||
child1 = factories.DocumentFactory(parent=document)
|
||||
child2 = factories.DocumentFactory(parent=document)
|
||||
|
||||
# Soft delete first the document
|
||||
document.soft_delete()
|
||||
document.refresh_from_db()
|
||||
child1.refresh_from_db()
|
||||
child2.refresh_from_db()
|
||||
assert document.deleted_at is not None
|
||||
assert document.ancestors_deleted_at == document.deleted_at
|
||||
assert child1.ancestors_deleted_at == document.deleted_at
|
||||
assert child2.ancestors_deleted_at == document.deleted_at
|
||||
|
||||
# Soft delete the grand parent
|
||||
grand_parent.soft_delete()
|
||||
grand_parent.refresh_from_db()
|
||||
parent.refresh_from_db()
|
||||
assert grand_parent.deleted_at is not None
|
||||
assert grand_parent.ancestors_deleted_at == grand_parent.deleted_at
|
||||
assert parent.ancestors_deleted_at == grand_parent.deleted_at
|
||||
# item, child1 and child2 should not be affected
|
||||
document.refresh_from_db()
|
||||
child1.refresh_from_db()
|
||||
child2.refresh_from_db()
|
||||
assert document.deleted_at is not None
|
||||
assert document.ancestors_deleted_at == document.deleted_at
|
||||
assert child1.ancestors_deleted_at == document.deleted_at
|
||||
assert child2.ancestors_deleted_at == document.deleted_at
|
||||
|
||||
# Restoring the grand parent should not restore the document
|
||||
# as it was deleted before the grand parent
|
||||
with django_assert_num_queries(9):
|
||||
grand_parent.restore()
|
||||
|
||||
grand_parent.refresh_from_db()
|
||||
parent.refresh_from_db()
|
||||
document.refresh_from_db()
|
||||
child1.refresh_from_db()
|
||||
child2.refresh_from_db()
|
||||
assert grand_parent.deleted_at is None
|
||||
assert grand_parent.ancestors_deleted_at is None
|
||||
assert parent.deleted_at is None
|
||||
assert parent.ancestors_deleted_at is None
|
||||
assert document.deleted_at is not None
|
||||
assert document.ancestors_deleted_at == document.deleted_at
|
||||
assert child1.ancestors_deleted_at == document.deleted_at
|
||||
assert child2.ancestors_deleted_at == document.deleted_at
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ancestors_links, select_options",
|
||||
[
|
||||
# One ancestor
|
||||
(
|
||||
[{"link_reach": "public", "link_role": "reader"}],
|
||||
{
|
||||
"restricted": ["editor"],
|
||||
"authenticated": ["editor"],
|
||||
"public": ["reader", "editor"],
|
||||
},
|
||||
),
|
||||
([{"link_reach": "public", "link_role": "editor"}], {"public": ["editor"]}),
|
||||
(
|
||||
[{"link_reach": "authenticated", "link_role": "reader"}],
|
||||
{
|
||||
"restricted": ["editor"],
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
},
|
||||
),
|
||||
(
|
||||
[{"link_reach": "authenticated", "link_role": "editor"}],
|
||||
{"authenticated": ["editor"], "public": ["reader", "editor"]},
|
||||
),
|
||||
(
|
||||
[{"link_reach": "restricted", "link_role": "reader"}],
|
||||
{
|
||||
"restricted": ["reader", "editor"],
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
},
|
||||
),
|
||||
(
|
||||
[{"link_reach": "restricted", "link_role": "editor"}],
|
||||
{
|
||||
"restricted": ["editor"],
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
},
|
||||
),
|
||||
# Multiple ancestors with different roles
|
||||
(
|
||||
[
|
||||
{"link_reach": "public", "link_role": "reader"},
|
||||
{"link_reach": "public", "link_role": "editor"},
|
||||
],
|
||||
{"public": ["editor"]},
|
||||
),
|
||||
(
|
||||
[
|
||||
{"link_reach": "authenticated", "link_role": "reader"},
|
||||
{"link_reach": "authenticated", "link_role": "editor"},
|
||||
],
|
||||
{"authenticated": ["editor"], "public": ["reader", "editor"]},
|
||||
),
|
||||
(
|
||||
[
|
||||
{"link_reach": "restricted", "link_role": "reader"},
|
||||
{"link_reach": "restricted", "link_role": "editor"},
|
||||
],
|
||||
{
|
||||
"restricted": ["editor"],
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
},
|
||||
),
|
||||
# Multiple ancestors with different reaches
|
||||
(
|
||||
[
|
||||
{"link_reach": "authenticated", "link_role": "reader"},
|
||||
{"link_reach": "public", "link_role": "reader"},
|
||||
],
|
||||
{
|
||||
"restricted": ["editor"],
|
||||
"authenticated": ["editor"],
|
||||
"public": ["reader", "editor"],
|
||||
},
|
||||
),
|
||||
(
|
||||
[
|
||||
{"link_reach": "restricted", "link_role": "reader"},
|
||||
{"link_reach": "authenticated", "link_role": "reader"},
|
||||
{"link_reach": "public", "link_role": "reader"},
|
||||
],
|
||||
{
|
||||
"restricted": ["editor"],
|
||||
"authenticated": ["editor"],
|
||||
"public": ["reader", "editor"],
|
||||
},
|
||||
),
|
||||
# Multiple ancestors with mixed reaches and roles
|
||||
(
|
||||
[
|
||||
{"link_reach": "authenticated", "link_role": "editor"},
|
||||
{"link_reach": "public", "link_role": "reader"},
|
||||
],
|
||||
{"authenticated": ["editor"], "public": ["reader", "editor"]},
|
||||
),
|
||||
(
|
||||
[
|
||||
{"link_reach": "authenticated", "link_role": "reader"},
|
||||
{"link_reach": "public", "link_role": "editor"},
|
||||
],
|
||||
{"public": ["editor"]},
|
||||
),
|
||||
(
|
||||
[
|
||||
{"link_reach": "restricted", "link_role": "editor"},
|
||||
{"link_reach": "authenticated", "link_role": "reader"},
|
||||
],
|
||||
{
|
||||
"restricted": ["editor"],
|
||||
"authenticated": ["reader", "editor"],
|
||||
"public": ["reader", "editor"],
|
||||
},
|
||||
),
|
||||
(
|
||||
[
|
||||
{"link_reach": "restricted", "link_role": "reader"},
|
||||
{"link_reach": "authenticated", "link_role": "editor"},
|
||||
],
|
||||
{"authenticated": ["editor"], "public": ["reader", "editor"]},
|
||||
),
|
||||
# No ancestors (edge case)
|
||||
(
|
||||
[],
|
||||
{
|
||||
"public": ["reader", "editor"],
|
||||
"authenticated": ["reader", "editor"],
|
||||
"restricted": ["reader", "editor"],
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_models_documents_get_select_options(ancestors_links, select_options):
|
||||
"""Validate that the "get_select_options" method operates as expected."""
|
||||
assert models.LinkReachChoices.get_select_options(ancestors_links) == select_options
|
||||
|
||||
|
||||
def test_models_documents_children_create_after_sibling_deletion():
|
||||
"""
|
||||
It should be possible to create a new child after all children have been deleted.
|
||||
"""
|
||||
|
||||
root = factories.DocumentFactory()
|
||||
assert root.numchild == 0
|
||||
assert root.has_deleted_children is False
|
||||
assert root.is_leaf() is True
|
||||
child1 = factories.DocumentFactory(parent=root)
|
||||
child2 = factories.DocumentFactory(parent=root)
|
||||
|
||||
root.refresh_from_db()
|
||||
assert root.numchild == 2
|
||||
assert root.has_deleted_children is False
|
||||
assert root.is_leaf() is False
|
||||
|
||||
child1.soft_delete()
|
||||
child2.soft_delete()
|
||||
root.refresh_from_db()
|
||||
assert root.numchild == 0
|
||||
assert root.has_deleted_children is True
|
||||
assert root.is_leaf() is False
|
||||
|
||||
factories.DocumentFactory(parent=root)
|
||||
root.refresh_from_db()
|
||||
assert root.numchild == 1
|
||||
assert root.has_deleted_children is True
|
||||
assert root.is_leaf() is False
|
||||
|
||||
|
||||
def test_models_documents_has_deleted_children():
|
||||
"""
|
||||
A document should have its has_deleted_children attribute set to True if one of its children
|
||||
has been solf deleted no matter if numchild is 0 or not.
|
||||
"""
|
||||
root = factories.DocumentFactory()
|
||||
child = factories.DocumentFactory(parent=root)
|
||||
assert root.has_deleted_children is False
|
||||
child.soft_delete()
|
||||
root.refresh_from_db()
|
||||
assert root.has_deleted_children is True
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
Test ai API endpoints in the impress core app.
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
@@ -58,9 +57,8 @@ def test_api_ai__client_error(mock_create):
|
||||
def test_api_ai__client_invalid_response(mock_create):
|
||||
"""Fail when the client response is invalid"""
|
||||
|
||||
answer = {"no_answer": "This is an invalid response"}
|
||||
mock_create.return_value = MagicMock(
|
||||
choices=[MagicMock(message=MagicMock(content=json.dumps(answer)))]
|
||||
choices=[MagicMock(message=MagicMock(content=None))]
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
@@ -77,49 +75,10 @@ def test_api_ai__client_invalid_response(mock_create):
|
||||
def test_api_ai__success(mock_create):
|
||||
"""The AI request should work as expect when called with valid arguments."""
|
||||
|
||||
answer = '{"answer": "Salut"}'
|
||||
mock_create.return_value = MagicMock(
|
||||
choices=[MagicMock(message=MagicMock(content=answer))]
|
||||
choices=[MagicMock(message=MagicMock(content="Salut"))]
|
||||
)
|
||||
|
||||
response = AIService().transform("hello", "prompt")
|
||||
|
||||
assert response == {"answer": "Salut"}
|
||||
|
||||
|
||||
@override_settings(
|
||||
AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="test-model"
|
||||
)
|
||||
@patch("openai.resources.chat.completions.Completions.create")
|
||||
def test_api_ai__success_sanitize(mock_create):
|
||||
"""The AI response should be sanitized"""
|
||||
|
||||
answer = '{"answer": "Salut\\n \tle \nmonde"}'
|
||||
mock_create.return_value = MagicMock(
|
||||
choices=[MagicMock(message=MagicMock(content=answer))]
|
||||
)
|
||||
|
||||
response = AIService().transform("hello", "prompt")
|
||||
|
||||
assert response == {"answer": "Salut\n \tle \nmonde"}
|
||||
|
||||
|
||||
@override_settings(
|
||||
AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="test-model"
|
||||
)
|
||||
@patch("openai.resources.chat.completions.Completions.create")
|
||||
def test_api_ai__success_when_sanitize_fails(mock_create):
|
||||
"""The AI request should work as expected even with badly formatted response."""
|
||||
|
||||
# pylint: disable=C0303
|
||||
answer = """{
|
||||
"answer" :
|
||||
"Salut le monde"
|
||||
}"""
|
||||
mock_create.return_value = MagicMock(
|
||||
choices=[MagicMock(message=MagicMock(content=answer))]
|
||||
)
|
||||
|
||||
response = AIService().transform("hello", "prompt")
|
||||
|
||||
assert response == {"answer": "Salut le monde"}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
<img width="200" src="https://impress-staging.beta.numerique.gouv.fr/assets/logo-gouv.png" />
|
||||
<img width="200" src="http://localhost:3000/assets/logo-gouv.png" />
|
||||
<br/>
|
||||
@@ -7,17 +7,12 @@ NB_OBJECTS = {
|
||||
}
|
||||
|
||||
DEV_USERS = [
|
||||
{"username": "impress", "email": "impress@impress.world", "language": "en-us"},
|
||||
{"username": "user-e2e-webkit", "email": "user@webkit.e2e", "language": "en-us"},
|
||||
{"username": "user-e2e-firefox", "email": "user@firefox.e2e", "language": "en-us"},
|
||||
{
|
||||
"username": "impress",
|
||||
"email": "impress@impress.world",
|
||||
"username": "user-e2e-chromium",
|
||||
"email": "user@chromium.e2e",
|
||||
"language": "en-us",
|
||||
},
|
||||
{
|
||||
"username": "user-e2e-webkit",
|
||||
"email": "user@webkit.e2e",
|
||||
},
|
||||
{
|
||||
"username": "user-e2e-firefox",
|
||||
"email": "user@firefox.e2e",
|
||||
},
|
||||
{"username": "user-e2e-chromium", "email": "user@chromium.e2e"},
|
||||
]
|
||||
|
||||
@@ -179,7 +179,8 @@ def create_demo(stdout):
|
||||
is_superuser=False,
|
||||
is_active=True,
|
||||
is_staff=False,
|
||||
language=random.choice(settings.LANGUAGES)[0],
|
||||
language=dev_user["language"]
|
||||
or random.choice(settings.LANGUAGES)[0],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
import sentry_sdk
|
||||
from configurations import Configuration, values
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
from sentry_sdk.integrations.logging import ignore_logger
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
@@ -220,7 +221,9 @@ class Base(Configuration):
|
||||
|
||||
# Languages
|
||||
LANGUAGE_CODE = values.Value("en-us")
|
||||
LANGUAGE_COOKIE_NAME = "docs_language" # cookie & language is set from frontend
|
||||
# cookie & language is set from frontend
|
||||
LANGUAGE_COOKIE_NAME = "docs_language"
|
||||
LANGUAGE_COOKIE_PATH = "/"
|
||||
|
||||
DRF_NESTED_MULTIPART_PARSER = {
|
||||
# output of parser is converted to querydict
|
||||
@@ -232,9 +235,10 @@ class Base(Configuration):
|
||||
# fallback/default languages throughout the app.
|
||||
LANGUAGES = values.SingleNestedTupleValue(
|
||||
(
|
||||
("en-us", _("English")),
|
||||
("fr-fr", _("French")),
|
||||
("de-de", _("German")),
|
||||
("en-us", "English"),
|
||||
("fr-fr", "Français"),
|
||||
("de-de", "Deutsch"),
|
||||
("nl-nl", "Nederlands"),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -333,6 +337,18 @@ class Base(Configuration):
|
||||
"PAGE_SIZE": 20,
|
||||
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning",
|
||||
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
||||
"DEFAULT_THROTTLE_RATES": {
|
||||
"user_list_sustained": values.Value(
|
||||
default="180/hour",
|
||||
environ_name="API_USERS_LIST_THROTTLE_RATE_SUSTAINED",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"user_list_burst": values.Value(
|
||||
default="30/minute",
|
||||
environ_name="API_USERS_LIST_THROTTLE_RATE_BURST",
|
||||
environ_prefix=None,
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
SPECTACULAR_SETTINGS = {
|
||||
@@ -600,6 +616,12 @@ class Base(Configuration):
|
||||
},
|
||||
}
|
||||
|
||||
API_USERS_LIST_LIMIT = values.PositiveIntegerValue(
|
||||
default=5,
|
||||
environ_name="API_USERS_LIST_LIMIT",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@property
|
||||
def ENVIRONMENT(self):
|
||||
@@ -646,8 +668,10 @@ class Base(Configuration):
|
||||
release=get_release(),
|
||||
integrations=[DjangoIntegration()],
|
||||
)
|
||||
with sentry_sdk.configure_scope() as scope:
|
||||
scope.set_extra("application", "backend")
|
||||
sentry_sdk.set_tag("application", "backend")
|
||||
|
||||
# Ignore the logs added by the DockerflowMiddleware
|
||||
ignore_logger("request.summary")
|
||||
|
||||
if (
|
||||
cls.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION
|
||||
@@ -695,6 +719,28 @@ class Development(Base):
|
||||
SESSION_COOKIE_NAME = "impress_sessionid"
|
||||
|
||||
USE_SWAGGER = True
|
||||
SESSION_CACHE_ALIAS = "session"
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.dummy.DummyCache",
|
||||
},
|
||||
"session": {
|
||||
"BACKEND": "django_redis.cache.RedisCache",
|
||||
"LOCATION": values.Value(
|
||||
"redis://redis:6379/2",
|
||||
environ_name="REDIS_URL",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"TIMEOUT": values.IntegerValue(
|
||||
30, # timeout in seconds
|
||||
environ_name="CACHES_DEFAULT_TIMEOUT",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"OPTIONS": {
|
||||
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
# pylint: disable=invalid-name
|
||||
|
||||
@@ -28,7 +28,7 @@ if settings.DEBUG:
|
||||
if settings.USE_SWAGGER or settings.DEBUG:
|
||||
urlpatterns += [
|
||||
path(
|
||||
f"{settings.API_VERSION}/swagger.json",
|
||||
f"api/{settings.API_VERSION}/swagger.json",
|
||||
SpectacularJSONAPIView.as_view(
|
||||
api_version=settings.API_VERSION,
|
||||
urlconf="core.urls",
|
||||
@@ -36,12 +36,12 @@ if settings.USE_SWAGGER or settings.DEBUG:
|
||||
name="client-api-schema",
|
||||
),
|
||||
path(
|
||||
f"{settings.API_VERSION}//swagger/",
|
||||
f"api/{settings.API_VERSION}/swagger/",
|
||||
SpectacularSwaggerView.as_view(url_name="client-api-schema"),
|
||||
name="swagger-ui-schema",
|
||||
),
|
||||
re_path(
|
||||
f"{settings.API_VERSION}//redoc/",
|
||||
f"api/{settings.API_VERSION}/redoc/",
|
||||
SpectacularRedocView.as_view(url_name="client-api-schema"),
|
||||
name="redoc-schema",
|
||||
),
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-02-06 15:30+0000\n"
|
||||
"PO-Revision-Date: 2025-02-10 14:14\n"
|
||||
"POT-Creation-Date: 2025-03-13 11:41+0000\n"
|
||||
"PO-Revision-Date: 2025-03-17 13:58\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: German\n"
|
||||
"Language: de_DE\n"
|
||||
@@ -32,37 +32,37 @@ msgstr "Wichtige Daten"
|
||||
|
||||
#: build/lib/core/admin.py:148 core/admin.py:148
|
||||
msgid "Tree structure"
|
||||
msgstr ""
|
||||
msgstr "Baumstruktur"
|
||||
|
||||
#: build/lib/core/api/filters.py:16 core/api/filters.py:16
|
||||
msgid "Creator is me"
|
||||
msgstr "Ersteller bin ich"
|
||||
|
||||
#: build/lib/core/api/filters.py:19 core/api/filters.py:19
|
||||
msgid "Favorite"
|
||||
msgstr "Favorit"
|
||||
|
||||
#: build/lib/core/api/filters.py:22 core/api/filters.py:22
|
||||
msgid "Title"
|
||||
msgstr "Titel"
|
||||
|
||||
#: build/lib/core/api/serializers.py:346 core/api/serializers.py:346
|
||||
#: build/lib/core/api/filters.py:30 core/api/filters.py:30
|
||||
msgid "Creator is me"
|
||||
msgstr "Ersteller bin ich"
|
||||
|
||||
#: build/lib/core/api/filters.py:33 core/api/filters.py:33
|
||||
msgid "Favorite"
|
||||
msgstr "Favorit"
|
||||
|
||||
#: build/lib/core/api/serializers.py:354 core/api/serializers.py:354
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "Ein neues Dokument wurde in Ihrem Namen erstellt!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:350 core/api/serializers.py:350
|
||||
#: build/lib/core/api/serializers.py:358 core/api/serializers.py:358
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr "Sie sind Besitzer eines neuen Dokuments:"
|
||||
|
||||
#: build/lib/core/api/serializers.py:453 core/api/serializers.py:453
|
||||
#: build/lib/core/api/serializers.py:473 core/api/serializers.py:473
|
||||
msgid "Body"
|
||||
msgstr "Inhalt"
|
||||
|
||||
#: build/lib/core/api/serializers.py:456 core/api/serializers.py:456
|
||||
#: build/lib/core/api/serializers.py:476 core/api/serializers.py:476
|
||||
msgid "Body type"
|
||||
msgstr "Typ"
|
||||
|
||||
#: build/lib/core/api/serializers.py:462 core/api/serializers.py:462
|
||||
#: build/lib/core/api/serializers.py:482 core/api/serializers.py:482
|
||||
msgid "Format"
|
||||
msgstr ""
|
||||
|
||||
@@ -78,319 +78,300 @@ msgstr "Benutzerkonto ist deaktiviert"
|
||||
|
||||
#: build/lib/core/enums.py:19 core/enums.py:19
|
||||
msgid "First child"
|
||||
msgstr ""
|
||||
msgstr "Erstes Unterelement"
|
||||
|
||||
#: build/lib/core/enums.py:20 core/enums.py:20
|
||||
msgid "Last child"
|
||||
msgstr ""
|
||||
msgstr "Letztes Unterelement"
|
||||
|
||||
#: build/lib/core/enums.py:21 core/enums.py:21
|
||||
msgid "First sibling"
|
||||
msgstr ""
|
||||
msgstr "Erstes Nebenelement"
|
||||
|
||||
#: build/lib/core/enums.py:22 core/enums.py:22
|
||||
msgid "Last sibling"
|
||||
msgstr ""
|
||||
msgstr "Letztes Nebenelement"
|
||||
|
||||
#: build/lib/core/enums.py:23 core/enums.py:23
|
||||
msgid "Left"
|
||||
msgstr ""
|
||||
msgstr "Links"
|
||||
|
||||
#: build/lib/core/enums.py:24 core/enums.py:24
|
||||
msgid "Right"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:54 build/lib/core/models.py:61 core/models.py:54
|
||||
#: core/models.py:61
|
||||
msgid "Reader"
|
||||
msgstr "Lesen"
|
||||
msgstr "Rechts"
|
||||
|
||||
#: build/lib/core/models.py:55 build/lib/core/models.py:62 core/models.py:55
|
||||
#: core/models.py:62
|
||||
msgid "Reader"
|
||||
msgstr "Lesen"
|
||||
|
||||
#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56
|
||||
#: core/models.py:63
|
||||
msgid "Editor"
|
||||
msgstr "Bearbeiten"
|
||||
|
||||
#: build/lib/core/models.py:63 core/models.py:63
|
||||
msgid "Administrator"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:64 core/models.py:64
|
||||
msgid "Administrator"
|
||||
msgstr "Administrator"
|
||||
|
||||
#: build/lib/core/models.py:65 core/models.py:65
|
||||
msgid "Owner"
|
||||
msgstr "Besitzer"
|
||||
|
||||
#: build/lib/core/models.py:75 core/models.py:75
|
||||
#: build/lib/core/models.py:76 core/models.py:76
|
||||
msgid "Restricted"
|
||||
msgstr "Beschränkt"
|
||||
|
||||
#: build/lib/core/models.py:79 core/models.py:79
|
||||
#: build/lib/core/models.py:80 core/models.py:80
|
||||
msgid "Authenticated"
|
||||
msgstr "Authentifiziert"
|
||||
|
||||
#: build/lib/core/models.py:81 core/models.py:81
|
||||
#: build/lib/core/models.py:82 core/models.py:82
|
||||
msgid "Public"
|
||||
msgstr "Öffentlich"
|
||||
|
||||
#: build/lib/core/models.py:103 core/models.py:103
|
||||
#: build/lib/core/models.py:153 core/models.py:153
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:104 core/models.py:104
|
||||
#: build/lib/core/models.py:154 core/models.py:154
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
msgstr "primärer Schlüssel für den Datensatz als UUID"
|
||||
|
||||
#: build/lib/core/models.py:110 core/models.py:110
|
||||
#: build/lib/core/models.py:160 core/models.py:160
|
||||
msgid "created on"
|
||||
msgstr "Erstellt"
|
||||
|
||||
#: build/lib/core/models.py:111 core/models.py:111
|
||||
#: build/lib/core/models.py:161 core/models.py:161
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "Datum und Uhrzeit, an dem ein Datensatz erstellt wurde"
|
||||
|
||||
#: build/lib/core/models.py:116 core/models.py:116
|
||||
#: build/lib/core/models.py:166 core/models.py:166
|
||||
msgid "updated on"
|
||||
msgstr "Aktualisiert"
|
||||
|
||||
#: build/lib/core/models.py:117 core/models.py:117
|
||||
#: build/lib/core/models.py:167 core/models.py:167
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "Datum und Uhrzeit, an dem zuletzt aktualisiert wurde"
|
||||
|
||||
#: build/lib/core/models.py:153 core/models.py:153
|
||||
#: build/lib/core/models.py:203 core/models.py:203
|
||||
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
|
||||
msgstr ""
|
||||
msgstr "Wir konnten keinen Benutzer mit diesem Abo finden, aber die E-Mail-Adresse ist bereits einem registrierten Benutzer zugeordnet."
|
||||
|
||||
#: build/lib/core/models.py:166 core/models.py:166
|
||||
#: build/lib/core/models.py:216 core/models.py:216
|
||||
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
|
||||
msgstr "Geben Sie eine gültige Unterseite ein. Dieser Wert darf nur Buchstaben, Zahlen und die @/./+/-/_/: Zeichen enthalten."
|
||||
|
||||
#: build/lib/core/models.py:172 core/models.py:172
|
||||
#: build/lib/core/models.py:222 core/models.py:222
|
||||
msgid "sub"
|
||||
msgstr "unter"
|
||||
|
||||
#: build/lib/core/models.py:174 core/models.py:174
|
||||
#: build/lib/core/models.py:224 core/models.py:224
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
|
||||
msgstr "Erforderlich. 255 Zeichen oder weniger. Buchstaben, Zahlen und die Zeichen @/./+/-/_/:"
|
||||
|
||||
#: build/lib/core/models.py:183 core/models.py:183
|
||||
#: build/lib/core/models.py:233 core/models.py:233
|
||||
msgid "full name"
|
||||
msgstr "Name"
|
||||
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
#: build/lib/core/models.py:234 core/models.py:234
|
||||
msgid "short name"
|
||||
msgstr "Kurzbezeichnung"
|
||||
|
||||
#: build/lib/core/models.py:186 core/models.py:186
|
||||
#: build/lib/core/models.py:236 core/models.py:236
|
||||
msgid "identity email address"
|
||||
msgstr "Identitäts-E-Mail-Adresse"
|
||||
|
||||
#: build/lib/core/models.py:191 core/models.py:191
|
||||
#: build/lib/core/models.py:241 core/models.py:241
|
||||
msgid "admin email address"
|
||||
msgstr "Admin E-Mail-Adresse"
|
||||
|
||||
#: build/lib/core/models.py:198 core/models.py:198
|
||||
#: build/lib/core/models.py:248 core/models.py:248
|
||||
msgid "language"
|
||||
msgstr "Sprache"
|
||||
|
||||
#: build/lib/core/models.py:199 core/models.py:199
|
||||
#: build/lib/core/models.py:249 core/models.py:249
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "Die Sprache, in der der Benutzer die Benutzeroberfläche sehen möchte."
|
||||
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
#: build/lib/core/models.py:257 core/models.py:257
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "Die Zeitzone, in der der Nutzer Zeiten sehen möchte."
|
||||
|
||||
#: build/lib/core/models.py:208 core/models.py:208
|
||||
#: build/lib/core/models.py:260 core/models.py:260
|
||||
msgid "device"
|
||||
msgstr "Gerät"
|
||||
|
||||
#: build/lib/core/models.py:210 core/models.py:210
|
||||
#: build/lib/core/models.py:262 core/models.py:262
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Ob der Benutzer ein Gerät oder ein echter Benutzer ist."
|
||||
|
||||
#: build/lib/core/models.py:213 core/models.py:213
|
||||
#: build/lib/core/models.py:265 core/models.py:265
|
||||
msgid "staff status"
|
||||
msgstr "Status des Teammitgliedes"
|
||||
|
||||
#: build/lib/core/models.py:215 core/models.py:215
|
||||
#: build/lib/core/models.py:267 core/models.py:267
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Gibt an, ob der Benutzer sich in diese Admin-Seite einloggen kann."
|
||||
|
||||
#: build/lib/core/models.py:218 core/models.py:218
|
||||
#: build/lib/core/models.py:270 core/models.py:270
|
||||
msgid "active"
|
||||
msgstr "aktiviert"
|
||||
|
||||
#: build/lib/core/models.py:221 core/models.py:221
|
||||
#: build/lib/core/models.py:273 core/models.py:273
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr "Ob dieser Benutzer als aktiviert behandelt werden soll. Deaktivieren Sie diese Option, anstatt Konten zu löschen."
|
||||
|
||||
#: build/lib/core/models.py:233 core/models.py:233
|
||||
#: build/lib/core/models.py:285 core/models.py:285
|
||||
msgid "user"
|
||||
msgstr "Benutzer"
|
||||
|
||||
#: build/lib/core/models.py:234 core/models.py:234
|
||||
#: build/lib/core/models.py:286 core/models.py:286
|
||||
msgid "users"
|
||||
msgstr "Benutzer"
|
||||
|
||||
#: build/lib/core/models.py:373 build/lib/core/models.py:925 core/models.py:373
|
||||
#: core/models.py:925
|
||||
#: build/lib/core/models.py:470 build/lib/core/models.py:1074
|
||||
#: core/models.py:470 core/models.py:1074
|
||||
msgid "title"
|
||||
msgstr "Titel"
|
||||
|
||||
#: build/lib/core/models.py:374 core/models.py:374
|
||||
#: build/lib/core/models.py:471 core/models.py:471
|
||||
msgid "excerpt"
|
||||
msgstr ""
|
||||
msgstr "Auszug"
|
||||
|
||||
#: build/lib/core/models.py:405 core/models.py:405
|
||||
#: build/lib/core/models.py:504 core/models.py:504
|
||||
msgid "Document"
|
||||
msgstr "Dokument"
|
||||
|
||||
#: build/lib/core/models.py:406 core/models.py:406
|
||||
#: build/lib/core/models.py:505 core/models.py:505
|
||||
msgid "Documents"
|
||||
msgstr "Dokumente"
|
||||
|
||||
#: build/lib/core/models.py:418 core/models.py:418
|
||||
#: build/lib/core/models.py:517 build/lib/core/models.py:826 core/models.py:517
|
||||
#: core/models.py:826
|
||||
msgid "Untitled Document"
|
||||
msgstr "Unbenanntes Dokument"
|
||||
|
||||
#: build/lib/core/models.py:719 core/models.py:719
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} hat ein Dokument mit Ihnen geteilt!"
|
||||
|
||||
#: build/lib/core/models.py:723 core/models.py:723
|
||||
#: build/lib/core/models.py:865 core/models.py:865
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} hat Sie mit der Rolle \"{role}\" zu folgendem Dokument eingeladen:"
|
||||
|
||||
#: build/lib/core/models.py:726 core/models.py:726
|
||||
#: build/lib/core/models.py:871 core/models.py:871
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} hat ein Dokument mit Ihnen geteilt: {title}"
|
||||
|
||||
#: build/lib/core/models.py:762 core/models.py:762
|
||||
msgid "This document is not deleted."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:769 core/models.py:769
|
||||
msgid "This document was permanently deleted and cannot be restored."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:820 core/models.py:820
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Dokument/Benutzer Linkverfolgung"
|
||||
|
||||
#: build/lib/core/models.py:821 core/models.py:821
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Dokument/Benutzer Linkverfolgung"
|
||||
|
||||
#: build/lib/core/models.py:827 core/models.py:827
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:850 core/models.py:850
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
msgid "Document favorite"
|
||||
msgstr "Dokumentenfavorit"
|
||||
|
||||
#: build/lib/core/models.py:851 core/models.py:851
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
msgid "Document favorites"
|
||||
msgstr "Dokumentfavoriten"
|
||||
|
||||
#: build/lib/core/models.py:857 core/models.py:857
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "Dieses Dokument ist bereits durch den gleichen Benutzer favorisiert worden."
|
||||
|
||||
#: build/lib/core/models.py:879 core/models.py:879
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
msgid "Document/user relation"
|
||||
msgstr "Dokument/Benutzerbeziehung"
|
||||
|
||||
#: build/lib/core/models.py:880 core/models.py:880
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
msgid "Document/user relations"
|
||||
msgstr "Dokument/Benutzerbeziehungen"
|
||||
|
||||
#: build/lib/core/models.py:886 core/models.py:886
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Dieser Benutzer befindet sich bereits in diesem Dokument."
|
||||
|
||||
#: build/lib/core/models.py:892 core/models.py:892
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Dieses Team befindet sich bereits in diesem Dokument."
|
||||
|
||||
#: build/lib/core/models.py:898 build/lib/core/models.py:1012
|
||||
#: core/models.py:898 core/models.py:1012
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1161
|
||||
#: core/models.py:1047 core/models.py:1161
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Benutzer oder Team müssen gesetzt werden, nicht beides."
|
||||
|
||||
#: build/lib/core/models.py:926 core/models.py:926
|
||||
#: build/lib/core/models.py:1075 core/models.py:1075
|
||||
msgid "description"
|
||||
msgstr "Beschreibung"
|
||||
|
||||
#: build/lib/core/models.py:927 core/models.py:927
|
||||
#: build/lib/core/models.py:1076 core/models.py:1076
|
||||
msgid "code"
|
||||
msgstr "Code"
|
||||
|
||||
#: build/lib/core/models.py:928 core/models.py:928
|
||||
#: build/lib/core/models.py:1077 core/models.py:1077
|
||||
msgid "css"
|
||||
msgstr "CSS"
|
||||
|
||||
#: build/lib/core/models.py:930 core/models.py:930
|
||||
#: build/lib/core/models.py:1079 core/models.py:1079
|
||||
msgid "public"
|
||||
msgstr "öffentlich"
|
||||
|
||||
#: build/lib/core/models.py:932 core/models.py:932
|
||||
#: build/lib/core/models.py:1081 core/models.py:1081
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "Ob diese Vorlage für jedermann öffentlich ist."
|
||||
|
||||
#: build/lib/core/models.py:938 core/models.py:938
|
||||
#: build/lib/core/models.py:1087 core/models.py:1087
|
||||
msgid "Template"
|
||||
msgstr "Vorlage"
|
||||
|
||||
#: build/lib/core/models.py:939 core/models.py:939
|
||||
#: build/lib/core/models.py:1088 core/models.py:1088
|
||||
msgid "Templates"
|
||||
msgstr "Vorlagen"
|
||||
|
||||
#: build/lib/core/models.py:993 core/models.py:993
|
||||
#: build/lib/core/models.py:1142 core/models.py:1142
|
||||
msgid "Template/user relation"
|
||||
msgstr "Vorlage/Benutzer-Beziehung"
|
||||
|
||||
#: build/lib/core/models.py:994 core/models.py:994
|
||||
#: build/lib/core/models.py:1143 core/models.py:1143
|
||||
msgid "Template/user relations"
|
||||
msgstr "Vorlage/Benutzerbeziehungen"
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1149 core/models.py:1149
|
||||
msgid "This user is already in this template."
|
||||
msgstr "Dieser Benutzer ist bereits in dieser Vorlage."
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1155 core/models.py:1155
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Dieses Team ist bereits in diesem Template."
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1178 core/models.py:1178
|
||||
msgid "email address"
|
||||
msgstr "E-Mail-Adresse"
|
||||
|
||||
#: build/lib/core/models.py:1048 core/models.py:1048
|
||||
#: build/lib/core/models.py:1197 core/models.py:1197
|
||||
msgid "Document invitation"
|
||||
msgstr "Einladung zum Dokument"
|
||||
|
||||
#: build/lib/core/models.py:1049 core/models.py:1049
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
msgid "Document invitations"
|
||||
msgstr "Dokumenteinladungen"
|
||||
|
||||
#: build/lib/core/models.py:1069 core/models.py:1069
|
||||
#: build/lib/core/models.py:1218 core/models.py:1218
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Diese E-Mail ist bereits einem registrierten Benutzer zugeordnet."
|
||||
|
||||
#: build/lib/impress/settings.py:236 impress/settings.py:236
|
||||
msgid "English"
|
||||
msgstr "Englisch"
|
||||
|
||||
#: build/lib/impress/settings.py:237 impress/settings.py:237
|
||||
msgid "French"
|
||||
msgstr "Französisch"
|
||||
|
||||
#: build/lib/impress/settings.py:238 impress/settings.py:238
|
||||
msgid "German"
|
||||
msgstr "Deutsch"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:162
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
msgid "Logo email"
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-02-06 15:30+0000\n"
|
||||
"PO-Revision-Date: 2025-02-10 14:14\n"
|
||||
"POT-Creation-Date: 2025-03-13 11:41+0000\n"
|
||||
"PO-Revision-Date: 2025-03-17 13:58\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
"Language: en_US\n"
|
||||
@@ -35,34 +35,34 @@ msgid "Tree structure"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/filters.py:16 core/api/filters.py:16
|
||||
msgid "Creator is me"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/filters.py:19 core/api/filters.py:19
|
||||
msgid "Favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/filters.py:22 core/api/filters.py:22
|
||||
msgid "Title"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:346 core/api/serializers.py:346
|
||||
#: build/lib/core/api/filters.py:30 core/api/filters.py:30
|
||||
msgid "Creator is me"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/filters.py:33 core/api/filters.py:33
|
||||
msgid "Favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:354 core/api/serializers.py:354
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:350 core/api/serializers.py:350
|
||||
#: build/lib/core/api/serializers.py:358 core/api/serializers.py:358
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:453 core/api/serializers.py:453
|
||||
#: build/lib/core/api/serializers.py:473 core/api/serializers.py:473
|
||||
msgid "Body"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:456 core/api/serializers.py:456
|
||||
#: build/lib/core/api/serializers.py:476 core/api/serializers.py:476
|
||||
msgid "Body type"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:462 core/api/serializers.py:462
|
||||
#: build/lib/core/api/serializers.py:482 core/api/serializers.py:482
|
||||
msgid "Format"
|
||||
msgstr ""
|
||||
|
||||
@@ -100,297 +100,278 @@ msgstr ""
|
||||
msgid "Right"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:54 build/lib/core/models.py:61 core/models.py:54
|
||||
#: core/models.py:61
|
||||
#: build/lib/core/models.py:55 build/lib/core/models.py:62 core/models.py:55
|
||||
#: core/models.py:62
|
||||
msgid "Reader"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:55 build/lib/core/models.py:62 core/models.py:55
|
||||
#: core/models.py:62
|
||||
#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56
|
||||
#: core/models.py:63
|
||||
msgid "Editor"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:63 core/models.py:63
|
||||
#: build/lib/core/models.py:64 core/models.py:64
|
||||
msgid "Administrator"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:64 core/models.py:64
|
||||
#: build/lib/core/models.py:65 core/models.py:65
|
||||
msgid "Owner"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:75 core/models.py:75
|
||||
#: build/lib/core/models.py:76 core/models.py:76
|
||||
msgid "Restricted"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:79 core/models.py:79
|
||||
#: build/lib/core/models.py:80 core/models.py:80
|
||||
msgid "Authenticated"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:81 core/models.py:81
|
||||
#: build/lib/core/models.py:82 core/models.py:82
|
||||
msgid "Public"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:103 core/models.py:103
|
||||
#: build/lib/core/models.py:153 core/models.py:153
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:104 core/models.py:104
|
||||
#: build/lib/core/models.py:154 core/models.py:154
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:110 core/models.py:110
|
||||
#: build/lib/core/models.py:160 core/models.py:160
|
||||
msgid "created on"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:111 core/models.py:111
|
||||
#: build/lib/core/models.py:161 core/models.py:161
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:116 core/models.py:116
|
||||
#: build/lib/core/models.py:166 core/models.py:166
|
||||
msgid "updated on"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:117 core/models.py:117
|
||||
#: build/lib/core/models.py:167 core/models.py:167
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:153 core/models.py:153
|
||||
#: build/lib/core/models.py:203 core/models.py:203
|
||||
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:166 core/models.py:166
|
||||
#: build/lib/core/models.py:216 core/models.py:216
|
||||
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:172 core/models.py:172
|
||||
#: build/lib/core/models.py:222 core/models.py:222
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:174 core/models.py:174
|
||||
#: build/lib/core/models.py:224 core/models.py:224
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:183 core/models.py:183
|
||||
#: build/lib/core/models.py:233 core/models.py:233
|
||||
msgid "full name"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
#: build/lib/core/models.py:234 core/models.py:234
|
||||
msgid "short name"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:186 core/models.py:186
|
||||
#: build/lib/core/models.py:236 core/models.py:236
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:191 core/models.py:191
|
||||
#: build/lib/core/models.py:241 core/models.py:241
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:198 core/models.py:198
|
||||
#: build/lib/core/models.py:248 core/models.py:248
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:199 core/models.py:199
|
||||
#: build/lib/core/models.py:249 core/models.py:249
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
#: build/lib/core/models.py:257 core/models.py:257
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:208 core/models.py:208
|
||||
#: build/lib/core/models.py:260 core/models.py:260
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:210 core/models.py:210
|
||||
#: build/lib/core/models.py:262 core/models.py:262
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:213 core/models.py:213
|
||||
#: build/lib/core/models.py:265 core/models.py:265
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:215 core/models.py:215
|
||||
#: build/lib/core/models.py:267 core/models.py:267
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:218 core/models.py:218
|
||||
#: build/lib/core/models.py:270 core/models.py:270
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:221 core/models.py:221
|
||||
#: build/lib/core/models.py:273 core/models.py:273
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:233 core/models.py:233
|
||||
#: build/lib/core/models.py:285 core/models.py:285
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:234 core/models.py:234
|
||||
#: build/lib/core/models.py:286 core/models.py:286
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:373 build/lib/core/models.py:925 core/models.py:373
|
||||
#: core/models.py:925
|
||||
#: build/lib/core/models.py:470 build/lib/core/models.py:1074
|
||||
#: core/models.py:470 core/models.py:1074
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:374 core/models.py:374
|
||||
#: build/lib/core/models.py:471 core/models.py:471
|
||||
msgid "excerpt"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:405 core/models.py:405
|
||||
#: build/lib/core/models.py:504 core/models.py:504
|
||||
msgid "Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:406 core/models.py:406
|
||||
#: build/lib/core/models.py:505 core/models.py:505
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:418 core/models.py:418
|
||||
#: build/lib/core/models.py:517 build/lib/core/models.py:826 core/models.py:517
|
||||
#: core/models.py:826
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:719 core/models.py:719
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:723 core/models.py:723
|
||||
#: build/lib/core/models.py:865 core/models.py:865
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:726 core/models.py:726
|
||||
#: build/lib/core/models.py:871 core/models.py:871
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:762 core/models.py:762
|
||||
msgid "This document is not deleted."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:769 core/models.py:769
|
||||
msgid "This document was permanently deleted and cannot be restored."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:820 core/models.py:820
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:821 core/models.py:821
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:827 core/models.py:827
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:850 core/models.py:850
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
msgid "Document favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:851 core/models.py:851
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
msgid "Document favorites"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:857 core/models.py:857
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:879 core/models.py:879
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:880 core/models.py:880
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:886 core/models.py:886
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:892 core/models.py:892
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:898 build/lib/core/models.py:1012
|
||||
#: core/models.py:898 core/models.py:1012
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1161
|
||||
#: core/models.py:1047 core/models.py:1161
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:926 core/models.py:926
|
||||
#: build/lib/core/models.py:1075 core/models.py:1075
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:927 core/models.py:927
|
||||
#: build/lib/core/models.py:1076 core/models.py:1076
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:928 core/models.py:928
|
||||
#: build/lib/core/models.py:1077 core/models.py:1077
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:930 core/models.py:930
|
||||
#: build/lib/core/models.py:1079 core/models.py:1079
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:932 core/models.py:932
|
||||
#: build/lib/core/models.py:1081 core/models.py:1081
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:938 core/models.py:938
|
||||
#: build/lib/core/models.py:1087 core/models.py:1087
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:939 core/models.py:939
|
||||
#: build/lib/core/models.py:1088 core/models.py:1088
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:993 core/models.py:993
|
||||
#: build/lib/core/models.py:1142 core/models.py:1142
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:994 core/models.py:994
|
||||
#: build/lib/core/models.py:1143 core/models.py:1143
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1149 core/models.py:1149
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1155 core/models.py:1155
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1178 core/models.py:1178
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1048 core/models.py:1048
|
||||
#: build/lib/core/models.py:1197 core/models.py:1197
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1049 core/models.py:1049
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1069 core/models.py:1069
|
||||
#: build/lib/core/models.py:1218 core/models.py:1218
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/impress/settings.py:236 impress/settings.py:236
|
||||
msgid "English"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/impress/settings.py:237 impress/settings.py:237
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/impress/settings.py:238 impress/settings.py:238
|
||||
msgid "German"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:162
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
msgid "Logo email"
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-02-06 15:30+0000\n"
|
||||
"PO-Revision-Date: 2025-02-10 14:14\n"
|
||||
"POT-Creation-Date: 2025-03-13 11:41+0000\n"
|
||||
"PO-Revision-Date: 2025-03-17 13:58\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: French\n"
|
||||
"Language: fr_FR\n"
|
||||
@@ -35,34 +35,34 @@ msgid "Tree structure"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/filters.py:16 core/api/filters.py:16
|
||||
msgid "Creator is me"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/filters.py:19 core/api/filters.py:19
|
||||
msgid "Favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/filters.py:22 core/api/filters.py:22
|
||||
msgid "Title"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:346 core/api/serializers.py:346
|
||||
#: build/lib/core/api/filters.py:30 core/api/filters.py:30
|
||||
msgid "Creator is me"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/filters.py:33 core/api/filters.py:33
|
||||
msgid "Favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:354 core/api/serializers.py:354
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "Un nouveau document a été créé pour vous !"
|
||||
|
||||
#: build/lib/core/api/serializers.py:350 core/api/serializers.py:350
|
||||
#: build/lib/core/api/serializers.py:358 core/api/serializers.py:358
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr "Vous avez été déclaré propriétaire d'un nouveau document :"
|
||||
|
||||
#: build/lib/core/api/serializers.py:453 core/api/serializers.py:453
|
||||
#: build/lib/core/api/serializers.py:473 core/api/serializers.py:473
|
||||
msgid "Body"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:456 core/api/serializers.py:456
|
||||
#: build/lib/core/api/serializers.py:476 core/api/serializers.py:476
|
||||
msgid "Body type"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:462 core/api/serializers.py:462
|
||||
#: build/lib/core/api/serializers.py:482 core/api/serializers.py:482
|
||||
msgid "Format"
|
||||
msgstr ""
|
||||
|
||||
@@ -100,297 +100,278 @@ msgstr ""
|
||||
msgid "Right"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:54 build/lib/core/models.py:61 core/models.py:54
|
||||
#: core/models.py:61
|
||||
#: build/lib/core/models.py:55 build/lib/core/models.py:62 core/models.py:55
|
||||
#: core/models.py:62
|
||||
msgid "Reader"
|
||||
msgstr "Lecteur"
|
||||
|
||||
#: build/lib/core/models.py:55 build/lib/core/models.py:62 core/models.py:55
|
||||
#: core/models.py:62
|
||||
#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56
|
||||
#: core/models.py:63
|
||||
msgid "Editor"
|
||||
msgstr "Éditeur"
|
||||
|
||||
#: build/lib/core/models.py:63 core/models.py:63
|
||||
#: build/lib/core/models.py:64 core/models.py:64
|
||||
msgid "Administrator"
|
||||
msgstr "Administrateur"
|
||||
|
||||
#: build/lib/core/models.py:64 core/models.py:64
|
||||
#: build/lib/core/models.py:65 core/models.py:65
|
||||
msgid "Owner"
|
||||
msgstr "Propriétaire"
|
||||
|
||||
#: build/lib/core/models.py:75 core/models.py:75
|
||||
#: build/lib/core/models.py:76 core/models.py:76
|
||||
msgid "Restricted"
|
||||
msgstr "Restreint"
|
||||
|
||||
#: build/lib/core/models.py:79 core/models.py:79
|
||||
#: build/lib/core/models.py:80 core/models.py:80
|
||||
msgid "Authenticated"
|
||||
msgstr "Authentifié"
|
||||
|
||||
#: build/lib/core/models.py:81 core/models.py:81
|
||||
#: build/lib/core/models.py:82 core/models.py:82
|
||||
msgid "Public"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:103 core/models.py:103
|
||||
#: build/lib/core/models.py:153 core/models.py:153
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:104 core/models.py:104
|
||||
#: build/lib/core/models.py:154 core/models.py:154
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:110 core/models.py:110
|
||||
#: build/lib/core/models.py:160 core/models.py:160
|
||||
msgid "created on"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:111 core/models.py:111
|
||||
#: build/lib/core/models.py:161 core/models.py:161
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:116 core/models.py:116
|
||||
#: build/lib/core/models.py:166 core/models.py:166
|
||||
msgid "updated on"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:117 core/models.py:117
|
||||
#: build/lib/core/models.py:167 core/models.py:167
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:153 core/models.py:153
|
||||
#: build/lib/core/models.py:203 core/models.py:203
|
||||
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:166 core/models.py:166
|
||||
#: build/lib/core/models.py:216 core/models.py:216
|
||||
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:172 core/models.py:172
|
||||
#: build/lib/core/models.py:222 core/models.py:222
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:174 core/models.py:174
|
||||
#: build/lib/core/models.py:224 core/models.py:224
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:183 core/models.py:183
|
||||
#: build/lib/core/models.py:233 core/models.py:233
|
||||
msgid "full name"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
#: build/lib/core/models.py:234 core/models.py:234
|
||||
msgid "short name"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:186 core/models.py:186
|
||||
#: build/lib/core/models.py:236 core/models.py:236
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:191 core/models.py:191
|
||||
#: build/lib/core/models.py:241 core/models.py:241
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:198 core/models.py:198
|
||||
#: build/lib/core/models.py:248 core/models.py:248
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:199 core/models.py:199
|
||||
#: build/lib/core/models.py:249 core/models.py:249
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
#: build/lib/core/models.py:257 core/models.py:257
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:208 core/models.py:208
|
||||
#: build/lib/core/models.py:260 core/models.py:260
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:210 core/models.py:210
|
||||
#: build/lib/core/models.py:262 core/models.py:262
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:213 core/models.py:213
|
||||
#: build/lib/core/models.py:265 core/models.py:265
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:215 core/models.py:215
|
||||
#: build/lib/core/models.py:267 core/models.py:267
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:218 core/models.py:218
|
||||
#: build/lib/core/models.py:270 core/models.py:270
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:221 core/models.py:221
|
||||
#: build/lib/core/models.py:273 core/models.py:273
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:233 core/models.py:233
|
||||
#: build/lib/core/models.py:285 core/models.py:285
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:234 core/models.py:234
|
||||
#: build/lib/core/models.py:286 core/models.py:286
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:373 build/lib/core/models.py:925 core/models.py:373
|
||||
#: core/models.py:925
|
||||
#: build/lib/core/models.py:470 build/lib/core/models.py:1074
|
||||
#: core/models.py:470 core/models.py:1074
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:374 core/models.py:374
|
||||
#: build/lib/core/models.py:471 core/models.py:471
|
||||
msgid "excerpt"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:405 core/models.py:405
|
||||
#: build/lib/core/models.py:504 core/models.py:504
|
||||
msgid "Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:406 core/models.py:406
|
||||
#: build/lib/core/models.py:505 core/models.py:505
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:418 core/models.py:418
|
||||
#: build/lib/core/models.py:517 build/lib/core/models.py:826 core/models.py:517
|
||||
#: core/models.py:826
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
msgstr "Document sans titre"
|
||||
|
||||
#: build/lib/core/models.py:719 core/models.py:719
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} a partagé un document avec vous!"
|
||||
|
||||
#: build/lib/core/models.py:723 core/models.py:723
|
||||
#: build/lib/core/models.py:865 core/models.py:865
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} vous a invité avec le rôle \"{role}\" sur le document suivant:"
|
||||
|
||||
#: build/lib/core/models.py:726 core/models.py:726
|
||||
#: build/lib/core/models.py:871 core/models.py:871
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} a partagé un document avec vous: {title}"
|
||||
|
||||
#: build/lib/core/models.py:762 core/models.py:762
|
||||
msgid "This document is not deleted."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:769 core/models.py:769
|
||||
msgid "This document was permanently deleted and cannot be restored."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:820 core/models.py:820
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:821 core/models.py:821
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:827 core/models.py:827
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:850 core/models.py:850
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
msgid "Document favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:851 core/models.py:851
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
msgid "Document favorites"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:857 core/models.py:857
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:879 core/models.py:879
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:880 core/models.py:880
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:886 core/models.py:886
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:892 core/models.py:892
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:898 build/lib/core/models.py:1012
|
||||
#: core/models.py:898 core/models.py:1012
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1161
|
||||
#: core/models.py:1047 core/models.py:1161
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:926 core/models.py:926
|
||||
#: build/lib/core/models.py:1075 core/models.py:1075
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:927 core/models.py:927
|
||||
#: build/lib/core/models.py:1076 core/models.py:1076
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:928 core/models.py:928
|
||||
#: build/lib/core/models.py:1077 core/models.py:1077
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:930 core/models.py:930
|
||||
#: build/lib/core/models.py:1079 core/models.py:1079
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:932 core/models.py:932
|
||||
#: build/lib/core/models.py:1081 core/models.py:1081
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:938 core/models.py:938
|
||||
#: build/lib/core/models.py:1087 core/models.py:1087
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:939 core/models.py:939
|
||||
#: build/lib/core/models.py:1088 core/models.py:1088
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:993 core/models.py:993
|
||||
#: build/lib/core/models.py:1142 core/models.py:1142
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:994 core/models.py:994
|
||||
#: build/lib/core/models.py:1143 core/models.py:1143
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
#: build/lib/core/models.py:1149 core/models.py:1149
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
#: build/lib/core/models.py:1155 core/models.py:1155
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
#: build/lib/core/models.py:1178 core/models.py:1178
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1048 core/models.py:1048
|
||||
#: build/lib/core/models.py:1197 core/models.py:1197
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1049 core/models.py:1049
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1069 core/models.py:1069
|
||||
#: build/lib/core/models.py:1218 core/models.py:1218
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/impress/settings.py:236 impress/settings.py:236
|
||||
msgid "English"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/impress/settings.py:237 impress/settings.py:237
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/impress/settings.py:238 impress/settings.py:238
|
||||
msgid "German"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:162
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
msgid "Logo email"
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-02-06 15:30+0000\n"
|
||||
"PO-Revision-Date: 2025-02-10 14:14\n"
|
||||
"POT-Creation-Date: 2025-03-13 11:41+0000\n"
|
||||
"PO-Revision-Date: 2025-03-17 13:58\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Dutch\n"
|
||||
"Language: nl_NL\n"
|
||||
@@ -19,396 +19,377 @@ msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:37 core/admin.py:37
|
||||
msgid "Personal info"
|
||||
msgstr ""
|
||||
msgstr "Persoonlijke informatie"
|
||||
|
||||
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
|
||||
#: core/admin.py:138
|
||||
msgid "Permissions"
|
||||
msgstr ""
|
||||
msgstr "Toestemmingen"
|
||||
|
||||
#: build/lib/core/admin.py:62 core/admin.py:62
|
||||
msgid "Important dates"
|
||||
msgstr ""
|
||||
msgstr "Belangrijke datums"
|
||||
|
||||
#: build/lib/core/admin.py:148 core/admin.py:148
|
||||
msgid "Tree structure"
|
||||
msgstr ""
|
||||
msgstr "Document structuur"
|
||||
|
||||
#: build/lib/core/api/filters.py:16 core/api/filters.py:16
|
||||
msgid "Creator is me"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/filters.py:19 core/api/filters.py:19
|
||||
msgid "Favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/filters.py:22 core/api/filters.py:22
|
||||
msgid "Title"
|
||||
msgstr ""
|
||||
msgstr "Titel"
|
||||
|
||||
#: build/lib/core/api/serializers.py:346 core/api/serializers.py:346
|
||||
#: build/lib/core/api/filters.py:30 core/api/filters.py:30
|
||||
msgid "Creator is me"
|
||||
msgstr "Ik ben Eigenaar"
|
||||
|
||||
#: build/lib/core/api/filters.py:33 core/api/filters.py:33
|
||||
msgid "Favorite"
|
||||
msgstr "Favoriete"
|
||||
|
||||
#: build/lib/core/api/serializers.py:354 core/api/serializers.py:354
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr ""
|
||||
msgstr "Een nieuw document was gecreëerd voor u!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:350 core/api/serializers.py:350
|
||||
#: build/lib/core/api/serializers.py:358 core/api/serializers.py:358
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr ""
|
||||
msgstr "U heeft eigenaarschap van een nieuw document:"
|
||||
|
||||
#: build/lib/core/api/serializers.py:453 core/api/serializers.py:453
|
||||
#: build/lib/core/api/serializers.py:473 core/api/serializers.py:473
|
||||
msgid "Body"
|
||||
msgstr ""
|
||||
msgstr "Text"
|
||||
|
||||
#: build/lib/core/api/serializers.py:456 core/api/serializers.py:456
|
||||
#: build/lib/core/api/serializers.py:476 core/api/serializers.py:476
|
||||
msgid "Body type"
|
||||
msgstr ""
|
||||
msgstr "Text type"
|
||||
|
||||
#: build/lib/core/api/serializers.py:462 core/api/serializers.py:462
|
||||
#: build/lib/core/api/serializers.py:482 core/api/serializers.py:482
|
||||
msgid "Format"
|
||||
msgstr ""
|
||||
msgstr "Formaat"
|
||||
|
||||
#: build/lib/core/authentication/backends.py:61
|
||||
#: core/authentication/backends.py:61
|
||||
msgid "Invalid response format or token verification failed"
|
||||
msgstr ""
|
||||
msgstr "Invalide response formaat of token verificatie gefaald"
|
||||
|
||||
#: build/lib/core/authentication/backends.py:108
|
||||
#: core/authentication/backends.py:108
|
||||
msgid "User account is disabled"
|
||||
msgstr ""
|
||||
msgstr "Gebruikersaccount is buiten gebruik gesteld"
|
||||
|
||||
#: build/lib/core/enums.py:19 core/enums.py:19
|
||||
msgid "First child"
|
||||
msgstr ""
|
||||
msgstr "Eerste node"
|
||||
|
||||
#: build/lib/core/enums.py:20 core/enums.py:20
|
||||
msgid "Last child"
|
||||
msgstr ""
|
||||
msgstr "Laatste node"
|
||||
|
||||
#: build/lib/core/enums.py:21 core/enums.py:21
|
||||
msgid "First sibling"
|
||||
msgstr ""
|
||||
msgstr "Eerste naaste"
|
||||
|
||||
#: build/lib/core/enums.py:22 core/enums.py:22
|
||||
msgid "Last sibling"
|
||||
msgstr ""
|
||||
msgstr "Laatste naaste"
|
||||
|
||||
#: build/lib/core/enums.py:23 core/enums.py:23
|
||||
msgid "Left"
|
||||
msgstr ""
|
||||
msgstr "Links"
|
||||
|
||||
#: build/lib/core/enums.py:24 core/enums.py:24
|
||||
msgid "Right"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:54 build/lib/core/models.py:61 core/models.py:54
|
||||
#: core/models.py:61
|
||||
msgid "Reader"
|
||||
msgstr ""
|
||||
msgstr "Rechts"
|
||||
|
||||
#: build/lib/core/models.py:55 build/lib/core/models.py:62 core/models.py:55
|
||||
#: core/models.py:62
|
||||
msgid "Editor"
|
||||
msgstr ""
|
||||
msgid "Reader"
|
||||
msgstr "Lezer"
|
||||
|
||||
#: build/lib/core/models.py:63 core/models.py:63
|
||||
msgid "Administrator"
|
||||
msgstr ""
|
||||
#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56
|
||||
#: core/models.py:63
|
||||
msgid "Editor"
|
||||
msgstr "Bewerker"
|
||||
|
||||
#: build/lib/core/models.py:64 core/models.py:64
|
||||
msgid "Administrator"
|
||||
msgstr "Administrator"
|
||||
|
||||
#: build/lib/core/models.py:65 core/models.py:65
|
||||
msgid "Owner"
|
||||
msgstr ""
|
||||
msgstr "Eigenaar"
|
||||
|
||||
#: build/lib/core/models.py:75 core/models.py:75
|
||||
#: build/lib/core/models.py:76 core/models.py:76
|
||||
msgid "Restricted"
|
||||
msgstr ""
|
||||
msgstr "Niet toegestaan"
|
||||
|
||||
#: build/lib/core/models.py:79 core/models.py:79
|
||||
#: build/lib/core/models.py:80 core/models.py:80
|
||||
msgid "Authenticated"
|
||||
msgstr ""
|
||||
msgstr "Geauthenticeerd"
|
||||
|
||||
#: build/lib/core/models.py:81 core/models.py:81
|
||||
#: build/lib/core/models.py:82 core/models.py:82
|
||||
msgid "Public"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:103 core/models.py:103
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:104 core/models.py:104
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:110 core/models.py:110
|
||||
msgid "created on"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:111 core/models.py:111
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:116 core/models.py:116
|
||||
msgid "updated on"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:117 core/models.py:117
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
msgstr "Publiek"
|
||||
|
||||
#: build/lib/core/models.py:153 core/models.py:153
|
||||
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
|
||||
msgstr ""
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: build/lib/core/models.py:154 core/models.py:154
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "primaire sleutel voor dossier als UUID"
|
||||
|
||||
#: build/lib/core/models.py:160 core/models.py:160
|
||||
msgid "created on"
|
||||
msgstr "gemaakt op"
|
||||
|
||||
#: build/lib/core/models.py:161 core/models.py:161
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "datum en tijd wanneer dossier was gecreëerd"
|
||||
|
||||
#: build/lib/core/models.py:166 core/models.py:166
|
||||
msgid "updated on"
|
||||
msgstr "Laatst gewijzigd op"
|
||||
|
||||
#: build/lib/core/models.py:167 core/models.py:167
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "datum en tijd waarop dossier laatst was gewijzigd"
|
||||
|
||||
#: build/lib/core/models.py:203 core/models.py:203
|
||||
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
|
||||
msgstr "Wij konden geen gebruiker vinden met deze id, maar de email is al geassocieerd met een geregistreerde gebruiker."
|
||||
|
||||
#: build/lib/core/models.py:216 core/models.py:216
|
||||
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
|
||||
msgstr ""
|
||||
msgstr ".Geef een valide id. De waarde mag alleen letters, nummers en @/./.+/-/_: karakters bevatten."
|
||||
|
||||
#: build/lib/core/models.py:172 core/models.py:172
|
||||
#: build/lib/core/models.py:222 core/models.py:222
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
msgstr "id"
|
||||
|
||||
#: build/lib/core/models.py:174 core/models.py:174
|
||||
#: build/lib/core/models.py:224 core/models.py:224
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:183 core/models.py:183
|
||||
msgid "full name"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:184 core/models.py:184
|
||||
msgid "short name"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:186 core/models.py:186
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:191 core/models.py:191
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:198 core/models.py:198
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:199 core/models.py:199
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:205 core/models.py:205
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:208 core/models.py:208
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:210 core/models.py:210
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:213 core/models.py:213
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:215 core/models.py:215
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:218 core/models.py:218
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:221 core/models.py:221
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr ""
|
||||
msgstr "Verplicht. 255 karakters of minder. Alleen letters, nummers en @/./+/-/_/: karakters zijn toegestaan."
|
||||
|
||||
#: build/lib/core/models.py:233 core/models.py:233
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
msgid "full name"
|
||||
msgstr "volledige naam"
|
||||
|
||||
#: build/lib/core/models.py:234 core/models.py:234
|
||||
msgid "short name"
|
||||
msgstr "gebruikersnaam"
|
||||
|
||||
#: build/lib/core/models.py:236 core/models.py:236
|
||||
msgid "identity email address"
|
||||
msgstr "identiteit email adres"
|
||||
|
||||
#: build/lib/core/models.py:241 core/models.py:241
|
||||
msgid "admin email address"
|
||||
msgstr "admin email adres"
|
||||
|
||||
#: build/lib/core/models.py:248 core/models.py:248
|
||||
msgid "language"
|
||||
msgstr "taal"
|
||||
|
||||
#: build/lib/core/models.py:249 core/models.py:249
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "De taal waarin de gebruiker de interface wilt zien."
|
||||
|
||||
#: build/lib/core/models.py:257 core/models.py:257
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "De tijdzone waarin de gebruiker de tijden wilt zien."
|
||||
|
||||
#: build/lib/core/models.py:260 core/models.py:260
|
||||
msgid "device"
|
||||
msgstr "apparaat"
|
||||
|
||||
#: build/lib/core/models.py:262 core/models.py:262
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
|
||||
|
||||
#: build/lib/core/models.py:265 core/models.py:265
|
||||
msgid "staff status"
|
||||
msgstr "beheerder status"
|
||||
|
||||
#: build/lib/core/models.py:267 core/models.py:267
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Of de gebruiker kan inloggen in het admin gedeelte."
|
||||
|
||||
#: build/lib/core/models.py:270 core/models.py:270
|
||||
msgid "active"
|
||||
msgstr "actief"
|
||||
|
||||
#: build/lib/core/models.py:273 core/models.py:273
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr "Of een gebruiker als actief moet worden beschouwd. Deselecteer dit in plaats van het account te deleten."
|
||||
|
||||
#: build/lib/core/models.py:285 core/models.py:285
|
||||
msgid "user"
|
||||
msgstr "gebruiker"
|
||||
|
||||
#: build/lib/core/models.py:286 core/models.py:286
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
msgstr "gebruikers"
|
||||
|
||||
#: build/lib/core/models.py:373 build/lib/core/models.py:925 core/models.py:373
|
||||
#: core/models.py:925
|
||||
#: build/lib/core/models.py:470 build/lib/core/models.py:1074
|
||||
#: core/models.py:470 core/models.py:1074
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
msgstr "titel"
|
||||
|
||||
#: build/lib/core/models.py:374 core/models.py:374
|
||||
#: build/lib/core/models.py:471 core/models.py:471
|
||||
msgid "excerpt"
|
||||
msgstr ""
|
||||
msgstr "uittreksel"
|
||||
|
||||
#: build/lib/core/models.py:405 core/models.py:405
|
||||
#: build/lib/core/models.py:504 core/models.py:504
|
||||
msgid "Document"
|
||||
msgstr ""
|
||||
msgstr "Document"
|
||||
|
||||
#: build/lib/core/models.py:406 core/models.py:406
|
||||
#: build/lib/core/models.py:505 core/models.py:505
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
msgstr "Documenten"
|
||||
|
||||
#: build/lib/core/models.py:418 core/models.py:418
|
||||
#: build/lib/core/models.py:517 build/lib/core/models.py:826 core/models.py:517
|
||||
#: core/models.py:826
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
msgstr "Naamloos Document"
|
||||
|
||||
#: build/lib/core/models.py:719 core/models.py:719
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr ""
|
||||
msgstr "{name} heeft een document met gedeeld!"
|
||||
|
||||
#: build/lib/core/models.py:723 core/models.py:723
|
||||
#: build/lib/core/models.py:865 core/models.py:865
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr ""
|
||||
msgstr "{name} heeft u uitgenodigd met de rol \"{role}\" op het volgende document:"
|
||||
|
||||
#: build/lib/core/models.py:726 core/models.py:726
|
||||
#: build/lib/core/models.py:871 core/models.py:871
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr ""
|
||||
msgstr "{name} heeft een document met u gedeeld: {title}"
|
||||
|
||||
#: build/lib/core/models.py:762 core/models.py:762
|
||||
msgid "This document is not deleted."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:769 core/models.py:769
|
||||
msgid "This document was permanently deleted and cannot be restored."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:820 core/models.py:820
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
msgstr "Document/gebruiker url"
|
||||
|
||||
#: build/lib/core/models.py:821 core/models.py:821
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
msgstr "Document/gebruiker url"
|
||||
|
||||
#: build/lib/core/models.py:827 core/models.py:827
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
msgstr "Een url bestaat al voor dit document/deze gebruiker."
|
||||
|
||||
#: build/lib/core/models.py:850 core/models.py:850
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
msgid "Document favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:851 core/models.py:851
|
||||
msgid "Document favorites"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:857 core/models.py:857
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:879 core/models.py:879
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:880 core/models.py:880
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:886 core/models.py:886
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:892 core/models.py:892
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:898 build/lib/core/models.py:1012
|
||||
#: core/models.py:898 core/models.py:1012
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:926 core/models.py:926
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:927 core/models.py:927
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:928 core/models.py:928
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:930 core/models.py:930
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:932 core/models.py:932
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:938 core/models.py:938
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:939 core/models.py:939
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:993 core/models.py:993
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:994 core/models.py:994
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
msgstr "Document favoriet"
|
||||
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
msgid "Document favorites"
|
||||
msgstr "Document favorieten"
|
||||
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "Dit document is al in gebruik als favoriete door dezelfde gebruiker."
|
||||
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
msgid "Document/user relation"
|
||||
msgstr "Document/gebruiker relatie"
|
||||
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
msgid "Document/user relations"
|
||||
msgstr "Document/gebruiker relaties"
|
||||
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
msgid "This user is already in this document."
|
||||
msgstr "De gebruiker is al in dit document."
|
||||
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Het team is al in dit document."
|
||||
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1161
|
||||
#: core/models.py:1047 core/models.py:1161
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Een gebruiker of team moet gekozen worden, maar niet beide."
|
||||
|
||||
#: build/lib/core/models.py:1075 core/models.py:1075
|
||||
msgid "description"
|
||||
msgstr "omschrijving"
|
||||
|
||||
#: build/lib/core/models.py:1076 core/models.py:1076
|
||||
msgid "code"
|
||||
msgstr "code"
|
||||
|
||||
#: build/lib/core/models.py:1077 core/models.py:1077
|
||||
msgid "css"
|
||||
msgstr "css"
|
||||
|
||||
#: build/lib/core/models.py:1079 core/models.py:1079
|
||||
msgid "public"
|
||||
msgstr "publiek"
|
||||
|
||||
#: build/lib/core/models.py:1081 core/models.py:1081
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "Of dit template als publiek is en door iedereen te gebruiken is."
|
||||
|
||||
#: build/lib/core/models.py:1087 core/models.py:1087
|
||||
msgid "Template"
|
||||
msgstr "Template"
|
||||
|
||||
#: build/lib/core/models.py:1088 core/models.py:1088
|
||||
msgid "Templates"
|
||||
msgstr "Templates"
|
||||
|
||||
#: build/lib/core/models.py:1142 core/models.py:1142
|
||||
msgid "Template/user relation"
|
||||
msgstr "Template/gebruiker relatie"
|
||||
|
||||
#: build/lib/core/models.py:1143 core/models.py:1143
|
||||
msgid "Template/user relations"
|
||||
msgstr "Template/gebruiker relaties"
|
||||
|
||||
#: build/lib/core/models.py:1149 core/models.py:1149
|
||||
msgid "This user is already in this template."
|
||||
msgstr "De gebruiker bestaat al in dit template."
|
||||
|
||||
#: build/lib/core/models.py:1155 core/models.py:1155
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Het team bestaat al in dit template."
|
||||
|
||||
#: build/lib/core/models.py:1178 core/models.py:1178
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
msgstr "email adres"
|
||||
|
||||
#: build/lib/core/models.py:1048 core/models.py:1048
|
||||
#: build/lib/core/models.py:1197 core/models.py:1197
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
msgstr "Document uitnodiging"
|
||||
|
||||
#: build/lib/core/models.py:1049 core/models.py:1049
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
msgstr "Document uitnodigingen"
|
||||
|
||||
#: build/lib/core/models.py:1069 core/models.py:1069
|
||||
#: build/lib/core/models.py:1218 core/models.py:1218
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/impress/settings.py:236 impress/settings.py:236
|
||||
msgid "English"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/impress/settings.py:237 impress/settings.py:237
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/impress/settings.py:238 impress/settings.py:238
|
||||
msgid "German"
|
||||
msgstr ""
|
||||
msgstr "Deze email is al geassocieerd met een geregistreerde gebruiker."
|
||||
|
||||
#: core/templates/mail/html/invitation.html:162
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
msgid "Logo email"
|
||||
msgstr ""
|
||||
msgstr "Logo email"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:209
|
||||
#: core/templates/mail/text/invitation.txt:10
|
||||
msgid "Open"
|
||||
msgstr ""
|
||||
msgstr "Open"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:226
|
||||
#: core/templates/mail/text/invitation.txt:14
|
||||
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
|
||||
msgstr ""
|
||||
msgstr " Docs, jouw nieuwe essentiële tool voor het organiseren, delen en collaboreren van documenten als team. "
|
||||
|
||||
#: core/templates/mail/html/invitation.html:233
|
||||
#: core/templates/mail/text/invitation.txt:16
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
msgstr ""
|
||||
msgstr " Geleverd door %(brandname)s "
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "impress"
|
||||
version = "2.2.0"
|
||||
version = "2.6.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -25,37 +25,37 @@ license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"boto3==1.36.7",
|
||||
"boto3==1.37.18",
|
||||
"Brotli==1.1.0",
|
||||
"celery[redis]==5.4.0",
|
||||
"django-configurations==2.5.1",
|
||||
"django-cors-headers==4.6.0",
|
||||
"django-cors-headers==4.7.0",
|
||||
"django-countries==7.6.1",
|
||||
"django-filter==24.3",
|
||||
"django-filter==25.1",
|
||||
"django-parler==2.3",
|
||||
"redis==5.2.1",
|
||||
"django-redis==5.4.0",
|
||||
"django-storages[s3]==1.14.4",
|
||||
"django-storages[s3]==1.14.5",
|
||||
"django-timezone-field>=5.1",
|
||||
"django==5.1.5",
|
||||
"django==5.1.7",
|
||||
"django-treebeard==4.7.1",
|
||||
"djangorestframework==3.15.2",
|
||||
"drf_spectacular==0.28.0",
|
||||
"dockerflow==2024.4.2",
|
||||
"easy_thumbnails==2.10",
|
||||
"factory_boy==3.3.1",
|
||||
"factory_boy==3.3.3",
|
||||
"gunicorn==23.0.0",
|
||||
"jsonschema==4.23.0",
|
||||
"markdown==3.7",
|
||||
"nested-multipart-parser==1.5.0",
|
||||
"openai==1.60.2",
|
||||
"psycopg[binary]==3.2.4",
|
||||
"openai==1.68.2",
|
||||
"psycopg[binary]==3.2.6",
|
||||
"PyJWT==2.10.1",
|
||||
"python-magic==0.4.27",
|
||||
"requests==2.32.3",
|
||||
"sentry-sdk==2.20.0",
|
||||
"sentry-sdk==2.24.0",
|
||||
"url-normalize==1.4.3",
|
||||
"whitenoise==6.8.2",
|
||||
"whitenoise==6.9.0",
|
||||
"mozilla-django-oidc==4.0.1",
|
||||
]
|
||||
|
||||
@@ -69,21 +69,21 @@ dependencies = [
|
||||
dev = [
|
||||
"django-extensions==3.2.3",
|
||||
"django-test-migrations==1.4.0",
|
||||
"drf-spectacular-sidecar==2024.12.1",
|
||||
"drf-spectacular-sidecar==2025.3.1",
|
||||
"freezegun==1.5.1",
|
||||
"ipdb==0.13.13",
|
||||
"ipython==8.31.0",
|
||||
"pyfakefs==5.7.4",
|
||||
"ipython==9.0.2",
|
||||
"pyfakefs==5.8.0",
|
||||
"pylint-django==2.6.1",
|
||||
"pylint==3.3.4",
|
||||
"pylint==3.3.6",
|
||||
"pytest-cov==6.0.0",
|
||||
"pytest-django==4.9.0",
|
||||
"pytest==8.3.4",
|
||||
"pytest-django==4.10.0",
|
||||
"pytest==8.3.5",
|
||||
"pytest-icdiff==0.9",
|
||||
"pytest-xdist==3.6.1",
|
||||
"responses==0.25.6",
|
||||
"ruff==0.9.3",
|
||||
"types-requests==2.32.0.20241016",
|
||||
"responses==0.25.7",
|
||||
"ruff==0.11.2",
|
||||
"types-requests==2.32.0.20250306",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
|
||||
1
src/frontend/apps/e2e/.gitignore
vendored
1
src/frontend/apps/e2e/.gitignore
vendored
@@ -4,3 +4,4 @@ report/
|
||||
blob-report/
|
||||
playwright/.auth/
|
||||
playwright/.cache/
|
||||
screenshots/
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg width="100" height="100" viewBox="0 0 100 100">
|
||||
<circle cx="50" cy="30" r="20" fill="#3498db" />
|
||||
<polygon
|
||||
points="50,10 55,20 65,20 58,30 60,40 50,35 40,40 42,30 35,20 45,20"
|
||||
fill="#f1c40f"
|
||||
/>
|
||||
<text x="50" y="70" text-anchor="middle" fill="white">Hello svg</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 292 B |
@@ -12,23 +12,36 @@ const saveStorageState = async (
|
||||
const context = await browser.newContext(useConfig);
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto('/', { waitUntil: 'networkidle' });
|
||||
await page.content();
|
||||
await expect(page.getByText('Docs').first()).toBeVisible();
|
||||
try {
|
||||
await page.goto('/', { waitUntil: 'networkidle' });
|
||||
await page.content();
|
||||
await expect(page.getByText('Docs').first()).toBeVisible();
|
||||
|
||||
await keyCloakSignIn(page, browserName);
|
||||
await keyCloakSignIn(page, browserName);
|
||||
|
||||
await expect(
|
||||
page.locator('header').first().getByRole('button', {
|
||||
name: 'Logout',
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('header').first().getByRole('button', {
|
||||
name: 'Logout',
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
await page.context().storageState({
|
||||
path: storageState as string,
|
||||
});
|
||||
await page.context().storageState({
|
||||
path: storageState as string,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
|
||||
await browser.close();
|
||||
await page.screenshot({
|
||||
path: `./screenshots/${browserName}-${Date.now()}.png`,
|
||||
});
|
||||
// Get console logs
|
||||
const consoleLogs = await page.evaluate(() =>
|
||||
console.log(window.console.log),
|
||||
);
|
||||
console.log(consoleLogs);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
};
|
||||
|
||||
async function globalSetup(config: FullConfig) {
|
||||
|
||||
@@ -51,9 +51,15 @@ export const createDoc = async (
|
||||
})
|
||||
.click();
|
||||
|
||||
await page.waitForURL('**/docs/**', {
|
||||
timeout: 10000,
|
||||
waitUntil: 'networkidle',
|
||||
});
|
||||
|
||||
const input = page.getByLabel('doc title input');
|
||||
await expect(input).toHaveText('');
|
||||
await input.click();
|
||||
|
||||
await input.fill(randomDocs[i]);
|
||||
await input.blur();
|
||||
}
|
||||
@@ -63,19 +69,22 @@ export const createDoc = async (
|
||||
|
||||
export const verifyDocName = async (page: Page, docName: string) => {
|
||||
const input = page.getByRole('textbox', { name: 'doc title input' });
|
||||
await expect(input).toBeVisible();
|
||||
await expect(input).toHaveText(docName);
|
||||
try {
|
||||
await expect(input).toHaveText(docName);
|
||||
} catch {
|
||||
await expect(page.getByRole('heading', { name: docName })).toBeVisible();
|
||||
}
|
||||
};
|
||||
|
||||
export const addNewMember = async (
|
||||
page: Page,
|
||||
index: number,
|
||||
role: 'Administrator' | 'Owner' | 'Member' | 'Editor' | 'Reader',
|
||||
fillText: string = 'user',
|
||||
fillText: string = 'user ',
|
||||
) => {
|
||||
const responsePromiseSearchUser = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes(`/users/?q=${fillText}`) &&
|
||||
response.url().includes(`/users/?q=${encodeURIComponent(fillText)}`) &&
|
||||
response.status() === 200,
|
||||
);
|
||||
|
||||
@@ -88,7 +97,7 @@ export const addNewMember = async (
|
||||
|
||||
// Intercept response
|
||||
const responseSearchUser = await responsePromiseSearchUser;
|
||||
const users = (await responseSearchUser.json()).results as {
|
||||
const users = (await responseSearchUser.json()) as {
|
||||
email: string;
|
||||
}[];
|
||||
|
||||
@@ -97,7 +106,7 @@ export const addNewMember = async (
|
||||
|
||||
// Choose a role
|
||||
await page.getByLabel('doc-role-dropdown').click();
|
||||
await page.getByRole('menuitem', { name: role }).click();
|
||||
await page.getByLabel(role).click();
|
||||
await page.getByRole('button', { name: 'Invite' }).click();
|
||||
|
||||
return users[index].email;
|
||||
@@ -191,6 +200,22 @@ export const mockedDocument = async (page: Page, json: object) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const mockedListDocs = async (page: Page, data: object[] = []) => {
|
||||
await page.route('**/documents/**/', async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method().includes('GET') && request.url().includes('page=')) {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
count: data.length,
|
||||
next: null,
|
||||
previous: null,
|
||||
results: data,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const mockedInvitations = async (page: Page, json?: object) => {
|
||||
await page.route('**/invitations/**/', async (route) => {
|
||||
const request = route.request();
|
||||
|
||||
@@ -8,12 +8,13 @@ const config = {
|
||||
CRISP_WEBSITE_ID: null,
|
||||
COLLABORATION_WS_URL: 'ws://localhost:8083/collaboration/ws/',
|
||||
ENVIRONMENT: 'development',
|
||||
FRONTEND_THEME: 'dsfr',
|
||||
FRONTEND_THEME: 'default',
|
||||
MEDIA_BASE_URL: 'http://localhost:8083',
|
||||
LANGUAGES: [
|
||||
['en-us', 'English'],
|
||||
['fr-fr', 'French'],
|
||||
['de-de', 'German'],
|
||||
['fr-fr', 'Français'],
|
||||
['de-de', 'Deutsch'],
|
||||
['nl-nl', 'Nederlands'],
|
||||
],
|
||||
LANGUAGE_CODE: 'en-us',
|
||||
POSTHOG_KEY: {},
|
||||
@@ -158,7 +159,7 @@ test.describe('Config: Not loggued', () => {
|
||||
expect(response.ok()).toBeTruthy();
|
||||
|
||||
const jsonResponse = await response.json();
|
||||
expect(jsonResponse.FRONTEND_THEME).toStrictEqual('dsfr');
|
||||
expect(jsonResponse.FRONTEND_THEME).toStrictEqual('default');
|
||||
|
||||
const footer = page.locator('footer').first();
|
||||
// alt 'Gouvernement Logo' comes from the theme
|
||||
|
||||
@@ -15,41 +15,6 @@ test.beforeEach(async ({ page }) => {
|
||||
});
|
||||
|
||||
test.describe('Doc Editor', () => {
|
||||
test('it check translations of the slash menu when changing language', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await createDoc(page, 'doc-toolbar', browserName, 1);
|
||||
|
||||
const header = page.locator('header').first();
|
||||
const editor = page.locator('.ProseMirror');
|
||||
// Trigger slash menu to show english menu
|
||||
await editor.click();
|
||||
await editor.fill('/');
|
||||
await expect(page.getByText('Headings', { exact: true })).toBeVisible();
|
||||
await header.click();
|
||||
await expect(page.getByText('Headings', { exact: true })).toBeHidden();
|
||||
|
||||
// Reset menu
|
||||
await editor.click();
|
||||
await editor.fill('');
|
||||
|
||||
// Change language to French
|
||||
await header.click();
|
||||
await header.getByRole('button', { name: /Language/ }).click();
|
||||
await page.getByRole('menuitem', { name: 'Français' }).click();
|
||||
await expect(
|
||||
header.getByRole('button').getByText('Français'),
|
||||
).toBeVisible();
|
||||
|
||||
// Trigger slash menu to show french menu
|
||||
await editor.click();
|
||||
await editor.fill('/');
|
||||
await expect(page.getByText('Titres', { exact: true })).toBeVisible();
|
||||
await header.click();
|
||||
await expect(page.getByText('Titres', { exact: true })).toBeHidden();
|
||||
});
|
||||
|
||||
test('it checks default toolbar buttons are displayed', async ({
|
||||
page,
|
||||
browserName,
|
||||
@@ -128,11 +93,7 @@ test.describe('Doc Editor', () => {
|
||||
const wsClosePromise = webSocket.waitForEvent('close');
|
||||
|
||||
await selectVisibility.click();
|
||||
await page
|
||||
.getByRole('menuitem', {
|
||||
name: 'Connected',
|
||||
})
|
||||
.click();
|
||||
await page.getByLabel('Connected').click();
|
||||
|
||||
// Assert that the doc reconnects to the ws
|
||||
const wsClose = await wsClosePromise;
|
||||
|
||||
@@ -98,6 +98,7 @@ test.describe('Doc Export', () => {
|
||||
test('it exports the doc to docx', async ({ page, browserName }) => {
|
||||
const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1);
|
||||
|
||||
const fileChooserPromise = page.waitForEvent('filechooser');
|
||||
const downloadPromise = page.waitForEvent('download', (download) => {
|
||||
return download.suggestedFilename().includes(`${randomDoc}.docx`);
|
||||
});
|
||||
@@ -107,6 +108,18 @@ test.describe('Doc Export', () => {
|
||||
await page.locator('.ProseMirror.bn-editor').click();
|
||||
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
|
||||
|
||||
await page.keyboard.press('Enter');
|
||||
await page.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Resizable image with caption').click();
|
||||
await page.getByText('Upload image').click();
|
||||
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(path.join(__dirname, 'assets/test.svg'));
|
||||
|
||||
const image = page.getByRole('img', { name: 'test.svg' });
|
||||
|
||||
await expect(image).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'download',
|
||||
@@ -136,6 +149,11 @@ test.describe('Doc Export', () => {
|
||||
test('it exports the docs with images', async ({ page, browserName }) => {
|
||||
const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1);
|
||||
|
||||
const responseCorsPromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/cors-proxy/') && response.status() === 200,
|
||||
);
|
||||
|
||||
const fileChooserPromise = page.waitForEvent('filechooser');
|
||||
const downloadPromise = page.waitForEvent('download', (download) => {
|
||||
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
|
||||
@@ -152,14 +170,20 @@ test.describe('Doc Export', () => {
|
||||
await page.getByText('Upload image').click();
|
||||
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(
|
||||
path.join(__dirname, 'assets/logo-suite-numerique.png'),
|
||||
);
|
||||
await fileChooser.setFiles(path.join(__dirname, 'assets/test.svg'));
|
||||
|
||||
const image = page.getByRole('img', { name: 'logo-suite-numerique.png' });
|
||||
const image = page.getByRole('img', { name: 'test.svg' });
|
||||
|
||||
await expect(image).toBeVisible();
|
||||
|
||||
await page.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Resizable image with caption').click();
|
||||
await page.getByRole('tab', { name: 'Embed' }).click();
|
||||
await page
|
||||
.getByRole('textbox', { name: 'Enter URL' })
|
||||
.fill('https://docs.numerique.gouv.fr/assets/logo-gouv.png');
|
||||
await page.getByText('Embed image').click();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'download',
|
||||
@@ -188,6 +212,8 @@ test.describe('Doc Export', () => {
|
||||
})
|
||||
.click();
|
||||
|
||||
const responseCors = await responseCorsPromise;
|
||||
expect(responseCors.ok()).toBe(true);
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
|
||||
|
||||
@@ -242,4 +268,47 @@ test.describe('Doc Export', () => {
|
||||
|
||||
expect(pdfData.text).toContain('Hello World'); // This is the pdf text
|
||||
});
|
||||
|
||||
/**
|
||||
* We cannot assert the line break is visible in the pdf but we can assert the
|
||||
* line break is visible in the editor and that the pdf is generated.
|
||||
*/
|
||||
test('it exports the doc with divider', async ({ page, browserName }) => {
|
||||
const [randomDoc] = await createDoc(page, 'export-divider', browserName, 1);
|
||||
|
||||
const downloadPromise = page.waitForEvent('download', (download) => {
|
||||
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
|
||||
});
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await editor.click();
|
||||
await editor.fill('Hello World');
|
||||
|
||||
// Trigger slash menu to show menu
|
||||
await editor.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Add a horizontal line').click();
|
||||
|
||||
await expect(
|
||||
editor.locator('.bn-block-content[data-content-type="divider"]'),
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'download',
|
||||
})
|
||||
.click();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Download',
|
||||
})
|
||||
.click();
|
||||
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
|
||||
|
||||
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
|
||||
const pdfData = await pdf(pdfBuffer);
|
||||
expect(pdfData.text).toContain('Hello World');
|
||||
});
|
||||
});
|
||||
|
||||
309
src/frontend/apps/e2e/__tests__/app-impress/doc-grid-dnd.spec.ts
Normal file
309
src/frontend/apps/e2e/__tests__/app-impress/doc-grid-dnd.spec.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc, mockedListDocs } from './common';
|
||||
|
||||
test.describe('Doc grid dnd', () => {
|
||||
test('it creates a doc', async ({ page, browserName }) => {
|
||||
await page.goto('/');
|
||||
const header = page.locator('header').first();
|
||||
await createDoc(page, 'Draggable doc', browserName, 1);
|
||||
await header.locator('h2').getByText('Docs').click();
|
||||
await createDoc(page, 'Droppable doc', browserName, 1);
|
||||
await header.locator('h2').getByText('Docs').click();
|
||||
|
||||
const response = await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().endsWith('documents/?page=1') &&
|
||||
response.status() === 200,
|
||||
);
|
||||
const responseJson = await response.json();
|
||||
|
||||
const items = responseJson.results;
|
||||
|
||||
const docsGrid = page.getByTestId('docs-grid');
|
||||
await expect(docsGrid).toBeVisible();
|
||||
await expect(page.getByTestId('grid-loader')).toBeHidden();
|
||||
const draggableElement = page.getByTestId(`draggable-doc-${items[1].id}`);
|
||||
const dropZone = page.getByTestId(`droppable-doc-${items[0].id}`);
|
||||
await expect(draggableElement).toBeVisible();
|
||||
await expect(dropZone).toBeVisible();
|
||||
|
||||
// Obtenir les positions des éléments
|
||||
const draggableBoundingBox = await draggableElement.boundingBox();
|
||||
const dropZoneBoundingBox = await dropZone.boundingBox();
|
||||
|
||||
expect(draggableBoundingBox).toBeDefined();
|
||||
expect(dropZoneBoundingBox).toBeDefined();
|
||||
|
||||
// eslint-disable-next-line playwright/no-conditional-in-test
|
||||
if (!draggableBoundingBox || !dropZoneBoundingBox) {
|
||||
throw new Error('Impossible de déterminer la position des éléments');
|
||||
}
|
||||
|
||||
await page.mouse.move(
|
||||
draggableBoundingBox.x + draggableBoundingBox.width / 2,
|
||||
draggableBoundingBox.y + draggableBoundingBox.height / 2,
|
||||
);
|
||||
await page.mouse.down();
|
||||
|
||||
// Déplacer vers la zone cible
|
||||
await page.mouse.move(
|
||||
dropZoneBoundingBox.x + dropZoneBoundingBox.width / 2,
|
||||
dropZoneBoundingBox.y + dropZoneBoundingBox.height / 2,
|
||||
{ steps: 10 }, // Rendre le mouvement plus fluide
|
||||
);
|
||||
|
||||
const dragOverlay = page.getByTestId('drag-doc-overlay');
|
||||
|
||||
await expect(dragOverlay).toBeVisible();
|
||||
await expect(dragOverlay).toHaveText(items[1].title as string);
|
||||
await page.mouse.up();
|
||||
|
||||
await expect(dragOverlay).toBeHidden();
|
||||
});
|
||||
|
||||
test('it checks cant drop when we have not the minimum role', async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockedListDocs(page, data);
|
||||
await page.goto('/');
|
||||
const docsGrid = page.getByTestId('docs-grid');
|
||||
await expect(docsGrid).toBeVisible();
|
||||
await expect(page.getByTestId('grid-loader')).toBeHidden();
|
||||
|
||||
const canDropAndDrag = page.getByTestId('droppable-doc-can-drop-and-drag');
|
||||
|
||||
const noDropAndNoDrag = page.getByTestId(
|
||||
'droppable-doc-no-drop-and-no-drag',
|
||||
);
|
||||
|
||||
await expect(canDropAndDrag).toBeVisible();
|
||||
|
||||
await expect(noDropAndNoDrag).toBeVisible();
|
||||
|
||||
const canDropAndDragBoundigBox = await canDropAndDrag.boundingBox();
|
||||
|
||||
const noDropAndNoDragBoundigBox = await noDropAndNoDrag.boundingBox();
|
||||
|
||||
if (!canDropAndDragBoundigBox || !noDropAndNoDragBoundigBox) {
|
||||
throw new Error('Impossible de déterminer la position des éléments');
|
||||
}
|
||||
|
||||
await page.mouse.move(
|
||||
canDropAndDragBoundigBox.x + canDropAndDragBoundigBox.width / 2,
|
||||
canDropAndDragBoundigBox.y + canDropAndDragBoundigBox.height / 2,
|
||||
);
|
||||
|
||||
await page.mouse.down();
|
||||
|
||||
await page.mouse.move(
|
||||
noDropAndNoDragBoundigBox.x + noDropAndNoDragBoundigBox.width / 2,
|
||||
noDropAndNoDragBoundigBox.y + noDropAndNoDragBoundigBox.height / 2,
|
||||
{ steps: 10 },
|
||||
);
|
||||
|
||||
const dragOverlay = page.getByTestId('drag-doc-overlay');
|
||||
|
||||
await expect(dragOverlay).toBeVisible();
|
||||
await expect(dragOverlay).toHaveText(
|
||||
'You must be at least the editor of the target document',
|
||||
);
|
||||
|
||||
await page.mouse.up();
|
||||
});
|
||||
|
||||
test('it checks cant drag when we have not the minimum role', async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockedListDocs(page, data);
|
||||
await page.goto('/');
|
||||
const docsGrid = page.getByTestId('docs-grid');
|
||||
await expect(docsGrid).toBeVisible();
|
||||
await expect(page.getByTestId('grid-loader')).toBeHidden();
|
||||
|
||||
const canDropAndDrag = page.getByTestId('droppable-doc-can-drop-and-drag');
|
||||
|
||||
const noDropAndNoDrag = page.getByTestId(
|
||||
'droppable-doc-no-drop-and-no-drag',
|
||||
);
|
||||
|
||||
await expect(canDropAndDrag).toBeVisible();
|
||||
|
||||
await expect(noDropAndNoDrag).toBeVisible();
|
||||
|
||||
const canDropAndDragBoundigBox = await canDropAndDrag.boundingBox();
|
||||
|
||||
const noDropAndNoDragBoundigBox = await noDropAndNoDrag.boundingBox();
|
||||
|
||||
if (!canDropAndDragBoundigBox || !noDropAndNoDragBoundigBox) {
|
||||
throw new Error('Impossible de déterminer la position des éléments');
|
||||
}
|
||||
|
||||
await page.mouse.move(
|
||||
noDropAndNoDragBoundigBox.x + noDropAndNoDragBoundigBox.width / 2,
|
||||
noDropAndNoDragBoundigBox.y + noDropAndNoDragBoundigBox.height / 2,
|
||||
);
|
||||
|
||||
await page.mouse.down();
|
||||
|
||||
await page.mouse.move(
|
||||
canDropAndDragBoundigBox.x + canDropAndDragBoundigBox.width / 2,
|
||||
canDropAndDragBoundigBox.y + canDropAndDragBoundigBox.height / 2,
|
||||
{ steps: 10 },
|
||||
);
|
||||
|
||||
const dragOverlay = page.getByTestId('drag-doc-overlay');
|
||||
|
||||
await expect(dragOverlay).toBeVisible();
|
||||
await expect(dragOverlay).toHaveText(
|
||||
'You must be the owner to move the document',
|
||||
);
|
||||
|
||||
await page.mouse.up();
|
||||
});
|
||||
});
|
||||
|
||||
const data = [
|
||||
{
|
||||
id: 'can-drop-and-drag',
|
||||
abilities: {
|
||||
accesses_manage: true,
|
||||
accesses_view: true,
|
||||
ai_transform: true,
|
||||
ai_translate: true,
|
||||
attachment_upload: true,
|
||||
children_list: true,
|
||||
children_create: true,
|
||||
collaboration_auth: true,
|
||||
descendants: true,
|
||||
destroy: true,
|
||||
favorite: true,
|
||||
link_configuration: true,
|
||||
invite_owner: true,
|
||||
move: true,
|
||||
partial_update: true,
|
||||
restore: true,
|
||||
retrieve: true,
|
||||
media_auth: true,
|
||||
link_select_options: {
|
||||
restricted: ['reader', 'editor'],
|
||||
authenticated: ['reader', 'editor'],
|
||||
public: ['reader', 'editor'],
|
||||
},
|
||||
tree: true,
|
||||
update: true,
|
||||
versions_destroy: true,
|
||||
versions_list: true,
|
||||
versions_retrieve: true,
|
||||
},
|
||||
created_at: '2025-03-14T14:45:22.527221Z',
|
||||
creator: 'bc6895e0-8f6d-4b00-827d-c143aa6b2ecb',
|
||||
depth: 1,
|
||||
excerpt: null,
|
||||
is_favorite: false,
|
||||
link_role: 'reader',
|
||||
link_reach: 'restricted',
|
||||
nb_accesses_ancestors: 1,
|
||||
nb_accesses_direct: 1,
|
||||
numchild: 5,
|
||||
path: '000000o',
|
||||
title: 'Can drop and drag',
|
||||
updated_at: '2025-03-14T14:45:27.699542Z',
|
||||
user_roles: ['owner'],
|
||||
},
|
||||
{
|
||||
id: 'can-only-drop',
|
||||
title: 'Can only drop',
|
||||
abilities: {
|
||||
accesses_manage: true,
|
||||
accesses_view: true,
|
||||
ai_transform: true,
|
||||
ai_translate: true,
|
||||
attachment_upload: true,
|
||||
children_list: true,
|
||||
children_create: true,
|
||||
collaboration_auth: true,
|
||||
descendants: true,
|
||||
destroy: true,
|
||||
favorite: true,
|
||||
link_configuration: true,
|
||||
invite_owner: true,
|
||||
move: true,
|
||||
partial_update: true,
|
||||
restore: true,
|
||||
retrieve: true,
|
||||
media_auth: true,
|
||||
link_select_options: {
|
||||
restricted: ['reader', 'editor'],
|
||||
authenticated: ['reader', 'editor'],
|
||||
public: ['reader', 'editor'],
|
||||
},
|
||||
tree: true,
|
||||
update: true,
|
||||
versions_destroy: true,
|
||||
versions_list: true,
|
||||
versions_retrieve: true,
|
||||
},
|
||||
created_at: '2025-03-14T14:45:22.527221Z',
|
||||
creator: 'bc6895e0-8f6d-4b00-827d-c143aa6b2ecb',
|
||||
depth: 1,
|
||||
excerpt: null,
|
||||
is_favorite: false,
|
||||
link_role: 'reader',
|
||||
link_reach: 'restricted',
|
||||
nb_accesses_ancestors: 1,
|
||||
nb_accesses_direct: 1,
|
||||
numchild: 5,
|
||||
path: '000000o',
|
||||
|
||||
updated_at: '2025-03-14T14:45:27.699542Z',
|
||||
user_roles: ['editor'],
|
||||
},
|
||||
{
|
||||
id: 'no-drop-and-no-drag',
|
||||
abilities: {
|
||||
accesses_manage: false,
|
||||
accesses_view: true,
|
||||
ai_transform: false,
|
||||
ai_translate: false,
|
||||
attachment_upload: false,
|
||||
children_list: true,
|
||||
children_create: false,
|
||||
collaboration_auth: true,
|
||||
descendants: true,
|
||||
destroy: false,
|
||||
favorite: true,
|
||||
link_configuration: false,
|
||||
invite_owner: false,
|
||||
move: false,
|
||||
partial_update: false,
|
||||
restore: false,
|
||||
retrieve: true,
|
||||
media_auth: true,
|
||||
link_select_options: {
|
||||
restricted: ['reader', 'editor'],
|
||||
authenticated: ['reader', 'editor'],
|
||||
public: ['reader', 'editor'],
|
||||
},
|
||||
tree: true,
|
||||
update: false,
|
||||
versions_destroy: false,
|
||||
versions_list: true,
|
||||
versions_retrieve: true,
|
||||
},
|
||||
created_at: '2025-03-14T14:44:16.032773Z',
|
||||
creator: '9264f420-f018-4bd6-96ae-4788f41af56d',
|
||||
depth: 1,
|
||||
excerpt: null,
|
||||
is_favorite: false,
|
||||
link_role: 'reader',
|
||||
link_reach: 'restricted',
|
||||
nb_accesses_ancestors: 14,
|
||||
nb_accesses_direct: 14,
|
||||
numchild: 0,
|
||||
path: '000000l',
|
||||
title: 'No drop and no drag',
|
||||
updated_at: '2025-03-14T14:44:16.032774Z',
|
||||
user_roles: ['reader'],
|
||||
},
|
||||
];
|
||||
@@ -115,25 +115,16 @@ test.describe('Document grid item options', () => {
|
||||
await expect(leftPanelFavorites.getByText(docTitle)).toBeHidden();
|
||||
});
|
||||
|
||||
test('it deletes the document', async ({ page }) => {
|
||||
let docs: SmallDoc[] = [];
|
||||
const response = await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().endsWith('documents/?page=1') &&
|
||||
response.status() === 200,
|
||||
);
|
||||
const result = await response.json();
|
||||
docs = result.results as SmallDoc[];
|
||||
test('it deletes the document', async ({ page, browserName }) => {
|
||||
const [docTitle] = await createDoc(page, `delete doc`, browserName);
|
||||
|
||||
const button = page.getByTestId(`docs-grid-actions-button-${docs[0].id}`);
|
||||
await expect(button).toBeVisible();
|
||||
await button.click();
|
||||
await page.goto('/');
|
||||
|
||||
const removeButton = page.getByTestId(
|
||||
`docs-grid-actions-remove-${docs[0].id}`,
|
||||
);
|
||||
await expect(removeButton).toBeVisible();
|
||||
await removeButton.click();
|
||||
await expect(page.getByText(docTitle)).toBeVisible();
|
||||
const row = await getGridRow(page, docTitle);
|
||||
await row.getByText(`more_horiz`).click();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Remove' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Delete a doc' }),
|
||||
@@ -145,20 +136,11 @@ test.describe('Document grid item options', () => {
|
||||
})
|
||||
.click();
|
||||
|
||||
const refetchResponse = await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().endsWith('documents/?page=1') &&
|
||||
response.status() === 200,
|
||||
);
|
||||
|
||||
const resultRefetch = await refetchResponse.json();
|
||||
expect(resultRefetch.count).toBe(result.count - 1);
|
||||
await expect(page.getByTestId('main-layout-loader')).toBeHidden();
|
||||
|
||||
await expect(
|
||||
page.getByText('The document has been deleted.'),
|
||||
).toBeVisible();
|
||||
await expect(button).toBeHidden();
|
||||
|
||||
await expect(page.getByText(docTitle)).toBeHidden();
|
||||
});
|
||||
|
||||
test("it checks if the delete option is disabled if we don't have the destroy capability", async ({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { expect, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
createDoc,
|
||||
getGridRow,
|
||||
goToGridDoc,
|
||||
mockedAccesses,
|
||||
mockedDocument,
|
||||
@@ -88,20 +89,14 @@ test.describe('Doc Header', () => {
|
||||
const [randomDoc] = await createDoc(page, 'doc-delete', browserName, 1);
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page
|
||||
.getByRole('menuitem', {
|
||||
name: 'Delete document',
|
||||
})
|
||||
.click();
|
||||
await page.getByLabel('Delete document').click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Delete a doc' }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
`Are you sure you want to delete the document "${randomDoc}"?`,
|
||||
),
|
||||
page.getByText(`Are you sure you want to delete this document ?`),
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
@@ -152,9 +147,7 @@ test.describe('Doc Header', () => {
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Delete document' }),
|
||||
).toBeDisabled();
|
||||
await expect(page.getByLabel('Delete document')).toBeDisabled();
|
||||
|
||||
// Click somewhere else to close the options
|
||||
await page.click('body', { position: { x: 0, y: 0 } });
|
||||
@@ -176,11 +169,7 @@ test.describe('Doc Header', () => {
|
||||
|
||||
await invitationCard.getByRole('button', { name: 'more_horiz' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('menuitem', {
|
||||
name: 'delete',
|
||||
}),
|
||||
).toBeEnabled();
|
||||
await expect(page.getByLabel('Delete')).toBeEnabled();
|
||||
await invitationCard.click();
|
||||
|
||||
const memberCard = shareModal.getByLabel('List members card');
|
||||
@@ -194,11 +183,7 @@ test.describe('Doc Header', () => {
|
||||
).toBeVisible();
|
||||
await memberCard.getByRole('button', { name: 'more_horiz' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('menuitem', {
|
||||
name: 'delete',
|
||||
}),
|
||||
).toBeEnabled();
|
||||
await expect(page.getByLabel('Delete')).toBeEnabled();
|
||||
});
|
||||
|
||||
test('it checks the options available if editor', async ({ page }) => {
|
||||
@@ -232,9 +217,7 @@ test.describe('Doc Header', () => {
|
||||
await expect(page.getByRole('button', { name: 'download' })).toBeVisible();
|
||||
await page.getByLabel('Open the document options').click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Delete document' }),
|
||||
).toBeDisabled();
|
||||
await expect(page.getByLabel('Delete document')).toBeDisabled();
|
||||
|
||||
// Click somewhere else to close the options
|
||||
await page.click('body', { position: { x: 0, y: 0 } });
|
||||
@@ -294,9 +277,7 @@ test.describe('Doc Header', () => {
|
||||
await expect(page.getByRole('button', { name: 'download' })).toBeVisible();
|
||||
await page.getByLabel('Open the document options').click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Delete document' }),
|
||||
).toBeDisabled();
|
||||
await expect(page.getByLabel('Delete document')).toBeDisabled();
|
||||
|
||||
// Click somewhere else to close the options
|
||||
await page.click('body', { position: { x: 0, y: 0 } });
|
||||
@@ -352,7 +333,7 @@ test.describe('Doc Header', () => {
|
||||
|
||||
// Copy content to clipboard
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page.getByRole('menuitem', { name: 'Copy as Markdown' }).click();
|
||||
await page.getByLabel('Copy as Markdown').click();
|
||||
await expect(page.getByText('Copied to clipboard')).toBeVisible();
|
||||
|
||||
// Test that clipboard is in Markdown format
|
||||
@@ -387,7 +368,7 @@ test.describe('Doc Header', () => {
|
||||
|
||||
// Copy content to clipboard
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page.getByRole('menuitem', { name: 'Copy as HTML' }).click();
|
||||
await page.getByLabel('Copy as HTML').click();
|
||||
await expect(page.getByText('Copied to clipboard')).toBeVisible();
|
||||
|
||||
// Test that clipboard is in HTML format
|
||||
@@ -398,7 +379,12 @@ test.describe('Doc Header', () => {
|
||||
expect(clipboardContent.trim()).toBe(`<h1>Hello World</h1><p></p>`);
|
||||
});
|
||||
|
||||
test('it checks the copy link button', async ({ page }) => {
|
||||
test('it checks the copy link button', async ({ page, browserName }) => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(
|
||||
browserName === 'webkit',
|
||||
'navigator.clipboard is not working with webkit and playwright',
|
||||
);
|
||||
await mockedDocument(page, {
|
||||
abilities: {
|
||||
destroy: false, // Means owner
|
||||
@@ -425,6 +411,50 @@ test.describe('Doc Header', () => {
|
||||
await shareButton.click();
|
||||
await page.getByRole('button', { name: 'Copy link' }).click();
|
||||
await expect(page.getByText('Link Copied !')).toBeVisible();
|
||||
|
||||
const handle = await page.evaluateHandle(() =>
|
||||
navigator.clipboard.readText(),
|
||||
);
|
||||
const clipboardContent = await handle.jsonValue();
|
||||
|
||||
const origin = await page.evaluate(() => window.location.origin);
|
||||
expect(clipboardContent.trim()).toMatch(
|
||||
`${origin}/docs/mocked-document-id/`,
|
||||
);
|
||||
});
|
||||
|
||||
test('it pins a document', async ({ page, browserName }) => {
|
||||
const [docTitle] = await createDoc(page, `Favorite doc`, browserName);
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
|
||||
// Pin
|
||||
await page.getByText('push_pin').click();
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await expect(page.getByText('Unpin')).toBeVisible();
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
const row = await getGridRow(page, docTitle);
|
||||
|
||||
// Check is pinned
|
||||
await expect(row.getByLabel('Pin document icon')).toBeVisible();
|
||||
const leftPanelFavorites = page.getByTestId('left-panel-favorites');
|
||||
await expect(leftPanelFavorites.getByText(docTitle)).toBeVisible();
|
||||
|
||||
await row.getByText(docTitle).click();
|
||||
await page.getByLabel('Open the document options').click();
|
||||
|
||||
// Unpin
|
||||
await page.getByText('Unpin').click();
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await expect(page.getByText('push_pin')).toBeVisible();
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
// Check is unpinned
|
||||
await expect(row.getByLabel('Pin document icon')).toBeHidden();
|
||||
await expect(leftPanelFavorites.getByText(docTitle)).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -435,12 +465,7 @@ test.describe('Documents Header mobile', () => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test('it checks the copy link button', async ({ page, browserName }) => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(
|
||||
browserName === 'webkit',
|
||||
'navigator.clipboard is not working with webkit and playwright',
|
||||
);
|
||||
test('it checks the copy link button is displayed', async ({ page }) => {
|
||||
await mockedDocument(page, {
|
||||
abilities: {
|
||||
destroy: false,
|
||||
@@ -460,19 +485,11 @@ test.describe('Documents Header mobile', () => {
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Copy link' })).toBeHidden();
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page.getByRole('menuitem', { name: 'Share' }).click();
|
||||
await page.getByRole('button', { name: 'Copy link' }).click();
|
||||
await expect(page.getByText('Link Copied !')).toBeVisible();
|
||||
// Test that clipboard is in HTML format
|
||||
const handle = await page.evaluateHandle(() =>
|
||||
navigator.clipboard.readText(),
|
||||
);
|
||||
const clipboardContent = await handle.jsonValue();
|
||||
|
||||
const origin = await page.evaluate(() => window.location.origin);
|
||||
expect(clipboardContent.trim()).toMatch(
|
||||
`${origin}/docs/mocked-document-id/`,
|
||||
);
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Copy link' }),
|
||||
).toBeVisible();
|
||||
await page.getByLabel('Share').click();
|
||||
await expect(page.getByRole('button', { name: 'Copy link' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('it checks the close button on Share modal', async ({ page }) => {
|
||||
@@ -494,7 +511,7 @@ test.describe('Documents Header mobile', () => {
|
||||
await goToGridDoc(page);
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page.getByRole('menuitem', { name: 'Share' }).click();
|
||||
await page.getByLabel('Share').click();
|
||||
|
||||
await expect(page.getByLabel('Share modal')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'close' }).click();
|
||||
|
||||
@@ -8,9 +8,11 @@ test.beforeEach(async ({ page }) => {
|
||||
|
||||
test.describe('Document create member', () => {
|
||||
test('it selects 2 users and 1 invitation', async ({ page, browserName }) => {
|
||||
const inputFill = 'user ';
|
||||
const responsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/users/?q=user') && response.status() === 200,
|
||||
response.url().includes(`/users/?q=${encodeURIComponent(inputFill)}`) &&
|
||||
response.status() === 200,
|
||||
);
|
||||
await createDoc(page, 'select-multi-users', browserName, 1);
|
||||
|
||||
@@ -22,9 +24,9 @@ test.describe('Document create member', () => {
|
||||
await expect(inputSearch).toBeVisible();
|
||||
|
||||
// Select user 1 and verify tag
|
||||
await inputSearch.fill('user');
|
||||
await inputSearch.fill(inputFill);
|
||||
const response = await responsePromise;
|
||||
const users = (await response.json()).results as {
|
||||
const users = (await response.json()) as {
|
||||
email: string;
|
||||
full_name?: string | null;
|
||||
}[];
|
||||
@@ -45,7 +47,7 @@ test.describe('Document create member', () => {
|
||||
).toBeVisible();
|
||||
|
||||
// Select user 2 and verify tag
|
||||
await inputSearch.fill('user');
|
||||
await inputSearch.fill(inputFill);
|
||||
await quickSearchContent
|
||||
.getByTestId(`search-user-row-${users[1].email}`)
|
||||
.click();
|
||||
@@ -65,15 +67,13 @@ test.describe('Document create member', () => {
|
||||
|
||||
// Check roles are displayed
|
||||
await list.getByLabel('doc-role-dropdown').click();
|
||||
await expect(page.getByRole('menuitem', { name: 'Reader' })).toBeVisible();
|
||||
await expect(page.getByRole('menuitem', { name: 'Editor' })).toBeVisible();
|
||||
await expect(page.getByRole('menuitem', { name: 'Owner' })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Administrator' }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByLabel('Reader')).toBeVisible();
|
||||
await expect(page.getByLabel('Editor')).toBeVisible();
|
||||
await expect(page.getByLabel('Owner')).toBeVisible();
|
||||
await expect(page.getByLabel('Administrator')).toBeVisible();
|
||||
|
||||
// Validate
|
||||
await page.getByRole('menuitem', { name: 'Administrator' }).click();
|
||||
await page.getByLabel('Administrator').click();
|
||||
await page.getByRole('button', { name: 'Invite' }).click();
|
||||
|
||||
// Check invitation added
|
||||
@@ -121,7 +121,7 @@ test.describe('Document create member', () => {
|
||||
// Choose a role
|
||||
const container = page.getByTestId('doc-share-add-member-list');
|
||||
await container.getByLabel('doc-role-dropdown').click();
|
||||
await page.getByRole('menuitem', { name: 'Owner' }).click();
|
||||
await page.getByLabel('Owner').click();
|
||||
|
||||
const responsePromiseCreateInvitation = page.waitForResponse(
|
||||
(response) =>
|
||||
@@ -139,7 +139,7 @@ test.describe('Document create member', () => {
|
||||
|
||||
// Choose a role
|
||||
await container.getByLabel('doc-role-dropdown').click();
|
||||
await page.getByRole('menuitem', { name: 'Owner' }).click();
|
||||
await page.getByLabel('Owner').click();
|
||||
|
||||
const responsePromiseCreateInvitationFail = page.waitForResponse(
|
||||
(response) =>
|
||||
@@ -155,47 +155,6 @@ test.describe('Document create member', () => {
|
||||
expect(responseCreateInvitationFail.ok()).toBeFalsy();
|
||||
});
|
||||
|
||||
test('The invitation endpoint get the language of the website', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await createDoc(page, 'user-invitation', browserName, 1);
|
||||
|
||||
const header = page.locator('header').first();
|
||||
await header.getByRole('button', { name: /Language/ }).click();
|
||||
await page.getByRole('menuitem', { name: 'Français' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Partager' }).click();
|
||||
|
||||
const inputSearch = page.getByRole('combobox', {
|
||||
name: 'Saisie de recherche rapide',
|
||||
});
|
||||
|
||||
const email = randomName('test@test.fr', browserName, 1)[0];
|
||||
await inputSearch.fill(email);
|
||||
await page.getByTestId(`search-user-row-${email}`).click();
|
||||
|
||||
// Choose a role
|
||||
const container = page.getByTestId('doc-share-add-member-list');
|
||||
await container.getByLabel('doc-role-dropdown').click();
|
||||
await page.getByRole('menuitem', { name: 'Administrateur' }).click();
|
||||
|
||||
const responsePromiseCreateInvitation = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/invitations/') && response.status() === 201,
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Invite' }).click();
|
||||
|
||||
// Check invitation sent
|
||||
|
||||
const responseCreateInvitation = await responsePromiseCreateInvitation;
|
||||
expect(responseCreateInvitation.ok()).toBeTruthy();
|
||||
expect(
|
||||
responseCreateInvitation.request().headers()['content-language'],
|
||||
).toBe('fr-fr');
|
||||
});
|
||||
|
||||
test('it manages invitation', async ({ page, browserName }) => {
|
||||
await createDoc(page, 'user-invitation', browserName, 1);
|
||||
|
||||
@@ -212,7 +171,7 @@ test.describe('Document create member', () => {
|
||||
// Choose a role
|
||||
const container = page.getByTestId('doc-share-add-member-list');
|
||||
await container.getByLabel('doc-role-dropdown').click();
|
||||
await page.getByRole('menuitem', { name: 'Administrator' }).click();
|
||||
await page.getByLabel('Administrator').click();
|
||||
|
||||
const responsePromiseCreateInvitation = page.waitForResponse(
|
||||
(response) =>
|
||||
@@ -232,14 +191,14 @@ test.describe('Document create member', () => {
|
||||
await expect(userInvitation).toBeVisible();
|
||||
|
||||
await userInvitation.getByLabel('doc-role-dropdown').click();
|
||||
await page.getByRole('menuitem', { name: 'Reader' }).click();
|
||||
await page.getByLabel('Reader').click();
|
||||
|
||||
const moreActions = userInvitation.getByRole('button', {
|
||||
name: 'more_horiz',
|
||||
});
|
||||
await moreActions.click();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByLabel('Delete').click();
|
||||
|
||||
await expect(userInvitation).toBeHidden();
|
||||
});
|
||||
|
||||
@@ -131,7 +131,7 @@ test.describe('Document list members', () => {
|
||||
const list = page.getByTestId('doc-share-quick-search');
|
||||
await expect(list).toBeVisible();
|
||||
const currentUser = list.getByTestId(
|
||||
`doc-share-member-row-user@chromium.e2e`,
|
||||
`doc-share-member-row-user@${browserName}.e2e`,
|
||||
);
|
||||
const currentUserRole = currentUser.getByLabel('doc-role-dropdown');
|
||||
await expect(currentUser).toBeVisible();
|
||||
@@ -161,12 +161,12 @@ test.describe('Document list members', () => {
|
||||
await list.click();
|
||||
|
||||
await currentUserRole.click();
|
||||
await page.getByRole('menuitem', { name: 'Administrator' }).click();
|
||||
await page.getByLabel('Administrator').click();
|
||||
await list.click();
|
||||
await expect(currentUserRole).toBeVisible();
|
||||
|
||||
await currentUserRole.click();
|
||||
await page.getByRole('menuitem', { name: 'Reader' }).click();
|
||||
await page.getByLabel('Reader').click();
|
||||
await list.click();
|
||||
await expect(currentUserRole).toBeHidden();
|
||||
});
|
||||
@@ -215,11 +215,11 @@ test.describe('Document list members', () => {
|
||||
await expect(mySelfMoreActions).toBeVisible();
|
||||
|
||||
await userReaderMoreActions.click();
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByLabel('Delete').click();
|
||||
await expect(userReader).toBeHidden();
|
||||
|
||||
await mySelfMoreActions.click();
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByLabel('Delete').click();
|
||||
await expect(
|
||||
page.getByText('You do not have permission to view this document.'),
|
||||
).toBeVisible();
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { expectLoginPage, keyCloakSignIn, mockedDocument } from './common';
|
||||
import {
|
||||
createDoc,
|
||||
expectLoginPage,
|
||||
keyCloakSignIn,
|
||||
mockedDocument,
|
||||
verifyDocName,
|
||||
} from './common';
|
||||
|
||||
test.describe('Doc Routing', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
@@ -50,6 +56,42 @@ test.describe('Doc Routing', () => {
|
||||
timeout: 15000,
|
||||
});
|
||||
});
|
||||
|
||||
test('checks 401 on docs/[id] page', async ({ page, browserName }) => {
|
||||
const [docTitle] = await createDoc(page, 'My new doc', browserName, 1);
|
||||
await verifyDocName(page, docTitle);
|
||||
|
||||
const responsePromise = page.route(
|
||||
/.*\/link-configuration\/$|users\/me\/$/,
|
||||
async (route) => {
|
||||
const request = route.request();
|
||||
|
||||
if (
|
||||
request.method().includes('PUT') ||
|
||||
request.method().includes('GET')
|
||||
) {
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
json: {
|
||||
detail: 'Log in to access the document',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const selectVisibility = page.getByLabel('Visibility', { exact: true });
|
||||
await selectVisibility.click();
|
||||
await page.getByLabel('Connected').click();
|
||||
|
||||
await responsePromise;
|
||||
|
||||
await expect(page.getByText('Log in to access the document')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Doc Routing: Not loggued', () => {
|
||||
|
||||
@@ -63,4 +63,35 @@ test.describe('Document search', () => {
|
||||
listSearch.getByRole('option').getByText(doc2Title),
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
test('it checks cmd+k modal search interaction', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
const [doc1Title] = await createDoc(
|
||||
page,
|
||||
'Doc seack ctrl k',
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
await verifyDocName(page, doc1Title);
|
||||
|
||||
await page.keyboard.press('Control+k');
|
||||
await expect(
|
||||
page.getByLabel('Search modal').getByText('search'),
|
||||
).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await editor.click();
|
||||
await editor.fill('Hello world');
|
||||
await editor.getByText('Hello world').dblclick();
|
||||
|
||||
await page.keyboard.press('Control+k');
|
||||
await expect(page.getByRole('textbox', { name: 'Edit URL' })).toBeVisible();
|
||||
await expect(
|
||||
page.getByLabel('Search modal').getByText('search'),
|
||||
).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/* eslint-disable playwright/no-conditional-in-test */
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc } from './common';
|
||||
|
||||
test.describe('Doc Tree', () => {
|
||||
test('create new sub pages', async ({ page, browserName }) => {
|
||||
await page.goto('/');
|
||||
await createDoc(page, 'doc-tree-content', browserName, 1);
|
||||
const addButton = page.getByRole('button', { name: 'New page' });
|
||||
const docTree = page.getByTestId('doc-tree');
|
||||
|
||||
await expect(addButton).toBeVisible();
|
||||
|
||||
// Attendre et intercepter la requête POST pour créer une nouvelle page
|
||||
const responsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/documents/') &&
|
||||
response.url().includes('/children/') &&
|
||||
response.request().method() === 'POST',
|
||||
);
|
||||
|
||||
await addButton.click();
|
||||
const response = await responsePromise;
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const subPageJson = await response.json();
|
||||
|
||||
await expect(docTree).toBeVisible();
|
||||
const subPageItem = docTree
|
||||
.getByTestId(`doc-sub-page-item-${subPageJson.id}`)
|
||||
.first();
|
||||
|
||||
await expect(subPageItem).toBeVisible();
|
||||
await subPageItem.click();
|
||||
const input = page.getByRole('textbox', { name: 'doc title input' });
|
||||
await input.click();
|
||||
await input.fill('Test');
|
||||
await input.press('Enter');
|
||||
await expect(subPageItem.getByText('Test')).toBeVisible();
|
||||
await page.reload();
|
||||
await expect(subPageItem.getByText('Test')).toBeVisible();
|
||||
});
|
||||
|
||||
test('check the reorder of sub pages', async ({ page, browserName }) => {
|
||||
await page.goto('/');
|
||||
await createDoc(page, 'doc-tree-content', browserName, 1);
|
||||
const addButton = page.getByRole('button', { name: 'New page' });
|
||||
await expect(addButton).toBeVisible();
|
||||
|
||||
const docTree = page.getByTestId('doc-tree');
|
||||
|
||||
// Create first sub page
|
||||
const firstResponsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/documents/') &&
|
||||
response.url().includes('/children/') &&
|
||||
response.request().method() === 'POST',
|
||||
);
|
||||
|
||||
await addButton.click();
|
||||
const firstResponse = await firstResponsePromise;
|
||||
expect(firstResponse.ok()).toBeTruthy();
|
||||
|
||||
const secondResponsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/documents/') &&
|
||||
response.url().includes('/children/') &&
|
||||
response.request().method() === 'POST',
|
||||
);
|
||||
|
||||
// Create second sub page
|
||||
await addButton.click();
|
||||
const secondResponse = await secondResponsePromise;
|
||||
expect(secondResponse.ok()).toBeTruthy();
|
||||
|
||||
const secondSubPageJson = await secondResponse.json();
|
||||
const firstSubPageJson = await firstResponse.json();
|
||||
|
||||
const firstSubPageItem = docTree
|
||||
.getByTestId(`doc-sub-page-item-${firstSubPageJson.id}`)
|
||||
.first();
|
||||
|
||||
const secondSubPageItem = docTree
|
||||
.getByTestId(`doc-sub-page-item-${secondSubPageJson.id}`)
|
||||
.first();
|
||||
|
||||
// check that the sub pages are visible in the tree
|
||||
await expect(firstSubPageItem).toBeVisible();
|
||||
await expect(secondSubPageItem).toBeVisible();
|
||||
|
||||
// get the bounding boxes of the sub pages
|
||||
const firstSubPageBoundingBox = await firstSubPageItem.boundingBox();
|
||||
const secondSubPageBoundingBox = await secondSubPageItem.boundingBox();
|
||||
|
||||
expect(firstSubPageBoundingBox).toBeDefined();
|
||||
expect(secondSubPageBoundingBox).toBeDefined();
|
||||
|
||||
if (!firstSubPageBoundingBox || !secondSubPageBoundingBox) {
|
||||
throw new Error('Impossible de déterminer la position des éléments');
|
||||
}
|
||||
|
||||
// move the first sub page to the second position
|
||||
await page.mouse.move(
|
||||
firstSubPageBoundingBox.x + firstSubPageBoundingBox.width / 2,
|
||||
firstSubPageBoundingBox.y + firstSubPageBoundingBox.height / 2,
|
||||
);
|
||||
|
||||
await page.mouse.down();
|
||||
|
||||
await page.mouse.move(
|
||||
secondSubPageBoundingBox.x + secondSubPageBoundingBox.width / 2,
|
||||
secondSubPageBoundingBox.y + secondSubPageBoundingBox.height + 4,
|
||||
{ steps: 10 },
|
||||
);
|
||||
|
||||
await page.mouse.up();
|
||||
|
||||
// check that the sub pages are visible in the tree
|
||||
await expect(firstSubPageItem).toBeVisible();
|
||||
await expect(secondSubPageItem).toBeVisible();
|
||||
|
||||
// reload the page
|
||||
await page.reload();
|
||||
|
||||
// check that the sub pages are visible in the tree
|
||||
await expect(firstSubPageItem).toBeVisible();
|
||||
await expect(secondSubPageItem).toBeVisible();
|
||||
|
||||
// Check the position of the sub pages
|
||||
const allSubPageItems = await docTree
|
||||
.getByTestId(/^doc-sub-page-item/)
|
||||
.all();
|
||||
|
||||
expect(allSubPageItems.length).toBe(2);
|
||||
|
||||
// Vérifier que le premier élément a l'ID de la deuxième sous-page après le drag and drop
|
||||
|
||||
await expect(allSubPageItems[0]).toHaveAttribute(
|
||||
'data-testid',
|
||||
`doc-sub-page-item-${secondSubPageJson.id}`,
|
||||
);
|
||||
|
||||
// Vérifier que le deuxième élément a l'ID de la première sous-page après le drag and drop
|
||||
await expect(allSubPageItems[1]).toHaveAttribute(
|
||||
'data-testid',
|
||||
`doc-sub-page-item-${firstSubPageJson.id}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -18,11 +18,7 @@ test.describe('Doc Version', () => {
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page
|
||||
.getByRole('menuitem', {
|
||||
name: 'Version history',
|
||||
})
|
||||
.click();
|
||||
await page.getByLabel('Version history').click();
|
||||
await expect(page.getByText('History', { exact: true })).toBeVisible();
|
||||
|
||||
const modal = page.getByLabel('version history modal');
|
||||
@@ -58,11 +54,7 @@ test.describe('Doc Version', () => {
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page
|
||||
.getByRole('menuitem', {
|
||||
name: 'Version history',
|
||||
})
|
||||
.click();
|
||||
await page.getByLabel('Version history').click();
|
||||
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(page.getByText('History', { exact: true })).toBeVisible();
|
||||
@@ -90,9 +82,7 @@ test.describe('Doc Version', () => {
|
||||
await verifyDocName(page, 'Mocked document');
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Version history' }),
|
||||
).toBeDisabled();
|
||||
await expect(page.getByLabel('Version history')).toBeDisabled();
|
||||
});
|
||||
|
||||
test('it restores the doc version', async ({ page, browserName }) => {
|
||||
@@ -119,11 +109,7 @@ test.describe('Doc Version', () => {
|
||||
await expect(page.getByText('World')).toBeVisible();
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page
|
||||
.getByRole('menuitem', {
|
||||
name: 'Version history',
|
||||
})
|
||||
.click();
|
||||
await page.getByLabel('Version history').click();
|
||||
|
||||
const modal = page.getByLabel('version history modal');
|
||||
const panel = modal.getByLabel('version list');
|
||||
|
||||
@@ -49,21 +49,13 @@ test.describe('Doc Visibility', () => {
|
||||
await expect(page.getByLabel('Can read and edit')).toBeHidden();
|
||||
|
||||
await selectVisibility.click();
|
||||
await page
|
||||
.getByRole('menuitem', {
|
||||
name: 'Connected',
|
||||
})
|
||||
.click();
|
||||
await page.getByLabel('Connected').click();
|
||||
|
||||
await expect(page.getByLabel('Visibility mode')).toBeVisible();
|
||||
|
||||
await selectVisibility.click();
|
||||
|
||||
await page
|
||||
.getByRole('menuitem', {
|
||||
name: 'Public',
|
||||
})
|
||||
.click();
|
||||
await page.getByLabel('Public', { exact: true }).click();
|
||||
|
||||
await expect(page.getByLabel('Visibility mode')).toBeVisible();
|
||||
});
|
||||
@@ -162,7 +154,7 @@ test.describe('Doc Visibility: Restricted', () => {
|
||||
// Choose a role
|
||||
const container = page.getByTestId('doc-share-add-member-list');
|
||||
await container.getByLabel('doc-role-dropdown').click();
|
||||
await page.getByRole('menuitem', { name: 'Administrator' }).click();
|
||||
await page.getByLabel('Reader').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Invite' }).click();
|
||||
|
||||
|
||||
@@ -1,19 +1,41 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { Page, expect, test } from '@playwright/test';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
import { createDoc } from './common';
|
||||
|
||||
test.describe('Language', () => {
|
||||
test('checks the language picker', async ({ page }) => {
|
||||
await expect(page.getByLabel('Logout')).toBeVisible();
|
||||
test.describe.serial('Language', () => {
|
||||
let page: Page;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForLanguageSwitch(page, TestLanguage.English);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
// Switch back to English - important for other tests to run as expected
|
||||
await waitForLanguageSwitch(page, TestLanguage.English);
|
||||
});
|
||||
|
||||
test('checks language switching', async ({ page }) => {
|
||||
const header = page.locator('header').first();
|
||||
await header
|
||||
.getByRole('button', { name: /Language/ })
|
||||
.getByText('English')
|
||||
.click();
|
||||
await page.getByRole('menuitem', { name: 'Français' }).click();
|
||||
|
||||
// initial language should be english
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: 'New doc',
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
// switch to french
|
||||
await waitForLanguageSwitch(page, TestLanguage.French);
|
||||
|
||||
await expect(
|
||||
header.getByRole('button').getByText('Français'),
|
||||
).toBeVisible();
|
||||
@@ -21,7 +43,7 @@ test.describe('Language', () => {
|
||||
await expect(page.getByLabel('Se déconnecter')).toBeVisible();
|
||||
|
||||
await header.getByRole('button').getByText('Français').click();
|
||||
await page.getByRole('menuitem', { name: 'Deutsch' }).click();
|
||||
await page.getByLabel('Deutsch').click();
|
||||
await expect(header.getByRole('button').getByText('Deutsch')).toBeVisible();
|
||||
|
||||
await expect(page.getByLabel('Abmelden')).toBeVisible();
|
||||
@@ -52,15 +74,76 @@ test.describe('Language', () => {
|
||||
// Check for English 404 response
|
||||
await check404Response('Not found.');
|
||||
|
||||
// Switch language to French
|
||||
const header = page.locator('header').first();
|
||||
await header
|
||||
.getByRole('button', { name: /Language/ })
|
||||
.getByText('English')
|
||||
.click();
|
||||
await page.getByRole('menuitem', { name: 'Français' }).click();
|
||||
await waitForLanguageSwitch(page, TestLanguage.French);
|
||||
|
||||
// Check for French 404 response
|
||||
await check404Response('Pas trouvé.');
|
||||
});
|
||||
|
||||
test('it check translations of the slash menu when changing language', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await createDoc(page, 'doc-toolbar', browserName, 1);
|
||||
|
||||
const header = page.locator('header').first();
|
||||
const editor = page.locator('.ProseMirror');
|
||||
// Trigger slash menu to show english menu
|
||||
await editor.click();
|
||||
await editor.fill('/');
|
||||
await expect(page.getByText('Headings', { exact: true })).toBeVisible();
|
||||
await header.click();
|
||||
await expect(page.getByText('Headings', { exact: true })).toBeHidden();
|
||||
|
||||
// Reset menu
|
||||
await editor.click();
|
||||
await editor.fill('');
|
||||
|
||||
// Change language to French
|
||||
await waitForLanguageSwitch(page, TestLanguage.French);
|
||||
|
||||
// Trigger slash menu to show french menu
|
||||
await editor.click();
|
||||
await editor.fill('/');
|
||||
await expect(page.getByText('Titres', { exact: true })).toBeVisible();
|
||||
await header.click();
|
||||
await expect(page.getByText('Titres', { exact: true })).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
// language helper
|
||||
export const TestLanguage = {
|
||||
English: {
|
||||
label: 'English',
|
||||
expectedLocale: ['en-us'],
|
||||
},
|
||||
French: {
|
||||
label: 'Français',
|
||||
expectedLocale: ['fr-fr'],
|
||||
},
|
||||
German: {
|
||||
label: 'Deutsch',
|
||||
expectedLocale: ['de-de'],
|
||||
},
|
||||
} as const;
|
||||
|
||||
type TestLanguageKey = keyof typeof TestLanguage;
|
||||
type TestLanguageValue = (typeof TestLanguage)[TestLanguageKey];
|
||||
|
||||
export async function waitForLanguageSwitch(
|
||||
page: Page,
|
||||
lang: TestLanguageValue,
|
||||
) {
|
||||
const header = page.locator('header').first();
|
||||
await header.getByRole('button', { name: 'arrow_drop_down' }).click();
|
||||
|
||||
const responsePromise = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes('/user') && resp.request().method() === 'PATCH',
|
||||
);
|
||||
await page.getByLabel(lang.label).click();
|
||||
const resolvedResponsePromise = await responsePromise;
|
||||
const responseData = await resolvedResponsePromise.json();
|
||||
|
||||
expect(lang.expectedLocale).toContain(responseData.language);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-e2e",
|
||||
"version": "2.2.0",
|
||||
"version": "2.6.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"lint": "eslint . --ext .ts",
|
||||
|
||||
@@ -1,492 +1,58 @@
|
||||
const config = {
|
||||
themes: {
|
||||
default: {
|
||||
theme: {
|
||||
colors: {
|
||||
'card-border': '#ededed',
|
||||
'primary-bg': '#FAFAFA',
|
||||
'primary-action': '#1212FF',
|
||||
'primary-050': '#F5F5FE',
|
||||
'primary-100': '#EDF5FA',
|
||||
'primary-150': '#E5EEFA',
|
||||
'primary-950': '#1B1B35',
|
||||
'info-150': '#E5EEFA',
|
||||
'greyscale-000': '#fff',
|
||||
'greyscale-1000': '#161616',
|
||||
'blue-400': '#7AB1E8',
|
||||
'blue-500': '#417DC4',
|
||||
'blue-600': '#3558A2',
|
||||
'brown-400': '#E6BE92',
|
||||
'brown-500': '#BD987A',
|
||||
'brown-600': '#745B47',
|
||||
'cyan-400': '#34BAB5',
|
||||
'cyan-500': '#009099',
|
||||
'cyan-600': '#006A6F',
|
||||
'gold-400': '#FFCA00',
|
||||
'gold-500': '#C3992A',
|
||||
'gold-600': '#695240',
|
||||
'green-400': '#34CB6A',
|
||||
'green-500': '#00A95F',
|
||||
'green-600': '#297254',
|
||||
'olive-400': '#99C221',
|
||||
'olive-500': '#68A532',
|
||||
'olive-600': '#447049',
|
||||
'orange-400': '#FF732C',
|
||||
'orange-500': '#E4794A',
|
||||
'orange-600': '#755348',
|
||||
'pink-400': '#FFB7AE',
|
||||
'pink-500': '#E18B76',
|
||||
'pink-600': '#8D533E',
|
||||
'purple-400': '#CE70CC',
|
||||
'purple-500': '#A558A0',
|
||||
'purple-600': '#6E445A',
|
||||
'yellow-400': '#D8C634',
|
||||
'yellow-500': '#B7A73F',
|
||||
'yellow-600': '#66673D',
|
||||
},
|
||||
font: {
|
||||
sizes: {
|
||||
xs: '0.75rem',
|
||||
sm: '0.875rem',
|
||||
md: '1rem',
|
||||
lg: '1.125rem',
|
||||
ml: '0.938rem',
|
||||
xl: '1.25rem',
|
||||
t: '0.6875rem',
|
||||
s: '0.75rem',
|
||||
h1: '2rem',
|
||||
h2: '1.75rem',
|
||||
h3: '1.5rem',
|
||||
h4: '1.375rem',
|
||||
h5: '1.25rem',
|
||||
h6: '1.125rem',
|
||||
'xl-alt': '5rem',
|
||||
'lg-alt': '4.5rem',
|
||||
'md-alt': '4rem',
|
||||
'sm-alt': '3.5rem',
|
||||
'xs-alt': '3rem',
|
||||
},
|
||||
weights: {
|
||||
thin: 100,
|
||||
extrabold: 800,
|
||||
black: 900,
|
||||
},
|
||||
},
|
||||
spacings: {
|
||||
'0': '0',
|
||||
none: '0',
|
||||
auto: 'auto',
|
||||
bx: '2.2rem',
|
||||
full: '100%',
|
||||
'4xs': '0.125rem',
|
||||
'3xs': '0.25rem',
|
||||
'2xs': '0.375rem',
|
||||
xs: '0.5rem',
|
||||
sm: '0.75rem',
|
||||
base: '1rem',
|
||||
md: '1.5rem',
|
||||
lg: '2rem',
|
||||
xl: '2.5rem',
|
||||
xxl: '3rem',
|
||||
xxxl: '3.5rem',
|
||||
'4xl': '4rem',
|
||||
'5xl': '4.5rem',
|
||||
'6xl': '6rem',
|
||||
'7xl': '7.5rem',
|
||||
},
|
||||
breakpoints: {
|
||||
xxs: '320px',
|
||||
xs: '480px',
|
||||
},
|
||||
logo: {
|
||||
src: '',
|
||||
widthHeader: '',
|
||||
widthFooter: '',
|
||||
alt: '',
|
||||
},
|
||||
},
|
||||
components: {
|
||||
datagrid: {
|
||||
header: {
|
||||
weight: 'var(--c--theme--font--weights--extrabold)',
|
||||
size: 'var(--c--theme--font--sizes--ml)',
|
||||
},
|
||||
cell: {
|
||||
color: 'var(--c--theme--colors--primary-500)',
|
||||
size: 'var(--c--theme--font--sizes--ml)',
|
||||
},
|
||||
},
|
||||
'forms-checkbox': {
|
||||
'background-color': {
|
||||
hover: '#055fd214',
|
||||
},
|
||||
color: 'var(--c--theme--colors--primary-500)',
|
||||
'font-size': 'var(--c--theme--font--sizes--ml)',
|
||||
},
|
||||
'forms-datepicker': {
|
||||
'border-color': 'var(--c--theme--colors--primary-500)',
|
||||
'value-color': 'var(--c--theme--colors--primary-500)',
|
||||
'border-radius': {
|
||||
hover: 'var(--c--components--forms-datepicker--border-radius)',
|
||||
focus: 'var(--c--components--forms-datepicker--border-radius)',
|
||||
},
|
||||
},
|
||||
'forms-field': {
|
||||
color: 'var(--c--theme--colors--primary-500)',
|
||||
'value-color': 'var(--c--theme--colors--primary-500)',
|
||||
width: 'auto',
|
||||
},
|
||||
'forms-input': {
|
||||
'value-color': 'var(--c--theme--colors--primary-500)',
|
||||
'border-color': 'var(--c--theme--colors--primary-500)',
|
||||
color: {
|
||||
error: 'var(--c--theme--colors--danger-500)',
|
||||
'error-hover': 'var(--c--theme--colors--danger-500)',
|
||||
'box-shadow-error-hover': 'var(--c--theme--colors--danger-500)',
|
||||
},
|
||||
},
|
||||
'forms-labelledbox': {
|
||||
'label-color': {
|
||||
small: 'var(--c--theme--colors--primary-500)',
|
||||
'small-disabled': 'var(--c--theme--colors--greyscale-400)',
|
||||
big: {
|
||||
disabled: 'var(--c--theme--colors--greyscale-400)',
|
||||
},
|
||||
},
|
||||
},
|
||||
'forms-select': {
|
||||
'border-color': 'var(--c--theme--colors--primary-500)',
|
||||
'border-color-disabled-hover':
|
||||
'var(--c--theme--colors--greyscale-200)',
|
||||
'border-radius': {
|
||||
hover: 'var(--c--components--forms-select--border-radius)',
|
||||
focus: 'var(--c--components--forms-select--border-radius)',
|
||||
},
|
||||
'font-size': 'var(--c--theme--font--sizes--ml)',
|
||||
'menu-background-color': '#fff',
|
||||
'item-background-color': {
|
||||
hover: 'var(--c--theme--colors--primary-300)',
|
||||
},
|
||||
},
|
||||
'forms-switch': {
|
||||
'accent-color': 'var(--c--theme--colors--primary-400)',
|
||||
},
|
||||
'forms-textarea': {
|
||||
'border-color': 'var(--c--components--forms-textarea--border-color)',
|
||||
'border-color-hover':
|
||||
'var(--c--components--forms-textarea--border-color)',
|
||||
'border-radius': {
|
||||
hover: 'var(--c--components--forms-textarea--border-radius)',
|
||||
focus: 'var(--c--components--forms-textarea--border-radius)',
|
||||
},
|
||||
color: 'var(--c--theme--colors--primary-500)',
|
||||
disabled: {
|
||||
'border-color-hover': 'var(--c--theme--colors--greyscale-200)',
|
||||
},
|
||||
},
|
||||
modal: {
|
||||
'background-color': '#fff',
|
||||
},
|
||||
button: {
|
||||
'border-radius': {
|
||||
active: 'var(--c--components--button--border-radius)',
|
||||
},
|
||||
'medium-height': 'auto',
|
||||
'small-height': 'auto',
|
||||
success: {
|
||||
color: 'white',
|
||||
'color-disabled': 'white',
|
||||
'color-hover': 'white',
|
||||
background: {
|
||||
color: 'var(--c--theme--colors--success-600)',
|
||||
'color-disabled': 'var(--c--theme--colors--greyscale-300)',
|
||||
'color-hover': 'var(--c--theme--colors--success-800)',
|
||||
},
|
||||
},
|
||||
danger: {
|
||||
'color-hover': 'white',
|
||||
background: {
|
||||
color: 'var(--c--theme--colors--danger-600)',
|
||||
'color-hover': '#FF2725',
|
||||
'color-disabled': 'var(--c--theme--colors--danger-100)',
|
||||
},
|
||||
},
|
||||
primary: {
|
||||
color: 'var(--c--theme--colors--primary-text)',
|
||||
'color-active': 'var(--c--theme--colors--primary-text)',
|
||||
background: {
|
||||
color: 'var(--c--theme--colors--primary-400)',
|
||||
'color-active': 'var(--c--theme--colors--primary-500)',
|
||||
},
|
||||
border: {
|
||||
'color-active': 'transparent',
|
||||
},
|
||||
},
|
||||
secondary: {
|
||||
color: 'var(--c--theme--colors--primary-500)',
|
||||
'color-hover': 'var(--c--theme--colors--primary-text)',
|
||||
background: {
|
||||
color: 'white',
|
||||
'color-hover': 'var(--c--theme--colors--primary-700)',
|
||||
},
|
||||
border: {
|
||||
color: 'var(--c--theme--colors--greyscale-300)',
|
||||
},
|
||||
},
|
||||
tertiary: {
|
||||
color: 'var(--c--theme--colors--primary-text)',
|
||||
'color-disabled': 'var(--c--theme--colors--greyscale-600)',
|
||||
background: {
|
||||
color: 'var(--c--theme--colors--primary-100)',
|
||||
'color-hover': 'var(--c--theme--colors--primary-300)',
|
||||
'color-active': 'var(--c--theme--colors--primary-100)',
|
||||
'color-disabled': 'var(--c--theme--colors--greyscale-200)',
|
||||
},
|
||||
},
|
||||
disabled: {
|
||||
color: 'white',
|
||||
background: {
|
||||
color: '#b3cef0',
|
||||
},
|
||||
},
|
||||
},
|
||||
'la-gauffre': {
|
||||
activated: false,
|
||||
},
|
||||
'home-proconnect': {
|
||||
activated: false,
|
||||
},
|
||||
},
|
||||
import { cunninghamConfig } from '@gouvfr-lasuite/ui-kit';
|
||||
|
||||
const tokens = {
|
||||
...cunninghamConfig,
|
||||
};
|
||||
|
||||
const customColors = {
|
||||
'primary-action': '#1212FF',
|
||||
'primary-bg': '#FAFAFA',
|
||||
'blue-400': '#7AB1E8',
|
||||
'blue-500': '#417DC4',
|
||||
'blue-600': '#3558A2',
|
||||
'brown-400': '#E6BE92',
|
||||
'brown-500': '#BD987A',
|
||||
'brown-600': '#745B47',
|
||||
'cyan-400': '#34BAB5',
|
||||
'cyan-500': '#009099',
|
||||
'cyan-600': '#006A6F',
|
||||
'gold-400': '#FFCA00',
|
||||
'gold-500': '#C3992A',
|
||||
'gold-600': '#695240',
|
||||
'green-400': '#34CB6A',
|
||||
'green-500': '#00A95F',
|
||||
'green-600': '#297254',
|
||||
'olive-400': '#99C221',
|
||||
'olive-500': '#68A532',
|
||||
'olive-600': '#447049',
|
||||
'orange-400': '#FF732C',
|
||||
'orange-500': '#E4794A',
|
||||
'orange-600': '#755348',
|
||||
'pink-400': '#FFB7AE',
|
||||
'pink-500': '#E18B76',
|
||||
'pink-600': '#8D533E',
|
||||
'purple-400': '#CE70CC',
|
||||
'purple-500': '#A558A0',
|
||||
'purple-600': '#6E445A',
|
||||
'yellow-400': '#D8C634',
|
||||
'yellow-500': '#B7A73F',
|
||||
'yellow-600': '#66673D',
|
||||
};
|
||||
tokens.themes.default.theme.colors = {
|
||||
...tokens.themes.default.theme.colors,
|
||||
...customColors,
|
||||
};
|
||||
|
||||
tokens.themes.default.components = {
|
||||
...tokens.themes.default.components,
|
||||
...{
|
||||
'la-gauffre': {
|
||||
activated: true,
|
||||
},
|
||||
dsfr: {
|
||||
theme: {
|
||||
colors: {
|
||||
'card-border': '#E5E5E5',
|
||||
'primary-text': '#000091',
|
||||
'primary-100': '#ECECFE',
|
||||
'primary-150': '#F4F4FD',
|
||||
'primary-200': '#E3E3FD',
|
||||
'primary-300': '#CACAFB',
|
||||
'primary-400': '#8585F6',
|
||||
'primary-500': '#6A6AF4',
|
||||
'primary-600': '#313178',
|
||||
'primary-700': '#272747',
|
||||
'primary-800': '#000091',
|
||||
'primary-900': '#21213F',
|
||||
'secondary-text': '#fff',
|
||||
'secondary-100': '#fee9ea',
|
||||
'secondary-200': '#fedfdf',
|
||||
'secondary-300': '#fdbfbf',
|
||||
'secondary-400': '#e1020f',
|
||||
'secondary-500': '#c91a1f',
|
||||
'secondary-600': '#5e2b2b',
|
||||
'secondary-700': '#3b2424',
|
||||
'secondary-800': '#341f1f',
|
||||
'secondary-900': '#2b1919',
|
||||
'greyscale-text': '#303C4B',
|
||||
'greyscale-000': '#fff',
|
||||
'greyscale-050': '#F6F6F6',
|
||||
'greyscale-100': '#eee',
|
||||
'greyscale-200': '#E5E5E5',
|
||||
'greyscale-250': '#ddd',
|
||||
'greyscale-300': '#CECECE',
|
||||
'greyscale-350': '#ddd',
|
||||
'greyscale-400': '#929292',
|
||||
'greyscale-500': '#7C7C7C',
|
||||
'greyscale-600': '#666666',
|
||||
'greyscale-700': '#3A3A3A',
|
||||
'greyscale-750': '#353535',
|
||||
'greyscale-800': '#2A2A2A',
|
||||
'greyscale-900': '#242424',
|
||||
'greyscale-950': '#1E1E1E',
|
||||
'greyscale-1000': '#161616',
|
||||
'success-text': '#1f8d49',
|
||||
'success-100': '#dffee6',
|
||||
'success-200': '#b8fec9',
|
||||
'success-300': '#88fdaa',
|
||||
'success-400': '#3bea7e',
|
||||
'success-500': '#1f8d49',
|
||||
'success-600': '#18753c',
|
||||
'success-700': '#204129',
|
||||
'success-800': '#1e2e22',
|
||||
'success-900': '#19281d',
|
||||
'info-text': '#0078f3',
|
||||
'info-100': '#E8EDFF',
|
||||
'info-200': '#DDE5FF',
|
||||
'info-300': '#BCCDFF',
|
||||
'info-400': '#518FFF',
|
||||
'info-500': '#0078F3',
|
||||
'info-600': '#0063CB',
|
||||
'info-700': '#273961',
|
||||
'info-800': '#222A3F',
|
||||
'info-900': '#1D2437',
|
||||
'warning-text': '#d64d00',
|
||||
'warning-100': '#fff4f3',
|
||||
'warning-200': '#ffe9e6',
|
||||
'warning-300': '#ffded9',
|
||||
'warning-400': '#ffbeb4',
|
||||
'warning-500': '#d64d00',
|
||||
'warning-600': '#b34000',
|
||||
'warning-700': '#5e2c21',
|
||||
'warning-800': '#3e241e',
|
||||
'warning-900': '#361e19',
|
||||
'danger-text': '#FFF',
|
||||
'danger-100': '#FFE9E9',
|
||||
'danger-200': '#FFDDDD',
|
||||
'danger-300': '#FFBDBD',
|
||||
'danger-400': '#FF5655',
|
||||
'danger-500': '#F60700',
|
||||
'danger-600': '#CE0500',
|
||||
'danger-700': '#642626',
|
||||
'danger-800': '#412121',
|
||||
'danger-900': '#391C1C',
|
||||
},
|
||||
font: {
|
||||
families: {
|
||||
accent: 'Marianne',
|
||||
base: 'Marianne',
|
||||
},
|
||||
},
|
||||
logo: {
|
||||
src: '/assets/logo-gouv.svg',
|
||||
widthHeader: '110px',
|
||||
widthFooter: '220px',
|
||||
alt: 'Gouvernement Logo',
|
||||
},
|
||||
},
|
||||
components: {
|
||||
alert: {
|
||||
'border-radius': '0',
|
||||
error: {
|
||||
'background-color': 'var(--c--theme--colors--danger-100)',
|
||||
'border-left-color': 'var(--c--theme--colors--danger-400)',
|
||||
close: {
|
||||
color: 'white',
|
||||
'background-color': 'var(--c--theme--colors--danger-400)',
|
||||
'background-color-hover': 'var(--c--theme--colors--danger-600)',
|
||||
},
|
||||
},
|
||||
},
|
||||
modal: {
|
||||
'width-small': '342px',
|
||||
},
|
||||
button: {
|
||||
'medium-height': '40px',
|
||||
'medium-text-height': '40px',
|
||||
'border-radius': '4px',
|
||||
primary: {
|
||||
background: {
|
||||
color: 'var(--c--theme--colors--primary-text)',
|
||||
'color-hover': '#1212ff',
|
||||
'color-active': '#2323ff',
|
||||
},
|
||||
color: '#fff',
|
||||
'color-hover': '#fff',
|
||||
'color-active': '#fff',
|
||||
},
|
||||
'primary-text': {
|
||||
background: {
|
||||
'color-hover': 'var(--c--theme--colors--primary-100)',
|
||||
'color-active': 'var(--c--theme--colors--primary-100)',
|
||||
},
|
||||
'color-hover': 'var(--c--theme--colors--primary-text)',
|
||||
color: 'var(--c--theme--colors--primary-800)',
|
||||
},
|
||||
secondary: {
|
||||
background: {
|
||||
'color-hover': '#F6F6F6',
|
||||
'color-active': '#EDEDED',
|
||||
},
|
||||
border: {
|
||||
color: 'var(--c--theme--colors--greyscale-300)',
|
||||
'color-hover': 'var(--c--theme--colors--greyscale-300)',
|
||||
},
|
||||
color: 'var(--c--theme--colors--primary-text)',
|
||||
},
|
||||
'tertiary-text': {
|
||||
background: {
|
||||
'color-hover': 'var(--c--theme--colors--greyscale-100)',
|
||||
},
|
||||
'color-hover': 'var(--c--theme--colors--primary-text)',
|
||||
color: 'var(--c--theme--colors--primary-600)',
|
||||
},
|
||||
},
|
||||
datagrid: {
|
||||
header: {
|
||||
color: 'var(--c--theme--colors--primary-text)',
|
||||
size: 'var(--c--theme--font--sizes--s)',
|
||||
},
|
||||
body: {
|
||||
'background-color': 'transparent',
|
||||
'background-color-hover': '#F4F4FD',
|
||||
},
|
||||
pagination: {
|
||||
'background-color': 'transparent',
|
||||
'background-color-active': 'var(--c--theme--colors--primary-300)',
|
||||
'border-color': 'var(--c--theme--colors--primary-400)',
|
||||
},
|
||||
},
|
||||
'forms-checkbox': {
|
||||
'border-radius': '0',
|
||||
color: 'var(--c--theme--colors--primary-text)',
|
||||
text: {
|
||||
color: 'var(--c--theme--colors--greyscale-text)',
|
||||
size: 'var(--c--theme--font--sizes--t)',
|
||||
},
|
||||
},
|
||||
'forms-datepicker': {
|
||||
'border-radius': '0',
|
||||
},
|
||||
'forms-fileuploader': {
|
||||
'border-radius': '0',
|
||||
},
|
||||
'forms-field': {
|
||||
color: 'var(--c--theme--colors--primary-text)',
|
||||
'footer-font-size': 'var(--c--theme--font--sizes--t)',
|
||||
'footer-color': 'var(--c--theme--colors--greyscale-text)',
|
||||
},
|
||||
'forms-input': {
|
||||
'border-radius': '4px',
|
||||
'background-color': '#fff',
|
||||
'border-color': 'var(--c--theme--colors--primary-text)',
|
||||
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
|
||||
'value-color': 'var(--c--theme--colors--primary-text)',
|
||||
'font-size': '14px',
|
||||
},
|
||||
'forms-labelledbox': {
|
||||
'label-color': {
|
||||
big: 'var(--c--theme--colors--primary-text)',
|
||||
},
|
||||
},
|
||||
'forms-radio': {
|
||||
'accent-color': 'var(--c--theme--colors--primary-600)',
|
||||
},
|
||||
'forms-select': {
|
||||
'item-font-size': '14px',
|
||||
'border-radius': '4px',
|
||||
'border-radius-hover': '4px',
|
||||
'background-color': '#fff',
|
||||
'border-color': 'var(--c--theme--colors--primary-text)',
|
||||
'border-color-hover': 'var(--c--theme--colors--primary-text)',
|
||||
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
|
||||
},
|
||||
'forms-switch': {
|
||||
'handle-border-radius': '2px',
|
||||
'rail-border-radius': '4px',
|
||||
'accent-color': 'var(--c--theme--colors--primary-text)',
|
||||
},
|
||||
'forms-textarea': {
|
||||
'border-radius': '0',
|
||||
},
|
||||
'la-gauffre': {
|
||||
activated: true,
|
||||
},
|
||||
'home-proconnect': {
|
||||
activated: true,
|
||||
},
|
||||
},
|
||||
'home-proconnect': {
|
||||
activated: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
export default tokens;
|
||||
|
||||
@@ -23,6 +23,7 @@ const jestConfig = async () => {
|
||||
'\\.svg$': '<rootDir>/jest/mocks/svg.js',
|
||||
'^.+\\.svg\\?url$': `<rootDir>/jest/mocks/fileMock.js`,
|
||||
BlockNoteEditor: `<rootDir>/jest/mocks/ComponentMock.js`,
|
||||
'custom-blocks': `<rootDir>/jest/mocks/ComponentMock.js`,
|
||||
...nextJestConfig.moduleNameMapper,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-impress",
|
||||
"version": "2.2.0",
|
||||
"version": "2.6.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -16,51 +16,58 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ag-media/react-pdf-table": "2.0.1",
|
||||
"@blocknote/core": "0.23.2",
|
||||
"@blocknote/mantine": "0.23.2",
|
||||
"@blocknote/react": "0.23.2",
|
||||
"@blocknote/xl-docx-exporter": "0.23.2",
|
||||
"@blocknote/xl-pdf-exporter": "0.23.2",
|
||||
"@blocknote/core": "0.23.2-hotfix.0",
|
||||
"@blocknote/mantine": "0.23.2-hotfix.0",
|
||||
"@blocknote/react": "0.23.2-hotfix.0",
|
||||
"@blocknote/xl-docx-exporter": "0.23.2-hotfix.0",
|
||||
"@blocknote/xl-pdf-exporter": "0.23.2-hotfix.0",
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/modifiers": "9.0.0",
|
||||
"@fontsource/material-icons": "5.2.5",
|
||||
"@gouvfr-lasuite/integration": "1.0.2",
|
||||
"@gouvfr-lasuite/ui-kit": "/Users/melde/Documents/societes/melde/clients/dinum/design-system",
|
||||
"@hocuspocus/provider": "2.15.2",
|
||||
"@openfun/cunningham-react": "2.9.4",
|
||||
"@openfun/cunningham-react": "3.0.0",
|
||||
"@react-pdf/renderer": "4.1.6",
|
||||
"@sentry/nextjs": "8.54.0",
|
||||
"@tanstack/react-query": "5.66.0",
|
||||
"@sentry/nextjs": "9.3.0",
|
||||
"@tanstack/react-query": "5.67.1",
|
||||
"canvg": "4.0.3",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.0.4",
|
||||
"crisp-sdk-web": "1.0.25",
|
||||
"docx": "9.1.1",
|
||||
"i18next": "24.2.2",
|
||||
"i18next-browser-languagedetector": "8.0.2",
|
||||
"i18next-browser-languagedetector": "8.0.4",
|
||||
"idb": "8.0.2",
|
||||
"lodash": "4.17.21",
|
||||
"luxon": "3.5.0",
|
||||
"next": "15.1.6",
|
||||
"posthog-js": "1.215.6",
|
||||
"next": "15.2.1",
|
||||
"posthog-js": "1.227.0",
|
||||
"react": "*",
|
||||
"react-aria-components": "1.6.0",
|
||||
"react-dom": "*",
|
||||
"react-i18next": "15.4.0",
|
||||
"react-i18next": "15.4.1",
|
||||
"react-intersection-observer": "9.15.1",
|
||||
"react-select": "5.10.0",
|
||||
"react-select": "5.10.1",
|
||||
"react-stately": "3.36.1",
|
||||
"styled-components": "6.1.15",
|
||||
"use-debounce": "10.0.4",
|
||||
"y-protocols": "1.0.6",
|
||||
"yjs": "13.6.23",
|
||||
"yjs": "*",
|
||||
"zustand": "5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@svgr/webpack": "8.1.0",
|
||||
"@tanstack/react-query-devtools": "5.66.0",
|
||||
"@tanstack/react-query-devtools": "5.67.1",
|
||||
"@testing-library/dom": "10.4.0",
|
||||
"@testing-library/jest-dom": "6.6.3",
|
||||
"@testing-library/react": "16.2.0",
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/lodash": "4.17.15",
|
||||
"@types/lodash": "4.17.16",
|
||||
"@types/luxon": "3.4.2",
|
||||
"@types/node": "*",
|
||||
"@types/react": "18.3.12",
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"cross-env": "7.0.3",
|
||||
"dotenv": "16.4.7",
|
||||
@@ -69,12 +76,12 @@
|
||||
"jest": "29.7.0",
|
||||
"jest-environment-jsdom": "29.7.0",
|
||||
"node-fetch": "2.7.0",
|
||||
"prettier": "3.4.2",
|
||||
"stylelint": "16.14.1",
|
||||
"prettier": "3.5.3",
|
||||
"stylelint": "16.15.0",
|
||||
"stylelint-config-standard": "37.0.0",
|
||||
"stylelint-prettier": "5.0.3",
|
||||
"typescript": "*",
|
||||
"webpack": "5.97.1",
|
||||
"webpack": "5.98.0",
|
||||
"workbox-webpack-plugin": "7.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,101 +0,0 @@
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src:
|
||||
url('Marianne-Thin.woff2') format('woff2'),
|
||||
url('Marianne-Thin.woff') format('woff');
|
||||
font-weight: 100;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src:
|
||||
url('Marianne-Thin_Italic.woff2') format('woff2'),
|
||||
url('Marianne-Thin_Italic.woff') format('woff');
|
||||
font-weight: 100;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src:
|
||||
url('Marianne-Light.woff2') format('woff2'),
|
||||
url('Marianne-Light.woff') format('woff');
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src:
|
||||
url('Marianne-Light_Italic.woff2') format('woff2'),
|
||||
url('Marianne-Light_Italic.woff') format('woff');
|
||||
font-weight: 300;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src:
|
||||
url('Marianne-Regular.woff2') format('woff2'),
|
||||
url('Marianne-Regular.woff') format('woff');
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src:
|
||||
url('Marianne-Regular_Italic.woff2') format('woff2'),
|
||||
url('Marianne-Regular_Italic.woff') format('woff');
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src:
|
||||
url('Marianne-Medium.woff2') format('woff2'),
|
||||
url('Marianne-Medium.woff') format('woff');
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src:
|
||||
url('Marianne-Medium_Italic.woff2') format('woff2'),
|
||||
url('Marianne-Medium_Italic.woff') format('woff');
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src:
|
||||
url('Marianne-Bold.woff2') format('woff2'),
|
||||
url('Marianne-Bold.woff') format('woff');
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src:
|
||||
url('Marianne-Bold_Italic.woff2') format('woff2'),
|
||||
url('Marianne-Bold_Italic.woff') format('woff');
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src:
|
||||
url('Marianne-ExtraBold.woff2') format('woff2'),
|
||||
url('Marianne-ExtraBold.woff') format('woff');
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src:
|
||||
url('Marianne-ExtraBold_Italic.woff2') format('woff2'),
|
||||
url('Marianne-ExtraBold_Italic.woff') format('woff');
|
||||
font-weight: 800;
|
||||
font-style: italic;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ComponentPropsWithRef, ReactHTML } from 'react';
|
||||
import { ComponentPropsWithRef, HTMLElementType } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { CSSProperties, RuleSet } from 'styled-components/dist/types';
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { hideEffect, showEffect } from './Effect';
|
||||
|
||||
export interface BoxProps {
|
||||
as?: keyof ReactHTML;
|
||||
as?: HTMLElementType;
|
||||
$align?: CSSProperties['alignItems'];
|
||||
$background?: CSSProperties['background'];
|
||||
$color?: CSSProperties['color'];
|
||||
|
||||
@@ -8,6 +8,7 @@ export type DropdownMenuOption = {
|
||||
icon?: string;
|
||||
label: string;
|
||||
testId?: string;
|
||||
value?: string;
|
||||
callback?: () => void | Promise<unknown>;
|
||||
danger?: boolean;
|
||||
isSelected?: boolean;
|
||||
@@ -23,6 +24,8 @@ export type DropdownMenuProps = {
|
||||
buttonCss?: BoxProps['$css'];
|
||||
disabled?: boolean;
|
||||
topMessage?: string;
|
||||
selectedValues?: string[];
|
||||
afterOpenChange?: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
export const DropdownMenu = ({
|
||||
@@ -34,6 +37,8 @@ export const DropdownMenu = ({
|
||||
buttonCss,
|
||||
label,
|
||||
topMessage,
|
||||
afterOpenChange,
|
||||
selectedValues,
|
||||
}: PropsWithChildren<DropdownMenuProps>) => {
|
||||
const theme = useCunninghamTheme();
|
||||
const spacings = theme.spacingsTokens();
|
||||
@@ -43,6 +48,7 @@ export const DropdownMenu = ({
|
||||
|
||||
const onOpenChange = (isOpen: boolean) => {
|
||||
setIsOpen(isOpen);
|
||||
afterOpenChange?.(isOpen);
|
||||
};
|
||||
|
||||
if (disabled) {
|
||||
@@ -161,7 +167,8 @@ export const DropdownMenu = ({
|
||||
{option.label}
|
||||
</Text>
|
||||
</Box>
|
||||
{option.isSelected && (
|
||||
{(option.isSelected ||
|
||||
selectedValues?.includes(option.value ?? '')) && (
|
||||
<Icon iconName="check" $size="20px" $theme="greyscale" />
|
||||
)}
|
||||
</BoxButton>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user