Compare commits

..

32 Commits

Author SHA1 Message Date
Your name
a4d968eff3 remise à niveau 2025-02-13 16:29:33 +01:00
Your name
12f7c8b371 after pull 2025-02-12 10:36:03 +01:00
MarineM1
70e3e9e7d4 Merge branch 'main' into accessibility 2025-02-12 10:02:25 +01:00
MarineM1
e0426ca803 Merge branch 'main' into accessibility 2025-02-10 14:17:20 +01:00
MarineM1
a01790dc0b Merge branch 'main' into accessibility 2025-02-10 08:35:03 +01:00
Your name
e14622ff66 ️ (frontend) modal options modified
The modal for document options has been modified to allow navigation
using "tab" and to be closed.
2025-02-06 14:47:04 +01:00
Your name
75fd994a5b (frontend) Focus
The elements focused with "Tab" are visible and consistent with the
defined style.
2025-02-04 16:27:46 +01:00
Your name
239933aef3 Merge branch 'accessibility' of https://github.com/suitenumerique/docs into accessibility 2025-02-03 16:38:13 +01:00
Your name
95c06d68cb ️ (frontend) navigation with tab and other modifications
- In DocGrid, when navigating with tab, we can see where we are
- In Doc, don't read "doc title input" but "rename"
- In docSearch, don't read "search" icon and read "aucun document
  trouvé" with screen reader
2025-02-03 16:27:24 +01:00
MarineM1
0d12278474 Merge branch 'main' into accessibility 2025-02-03 10:58:02 +01:00
Your name
8ce13075c1 (frontend) leftpanel and grid modified
- Read the Tooltip with the number of participants, not the icon
- In LeftPanel, when navigating with tab, we can see where we are
2025-01-31 16:47:37 +01:00
Your name
225f5a8abb erreur du commit précedent
je ne commite que les modifications annoncées dans le commit précédent
2025-01-31 13:23:05 +01:00
Your name
71a8765770 ️ (frontend) update after comments
-modifications after Sylvain's commentary
-modifications after Anto's commentaries
2025-01-31 11:42:32 +01:00
MarineM1
9186a101ec Merge branch 'main' into accessibility 2025-01-31 11:41:31 +01:00
MarineM1
74e816c479 Merge branch 'main' into accessibility 2025-01-30 16:14:09 +01:00
Your name
5d9eb2d694 mise en correspondance avec le main 2025-01-30 15:25:40 +01:00
Your name
33e168ba17 mise en correspondance avec le main 2025-01-30 14:49:13 +01:00
Your name
f1c0f6bba0 ️ (frontend) pin icon translated and read
For good accessibility, the pinned icon will be read by the screen reader, but the simple icon will not
2025-01-30 14:26:29 +01:00
Your name
8f4fd15495 Merge branch 'accessibility' of https://github.com/suitenumerique/docs into accessibility 2025-01-30 09:36:00 +01:00
MarineM1
cc6ce4a945 Merge branch 'main' into accessibility 2025-01-30 09:02:51 +01:00
MarineM1
044c8f0bbd Merge branch 'main' into accessibility 2025-01-29 13:18:04 +01:00
Your name
7e62dcf1fc Merge branch 'main' of https://github.com/suitenumerique/docs into accessibility 2025-01-29 13:10:44 +01:00
MarineM1
e7742d914c Merge branch 'main' into accessibility 2025-01-29 10:54:54 +01:00
Your name
b1a5c17d75 ️(frontend) update some code html
To improve accessibility, certain parts of the code are modified
 with the help of a screen reader
2025-01-28 15:32:55 +01:00
Your name
bd68396e52 commit suite pull du 28 01 2025 2025-01-28 10:32:11 +01:00
Your name
9f1ae58ead commit avant récup du code 2025-01-28 09:15:29 +01:00
Your name
f597549e96 Accessibilité - modifications des boutons corporate, group et public pour le lecteur d'écran 2025-01-28 09:14:26 +01:00
Your name
847e120d67 commit après récupération des modif du main 2025-01-27 09:18:37 +01:00
Your name
6682ddafff accessibilité - icons apps,lock et group plus plues 2025-01-27 09:16:26 +01:00
Your name
2f23404003 Accessibilité - modif des boutons home et search 2025-01-22 11:18:24 +01:00
Your name
6d4210d34b essai de modificaation de la couleur des arrows + pull du 20/01/2025 2025-01-20 11:36:24 +01:00
Your name
85f7598be8 Accessibilité - Lecture du Logo supprimée car double 2025-01-16 15:21:38 +01:00
321 changed files with 26836 additions and 29399 deletions

View File

@@ -1,78 +0,0 @@
---
description: Rules for writing Python with Django
globs: src/backend/**/*.py
alwaysApply: false
---
You are an expert in Python, Django, and scalable web application development.
Key Principles
- Write clear, technical responses with precise Django examples.
- Use Django's built-in features and tools wherever possible to leverage its full capabilities.
- Prioritize readability and maintainability; follow Django's coding style guide (PEP 8 compliance for the most part, with the one exception being 100 characters per line instead of 79).
- Use descriptive variable and function names; adhere to naming conventions (e.g., lowercase with underscores for functions and variables).
Django/Python
- Use Django REST Framework viewsets for API endpoints.
- Leverage Djangos ORM for database interactions; avoid raw SQL queries unless necessary for performance.
- Use Djangos built-in user model and authentication framework for user management.
- Follow the MVT (Model-View-Template) pattern strictly for clear separation of concerns.
- Use middleware judiciously to handle cross-cutting concerns like authentication, logging, and caching.
Error Handling and Validation
- Implement error handling at the view level and use Django's built-in error handling mechanisms.
- Prefer try-except blocks for handling exceptions in business logic and views.
Dependencies
- Django
- Django REST Framework (for API development)
- Celery (for background tasks)
- Redis (for caching and task queues)
- PostgreSQL (preferred databases for production)
- Minio (file storage for production)
- OIDC prodiver (for managing authentication)
Django-Specific Guidelines
- Use Django templates for rendering HTML and DRF serializers for JSON responses.
- Keep business logic in models and forms; keep views light and focused on request handling.
- Use Django's URL dispatcher (urls.py) to define clear and RESTful URL patterns.
- Apply Django's security best practices (e.g., CSRF protection, SQL injection protection, XSS prevention).
- Use Djangos built-in tools for testing (pytest-django) to ensure code quality and reliability.
- Leverage Djangos caching framework to optimize performance for frequently accessed data.
- Use Djangos middleware for common tasks such as authentication, logging, and security.
Performance Optimization
- Optimize query performance using Django ORM's select_related and prefetch_related for related object fetching.
- Use Djangos cache framework with backend support (e.g., Redis or Memcached) to reduce database load.
- Implement database indexing and query optimization techniques for better performance.
- Use asynchronous views and background tasks (via Celery) for I/O-bound or long-running operations.
- Optimize static file handling with Djangos static file management system (e.g., WhiteNoise).
Logging
- As a general rule, we should have logs for every expected and unexpected actions of the application, using the appropriate log level.
- We should also be logging these exceptions to Sentry with the Sentry Python SDK. Python exceptions should almost always be captured automatically without extra instrumentation, but custom ones (such as failed requests to external services, query errors, or Celery task failures) can be tracked using capture_exception().
Log Levels
- A log level or log severity is a piece of information telling how important a given log message is:
- DEBUG: should be used for information that may be needed for diagnosing issues and troubleshooting or when running application in the test environment for the purpose of making sure everything is running correctly
- INFO: should be used as standard log level, indicating that something happened
- WARN: should be used when something unexpected happened but the code can continue the work
- ERROR: should be used when the application hits an issue preventing one or more functionalities from properly functioning
Security
- Dont log sensitive information. Make sure you never log:
- authorization tokens
- passwords
- financial data
- health data
- PII (Personal Identifiable Information)
Testing
- All new packages and most new significant functionality should come with unit tests
Unit tests
- A good unit test should:
- focus on a single use-case at a time
- have a minimal set of assertions per test
- demonstrate every use case. The rule of thumb is: if it can happen, it should be covered
Refer to Django documentation for best practices in views, models, forms, and security considerations.

View File

@@ -125,11 +125,15 @@ 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:
- uses: numerique-gouv/action-argocd-webhook-notification@main
id: notify
with:
deployment_repo_path: "${{ secrets.DEPLOYMENT_REPO_URL }}"
argocd_webhook_secret: "${{ secrets.ARGOCD_PREPROD_WEBHOOK_SECRET }}"
argocd_url: "${{ vars.ARGOCD_PREPROD_WEBHOOK_URL }}"
-
name: Checkout repository
uses: actions/checkout@v4
-
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 }}

View File

@@ -11,7 +11,7 @@ jobs:
helmfile-lint:
runs-on: ubuntu-latest
container:
image: ghcr.io/helmfile/helmfile:v0.171.0
image: ghcr.io/helmfile/helmfile:latest
steps:
-
name: Checkout repository
@@ -22,9 +22,9 @@ jobs:
run: |
set -e
HELMFILE=src/helm/helmfile.yaml
environments=$(awk 'BEGIN {in_env=0} /^environments:/ {in_env=1; next} /^---/ {in_env=0} in_env && /^ [^ ]/ {gsub(/^ /,""); gsub(/:.*$/,""); print}' "$HELMFILE")
environments=$(awk '/environments:/ {flag=1; next} flag && NF {print} !NF {flag=0}' "$HELMFILE" | grep -E '^[[:space:]]{2}[a-zA-Z]+' | sed 's/^[[:space:]]*//;s/:.*//')
for env in $environments; do
echo "################### $env lint ###################"
helmfile -e $env -f $HELMFILE lint || exit 1
echo -e "\n"
done
done

View File

@@ -88,6 +88,28 @@ jobs:
- name: Start Docker services
run: make bootstrap FLUSH_ARGS='--no-input' cache=
# Tool to wait for a service to be ready
- name: Install Dockerize
run: |
curl -sSL https://github.com/jwilder/dockerize/releases/download/v0.8.0/dockerize-linux-amd64-v0.8.0.tar.gz | sudo tar -C /usr/local/bin -xzv
- name: Wait for services to be ready
run: |
printf "Minio check...\n"
dockerize -wait tcp://localhost:9000 -timeout 20s
printf "Keyclock check...\n"
dockerize -wait tcp://localhost:8080 -timeout 20s
printf "Server collaboration check...\n"
dockerize -wait tcp://localhost:4444 -timeout 20s
printf "Ngnix check...\n"
dockerize -wait tcp://localhost:8083 -timeout 20s
printf "DRF check...\n"
dockerize -wait tcp://localhost:8071 -timeout 20s
printf "Postgres Keyclock check...\n"
dockerize -wait tcp://localhost:5433 -timeout 20s
printf "Postgres back check...\n"
dockerize -wait tcp://localhost:15432 -timeout 20s
- name: Run e2e tests
run: cd src/frontend/ && yarn e2e:test --project='chromium'

View File

@@ -6,122 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0),
and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## Fixed
- 🐛(back) validate document content in serializer #822
## [3.0.0] - 2025-03-28
## Added
- 📄(legal) Require contributors to sign a DCO #779
## Changed
- ♻️(frontend) Integrate UI kit #783
- 🏗️(y-provider) manage auth in y-provider app #804
## Fixed
- 🐛(backend) compute ancestor_links in get_abilities if needed #725
- 🔒️(back) restrict access to document accesses #801
## [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
- 🛂(frontend) Restore version visibility #629
- 📝(doc) minor README.md formatting and wording enhancements
-Stop setting a default title on doc creation #634
- ♻️(frontend) misc ui improvements #644
## Fixed
- 🐛(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
## Added
@@ -141,12 +28,11 @@ and this project adheres to
- 🐛(frontend) fix cursor breakline #609
- 🐛(frontend) fix style pdf export #609
## [2.1.0] - 2025-01-29
## Added
- ✨(backend) add duplicate action to the document API endpoint
- ⚗️(backend) add util to extract text from base64 yjs document
- ✨(backend) add soft delete and restore API endpoints to documents #516
- ✨(backend) allow organizing documents in a tree structure #516
- ✨(backend) add "excerpt" field to document list serializer #516
@@ -158,7 +44,7 @@ and this project adheres to
## Changed
- 💄(frontend) add abilities on doc row #581
- 💄(frontend) improve DocsGridItem responsive padding #582
- 💄(frontend) improve DocsGridItem responsive padding #582
- 🔧(backend) Bump maximum page size to 200 #516
- 📝(doc) Improve Read me #558
@@ -170,6 +56,7 @@ and this project adheres to
- 🔥(backend) remove "content" field from list serializer # 516
## [2.0.1] - 2025-01-17
## Fixed
@@ -224,11 +111,12 @@ and this project adheres to
- ⚡️(e2e) reduce flakiness on e2e tests #511
## Fixed
## Fixed
- 🐛(frontend) update doc editor height #481
- 💄(frontend) add doc search #485
## [1.9.0] - 2024-12-11
## Added
@@ -250,18 +138,21 @@ and this project adheres to
- 🐛(frontend) Fix hidden menu on Firefox #468
- 🐛(backend) fix sanitize problem IA #490
## [1.8.2] - 2024-11-28
## Changed
- ♻️(SW) change strategy html caching #460
## [1.8.1] - 2024-11-27
## Fixed
- 🐛(frontend) link not clickable and flickering firefox #457
## [1.8.0] - 2024-11-25
## Added
@@ -290,6 +181,7 @@ and this project adheres to
- 🐛(frontend) users have view access when revoked #387
- 🐛(frontend) fix placeholder editable when double clicks #454
## [1.7.0] - 2024-10-24
## Added
@@ -317,6 +209,7 @@ and this project adheres to
- 🔥(helm) remove infra related codes #366
## [1.6.0] - 2024-10-17
## Added
@@ -339,6 +232,7 @@ and this project adheres to
- 🐛(backend) fix nginx docker container #340
- 🐛(frontend) fix copy paste firefox #353
## [1.5.1] - 2024-10-10
## Fixed
@@ -373,6 +267,7 @@ and this project adheres to
- 🔧(backend) fix configuration to avoid different ssl warning #297
- 🐛(frontend) fix editor break line not working #302
## [1.4.0] - 2024-09-17
## Added
@@ -393,6 +288,7 @@ and this project adheres to
- 🐛(backend) Fix forcing ID when creating a document via API endpoint #234
- 🐛 Rebuild frontend dev container from makefile #248
## [1.3.0] - 2024-09-05
## Added
@@ -417,6 +313,7 @@ and this project adheres to
- 🔥(frontend) remove saving modal #213
## [1.2.1] - 2024-08-23
## Changed
@@ -424,6 +321,7 @@ and this project adheres to
- ♻️ Change ordering docs datagrid #195
- 🔥(helm) use scaleway email #194
## [1.2.0] - 2024-08-22
## Added
@@ -447,14 +345,14 @@ and this project adheres to
- ⚡️(CI) only e2e chrome mandatory #177
## Removed
- 🔥(helm) remove htaccess #181
## [1.1.0] - 2024-07-15
## Added
- 🤡(demo) generate dummy documents on dev users #120
- 🤡(demo) generate dummy documents on dev users #120
- ✨(frontend) create side modal component #134
- ✨(frontend) Doc grid actions (update / delete) #136
- ✨(frontend) Doc editor header information #137
@@ -465,11 +363,12 @@ and this project adheres to
- ♻️(frontend) create a doc from a modal #132
- ♻️(frontend) manage members from the share modal #140
## [1.0.0] - 2024-07-02
## Added
- 🛂(frontend) Manage the document's right (#75)
- 🛂(frontend) Manage the document's right (#75)
- ✨(frontend) Update document (#68)
- ✨(frontend) Remove document (#68)
- 🐳(docker) dockerize dev frontend (#63)
@@ -503,6 +402,7 @@ and this project adheres to
- 💚(CI) Remove trigger workflow on push tags on CI (#68)
- 🔥(frontend) Remove coming soon page (#121)
## [0.1.0] - 2024-05-24
## Added
@@ -510,12 +410,8 @@ 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/v3.0.0...main
[v3.0.0]: https://github.com/numerique-gouv/impress/releases/v3.0.0
[v2.6.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
[unreleased]: https://github.com/numerique-gouv/impress/compare/v2.2.0...main
[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

View File

@@ -4,8 +4,6 @@ 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
@@ -37,7 +35,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.
* **title**: A short, descriptive title for the change, starting with a lowercase character.
* **description**: Include additional details about what was changed and why.
### Example Commit Message

View File

@@ -15,13 +15,6 @@ FROM base AS back-builder
WORKDIR /builder
# Install Rust and Cargo using Alpine's package manager
RUN apk add --no-cache \
build-base \
libffi-dev \
rust \
cargo
# Copy required python dependencies
COPY ./src/backend /builder

View File

@@ -44,6 +44,7 @@ COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
COMPOSE_RUN = $(COMPOSE) run --rm
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
# -- Backend
MANAGE = $(COMPOSE_RUN_APP) python manage.py
@@ -80,12 +81,12 @@ bootstrap: \
data/static \
create-env-files \
build \
run-with-frontend \
migrate \
demo \
back-i18n-compile \
mails-install \
mails-build \
run
mails-build
.PHONY: bootstrap
# -- Docker/compose
@@ -108,7 +109,7 @@ build-yjs-provider: ## build the y-provider container
build-frontend: cache ?=
build-frontend: ## build the frontend container
@$(COMPOSE) build frontend $(cache)
@$(COMPOSE) build frontend-dev $(cache)
.PHONY: build-frontend
down: ## stop and remove containers, networks, images, and volumes
@@ -119,18 +120,19 @@ logs: ## display app-dev logs (follow mode)
@$(COMPOSE) logs -f app-dev
.PHONY: logs
run-backend: ## Start only the backend application and all needed services
run: ## start the wsgi (production) and development server
@$(COMPOSE) up --force-recreate -d celery-dev
@$(COMPOSE) up --force-recreate -d y-provider
@$(COMPOSE) up --force-recreate -d nginx
.PHONY: run-backend
run: ## start the wsgi (production) and development server
run:
@$(MAKE) run-backend
@$(COMPOSE) up --force-recreate -d frontend
@echo "Wait for postgresql to be up..."
@$(WAIT_DB)
.PHONY: run
run-with-frontend: ## Start all the containers needed (backend to frontend)
@$(MAKE) run
@$(COMPOSE) up --force-recreate -d frontend-dev
.PHONY: run-with-frontend
status: ## an alias for "docker compose ps"
@$(COMPOSE) ps
.PHONY: status
@@ -186,12 +188,14 @@ test-back-parallel: ## run all back-end tests in parallel
makemigrations: ## run django makemigrations for the impress project.
@echo "$(BOLD)Running makemigrations$(RESET)"
@$(COMPOSE) up -d postgresql
@$(WAIT_DB)
@$(MANAGE) makemigrations
.PHONY: makemigrations
migrate: ## run django migrations for the impress project.
@echo "$(BOLD)Running migrations$(RESET)"
@$(COMPOSE) up -d postgresql
@$(WAIT_DB)
@$(MANAGE) migrate
.PHONY: migrate
@@ -306,16 +310,16 @@ help:
.PHONY: help
# Front
frontend-development-install: ## install the frontend locally
frontend-install: ## install the frontend locally
cd $(PATH_FRONT_IMPRESS) && yarn
.PHONY: frontend-development-install
.PHONY: frontend-install
frontend-lint: ## run the frontend linter
cd $(PATH_FRONT) && yarn lint
.PHONY: frontend-lint
run-frontend-development: ## Run the frontend in development mode
@$(COMPOSE) stop frontend
@$(COMPOSE) stop frontend-dev
cd $(PATH_FRONT_IMPRESS) && yarn dev
.PHONY: run-frontend-development

View File

@@ -23,9 +23,6 @@ Welcome to Docs! The open source document editor where your notes can become kno
<img src="/docs/assets/docs_live_collaboration_light.gif" width="100%" align="center"/>
## Why use Docs ❓
⚠️ **Note that Docs provides docs/pdf exporters by loading [two BlockNote packages](https://github.com/suitenumerique/docs/blob/main/src/frontend/apps/impress/package.json#L22C7-L23C53), which we use under the AGPL-3.0 licence. Until we comply with the terms of this license, we recommend that you don't run Docs as a commercial product, unless you are willing to sponsor [BlockNote](https://github.com/TypeCellOS/BlockNote).**
Docs is a collaborative text editor designed to address common challenges in knowledge building and sharing.
### Write
@@ -36,31 +33,23 @@ Docs is a collaborative text editor designed to address common challenges in kno
* ✨ Save time thanks to our AI actions (generate, sum up, correct, translate)
### Collaborate
* 🤝 Collaborate with your team in real time
* 🔒 Granular access control to ensure your information is secure and only shared with the right people
* 🤝 Collaborate in realtime with your team mates
* 🔒 Granular access control to keep your information secure and shared with the right people
* 📑 Professional document exports in multiple formats (.odt, .doc, .pdf) with customizable templates
* 📚 Built-in wiki functionality to turn your team's collaborative work into organized knowledge `ETA 02/2025`
* 📚 Built-in wiki functionality to transform your team's collaborative work into organized knowledge `ETA 02/2025`
### Self-host
* 🚀 Easy to install, scalable and secure alternative to Notion, Outline or Confluence
## Getting started 🔧
### Test it
Test Docs on your browser by logging in on this [environment](https://impress-preprod.beta.numerique.gouv.fr/)
Test Docs on your browser by logging in on this [environment](https://impress-preprod.beta.numerique.gouv.fr/docs/0aa856e9-da41-4d59-b73d-a61cb2c1245f/)
```
email: test.docs@yopmail.com
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 but you can choose any S3 compatible object storage of your choice.
**Prerequisite**
Make sure you have a recent version of Docker and [Docker Compose](https://docs.docker.com/compose/install) installed on your laptop:
```shellscript
@@ -68,22 +57,23 @@ $ docker -v
Docker version 20.10.2, build 2291f61
$ docker compose version
$ docker compose -v
Docker Compose version v2.32.4
docker compose version 1.27.4, build 40524192
```
> ⚠️ You may need to run the following commands with sudo but this can be avoided by adding your user to the `docker` group.
**Project bootstrap**
The easiest way to start working on the project is to use [GNU Make](https://www.gnu.org/software/make/):
The easiest way to start working on the project is to use GNU Make:
```shellscript
$ make bootstrap FLUSH_ARGS='--no-input'
```
This command builds the `app` container, installs dependencies, performs database migrations and compile translations. It's a good idea to use this command each time you are pulling code from the project repository to avoid dependency-related or migration-related issues.
This command builds the `app` container, installs dependencies, performs database migrations and compile translations. It's a good idea to use this
command each time you are pulling code from the project repository to avoid dependency-related or migration-related issues.
Your Docker services should now be up and running 🎉
@@ -99,7 +89,7 @@ password: impress
📝 Note that if you need to run them afterwards, you can use the eponym Make rule:
```shellscript
$ make run
$ make run-with-frontend
```
⚠️ For the frontend developer, it is often better to run the frontend in development mode locally.
@@ -107,7 +97,7 @@ $ make run
To do so, install the frontend dependencies with the following command:
```shellscript
$ make frontend-development-install
$ make frontend-install
```
And run the frontend locally in development mode with the following command:
@@ -119,7 +109,7 @@ $ make run-frontend-development
To start all the services, except the frontend container, you can use the following command:
```shellscript
$ make run-backend
$ make run
```
**Adding content**
@@ -136,7 +126,6 @@ $ make help
```
**Django admin**
You can access the Django admin site at
<http://localhost:8071/admin>.
@@ -148,21 +137,17 @@ $ make superuser
```
## Feedback 🙋‍♂️🙋‍♀️
We'd love to hear your thoughts and hear about your experiments, so come and say hi on [Matrix](https://matrix.to/#/#docs-official:matrix.org).
## Roadmap
Want to know where the project is headed? [🗺️ Checkout our roadmap](https://github.com/orgs/numerique-gouv/projects/13/views/11)
## Licence 📝
This work is released under the MIT License (see [LICENSE](https://github.com/suitenumerique/docs/blob/main/LICENSE)).
While Docs is a public driven initiative our licence choice is an invitation for private sector actors to use, sell and contribute to the project.
## Contributing 🙌
This project is intended to be community-driven, so please, do not hesitate to [get in touch](https://matrix.to/#/#docs-official:matrix.org) if you have any question related to our implementation or design decisions.
You can help us with translations on [Crowdin](https://crowdin.com/project/lasuite-docs).
@@ -184,13 +169,10 @@ docs
```
## Credits ❤️
### Stack
Docs is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/), [BlockNote.js](https://www.blocknotejs.org/), [HocusPocus](https://tiptap.dev/docs/hocuspocus/introduction) and [Yjs](https://yjs.dev/).
Docs is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/), [MinIO](https://min.io/), [BlockNote.js](https://www.blocknotejs.org/), [HocusPocus](https://tiptap.dev/docs/hocuspocus/introduction) and [Yjs](https://yjs.dev/)
### Gov ❤️ open source
Docs is the result of a joint effort led by the French 🇫🇷🥖 ([DINUM](https://www.numerique.gouv.fr/dinum/)) and German 🇩🇪🥨 governments ([ZenDiS](https://zendis.de/)).
We are proud sponsors of [BlockNotejs](https://www.blocknotejs.org/) and [Yjs](https://yjs.dev/).

View File

@@ -16,18 +16,6 @@ the following command inside your docker container:
## [Unreleased]
## [3.0.0] - 2025-03-28
We are not using the nginx auth request anymore to access the collaboration server (`yProvider`)
The authentication is now managed directly from the yProvider server.
You must remove the annotation `nginx.ingress.kubernetes.io/auth-url` from the `ingressCollaborationWS`.
This means as well that the yProvider server must be able to access the Django server.
To do so, you must set the `COLLABORATION_BACKEND_BASE_URL` environment variable to the `yProvider`
service.
## [2.2.0] - 2025-02-10
- AI features are now limited to users who are authenticated. Before this release, even anonymous
users who gained editor access on a document with link reach used to get AI feature.
IF you want anonymous users to keep access on AI features, you must now define the

View File

@@ -39,9 +39,6 @@ docker_build(
]
)
k8s_resource('impress-docs-backend-migrate', resource_deps=['postgres-postgresql'])
k8s_resource('impress-docs-backend-createsuperuser', resource_deps=['impress-docs-backend-migrate'])
k8s_resource('impress-docs-backend', resource_deps=['impress-docs-backend-migrate'])
k8s_yaml(local('cd ../src/helm && helmfile -n impress -e dev template .'))
migration = '''

View File

@@ -7,6 +7,7 @@ UNSET_USER=0
TERRAFORM_DIRECTORY="./env.d/terraform"
COMPOSE_FILE="${REPO_DIR}/docker-compose.yml"
COMPOSE_PROJECT="docs"
# _set_user: set (or unset) default user id used to run docker commands
@@ -39,8 +40,9 @@ function _set_user() {
# ARGS : docker compose command arguments
function _docker_compose() {
echo "🐳(compose) file: '${COMPOSE_FILE}'"
echo "🐳(compose) project: '${COMPOSE_PROJECT}' file: '${COMPOSE_FILE}'"
docker compose \
-p "${COMPOSE_PROJECT}" \
-f "${COMPOSE_FILE}" \
--project-directory "${REPO_DIR}" \
"$@"

View File

@@ -1,13 +1,6 @@
name: docs
services:
postgresql:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
interval: 1s
timeout: 2s
retries: 300
env_file:
- env.d/development/postgresql
ports:
@@ -22,7 +15,7 @@ services:
- "1081:1080"
minio:
user: ${DOCKER_USER:-1000}
# user: ${DOCKER_USER:-1000}
image: minio/minio
environment:
- MINIO_ROOT_USER=impress
@@ -30,11 +23,6 @@ services:
ports:
- '9000:9000'
- '9001:9001'
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 1s
timeout: 20s
retries: 300
entrypoint: ""
command: minio server --console-address :9001 /data
volumes:
@@ -43,9 +31,7 @@ services:
createbuckets:
image: minio/mc
depends_on:
minio:
condition: service_healthy
restart: true
- minio
entrypoint: >
sh -c "
/usr/bin/mc alias set impress http://minio:9000 impress password && \
@@ -73,15 +59,10 @@ services:
- ./src/backend:/app
- ./data/static:/data/static
depends_on:
postgresql:
condition: service_healthy
restart: true
mailcatcher:
condition: service_started
redis:
condition: service_started
createbuckets:
condition: service_started
- postgresql
- mailcatcher
- redis
- createbuckets
celery-dev:
user: ${DOCKER_USER:-1000}
@@ -112,13 +93,9 @@ services:
- env.d/development/common
- env.d/development/postgresql
depends_on:
postgresql:
condition: service_healthy
restart: true
redis:
condition: service_started
minio:
condition: service_started
- postgresql
- redis
- minio
celery:
user: ${DOCKER_USER:-1000}
@@ -139,15 +116,11 @@ services:
volumes:
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
depends_on:
app-dev:
condition: service_started
y-provider:
condition: service_started
keycloak:
condition: service_healthy
restart: true
- keycloak
- app-dev
- y-provider
frontend:
frontend-dev:
user: "${DOCKER_USER:-1000}"
build:
context: .
@@ -162,6 +135,9 @@ services:
ports:
- "3000:3000"
dockerize:
image: jwilder/dockerize
crowdin:
image: crowdin/cli:3.16.0
volumes:
@@ -185,23 +161,14 @@ services:
context: .
dockerfile: ./src/frontend/servers/y-provider/Dockerfile
target: y-provider
command: ["yarn", "workspace", "server-y-provider", "run", "dev"]
working_dir: /app/frontend
restart: unless-stopped
env_file:
- env.d/development/common
ports:
- "4444:4444"
volumes:
- ./src/frontend/:/app/frontend
kc_postgresql:
image: postgres:14.3
healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
interval: 1s
timeout: 2s
retries: 300
ports:
- "5433:5432"
env_file:
@@ -220,13 +187,6 @@ services:
- --hostname-admin-url=http://localhost:8083/
- --hostname-strict=false
- --hostname-strict-https=false
- --health-enabled=true
- --metrics-enabled=true
healthcheck:
test: ["CMD", "curl", "--head", "-fsS", "http://localhost:8080/health/ready"]
interval: 1s
timeout: 2s
retries: 300
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
@@ -240,6 +200,4 @@ services:
ports:
- "8080:8080"
depends_on:
kc_postgresql:
condition: service_healthy
restart: true
- kc_postgresql

View File

@@ -4,6 +4,54 @@ server {
server_name localhost;
charset utf-8;
# Proxy auth for collaboration server
location /collaboration/ws/ {
# Collaboration Auth request configuration
auth_request /collaboration-auth;
auth_request_set $authHeader $upstream_http_authorization;
auth_request_set $canEdit $upstream_http_x_can_edit;
auth_request_set $userId $upstream_http_x_user_id;
# Pass specific headers from the auth response
proxy_set_header Authorization $authHeader;
proxy_set_header X-Can-Edit $canEdit;
proxy_set_header X-User-Id $userId;
# Ensure WebSocket upgrade
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
# Collaboration server
proxy_pass http://y-provider:4444;
# Set appropriate timeout for WebSocket
proxy_read_timeout 86400;
proxy_send_timeout 86400;
# Preserve original host and additional headers
proxy_set_header Host $host;
}
location /collaboration-auth {
proxy_pass http://app-dev:8000/api/v1.0/documents/collaboration-auth/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Original-URL $request_uri;
# Prevent the body from being passed
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-Method $request_method;
}
location /collaboration/api/ {
# Collaboration server
proxy_pass http://y-provider:4444;
proxy_set_header Host $host;
}
# Proxy auth for media
location /media/ {
# Auth request configuration
@@ -20,8 +68,6 @@ server {
# Get resource from Minio
proxy_pass http://minio:9000/impress-media-storage/;
proxy_set_header Host minio:9000;
add_header Content-Security-Policy "default-src 'none'" always;
}
location /media-auth {
@@ -42,11 +88,5 @@ server {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Increase proxy buffer size to allow keycloak to send large
# header responses when a user is created.
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
}
}

View File

@@ -1,94 +0,0 @@
# Docs variables
Here we describe all environment variables that can be set for the docs application.
## impress-backend container
These are the environmental variables you can set for the impress-backend container.
| Option | Description | default |
| ----------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| DJANGO_ALLOWED_HOSTS | allowed hosts | [] |
| DJANGO_SECRET_KEY | secret key | |
| DJANGO_SERVER_TO_SERVER_API_TOKENS | | [] |
| DB_ENGINE | engine to use for database connections | django.db.backends.postgresql_psycopg2 |
| DB_NAME | name of the database | impress |
| DB_USER | user to authenticate with | dinum |
| DB_PASSWORD | password to authenticate with | pass |
| DB_HOST | host of the database | localhost |
| DB_PORT | port of the database | 5432 |
| MEDIA_BASE_URL | | |
| STORAGES_STATICFILES_BACKEND | | whitenoise.storage.CompressedManifestStaticFilesStorage |
| AWS_S3_ENDPOINT_URL | S3 endpoint | |
| AWS_S3_ACCESS_KEY_ID | access id for s3 endpoint | |
| AWS_S3_SECRET_ACCESS_KEY | access key for s3 endpoint | |
| AWS_S3_REGION_NAME | region name for s3 endpoint | |
| AWS_STORAGE_BUCKET_NAME | bucket name for s3 endpoint | impress-media-storage |
| DOCUMENT_IMAGE_MAX_SIZE | maximum size of document in bytes | 10485760 |
| LANGUAGE_CODE | default language | en-us |
| API_USERS_LIST_THROTTLE_RATE_SUSTAINED | throttle rate for api | 180/hour |
| API_USERS_LIST_THROTTLE_RATE_BURST | throttle rate for api on burst | 30/minute |
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | | false |
| TRASHBIN_CUTOFF_DAYS | trashbin cutoff | 30 |
| DJANGO_EMAIL_BACKEND | email backend library | django.core.mail.backends.smtp.EmailBackend |
| DJANGO_EMAIL_BRAND_NAME | brand name for email | |
| DJANGO_EMAIL_HOST | host name of email | |
| DJANGO_EMAIL_HOST_USER | user to authenticate with on the email host | |
| DJANGO_EMAIL_HOST_PASSWORD | password to authenticate with on the email host | |
| DJANGO_EMAIL_LOGO_IMG | logo for the email | |
| DJANGO_EMAIL_PORT | port used to connect to email host | |
| DJANGO_EMAIL_USE_TLS | use tls for email host connection | false |
| DJANGO_EMAIL_USE_SSL | use sstl for email host connection | false |
| DJANGO_EMAIL_FROM | email adress used as sender | from@example.com |
| DJANGO_CORS_ALLOW_ALL_ORIGINS | allow all CORS origins | true |
| DJANGO_CORS_ALLOWED_ORIGINS | list of origins allowed for CORS | [] |
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | list of origins allowed for CORS using regulair expressions | [] |
| SENTRY_DSN | sentry host | |
| COLLABORATION_API_URL | collaboration api host | |
| COLLABORATION_SERVER_SECRET | collaboration api secret | |
| COLLABORATION_WS_URL | collaboration websocket url | |
| FRONTEND_THEME | frontend theme to use | |
| POSTHOG_KEY | posthog key for analytics | |
| CRISP_WEBSITE_ID | crisp website id for support | |
| DJANGO_CELERY_BROKER_URL | celery broker url | redis://redis:6379/0 |
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | celery broker transport options | {} |
| OIDC_CREATE_USER | create used on OIDC | false |
| OIDC_RP_SIGN_ALGO | verification algorithm used OIDC tokens | RS256 |
| OIDC_RP_CLIENT_ID | client id used for OIDC | impress |
| OIDC_RP_CLIENT_SECRET | client secret used for OIDC | |
| OIDC_OP_JWKS_ENDPOINT | JWKS endpoint for OIDC | |
| OIDC_OP_AUTHORIZATION_ENDPOINT | Autorization endpoint for OIDC | |
| OIDC_OP_TOKEN_ENDPOINT | Token endpoint for OIDC | |
| OIDC_OP_USER_ENDPOINT | User endpoint for OIDC | |
| OIDC_OP_LOGOUT_ENDPOINT | Logout endpoint for OIDC | |
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth paramaters | {} |
| OIDC_RP_SCOPES | scopes requested for OIDC | openid email |
| LOGIN_REDIRECT_URL | login redirect url | |
| LOGIN_REDIRECT_URL_FAILURE | login redirect url on failure | |
| LOGOUT_REDIRECT_URL | logout redirect url | |
| OIDC_USE_NONCE | use nonce for OIDC | true |
| OIDC_REDIRECT_REQUIRE_HTTPS | Require https for OIDC redirect url | false |
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed hosts for OIDC redirect url | [] |
| OIDC_STORE_ID_TOKEN | Store OIDC token | true |
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | faillback to email for identification | true |
| OIDC_ALLOW_DUPLICATE_EMAILS | Allow dupplicate emails | false |
| USER_OIDC_ESSENTIAL_CLAIMS | essential claims in OIDC token | [] |
| USER_OIDC_FIELDS_TO_FULLNAME | OIDC token claims to create full name | ["first_name", "last_name"] |
| USER_OIDC_FIELD_TO_SHORTNAME | OIDC token claims to create shortname | first_name |
| ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true |
| AI_API_KEY | AI key to be used for AI Base url | |
| AI_BASE_URL | OpenAI compatible AI base url | |
| AI_MODEL | AI Model to use | |
| AI_ALLOW_REACH_FROM | Users that can use AI must be this level. options are "public", "authenticated", "restricted" | authenticated |
| Y_PROVIDER_API_KEY | Y provider API key | |
| Y_PROVIDER_API_BASE_URL | Y Provider url | |
| CONVERSION_API_ENDPOINT | Conversion API endpoint | convert-markdown |
| CONVERSION_API_CONTENT_FIELD | Conversion api content field | content |
| CONVERSION_API_TIMEOUT | Conversion api timeout | 30 |
| CONVERSION_API_SECURE | Require secure conversion api | false |
| LOGGING_LEVEL_LOGGERS_ROOT | default logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
| LOGGING_LEVEL_LOGGERS_APP | application logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
| API_USERS_LIST_LIMIT | Limit on API users | 5 |
| DJANGO_CSRF_TRUSTED_ORIGINS | CSRF trusted origins | [] |
| REDIS_URL | cache url | redis://redis:6379/1 |
| CACHES_DEFAULT_TIMEOUT | cache default timeout | 30 |

View File

@@ -2,6 +2,7 @@
This document is a step-by-step guide that describes how to install Docs on a k8s cluster without AI features. It's a teaching document to learn how it's work. It needs to be adapt for production environment.
## Prerequisites
- k8s cluster with an nginx-ingress controller
@@ -22,7 +23,7 @@ To be able to use the script, you will need to install:
- Helm (https://helm.sh/docs/intro/quickstart/#install-helm)
```
./bin/start-kind.sh
./bin/start-kind.sh
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 4700 100 4700 0 0 92867 0 --:--:-- --:--:-- --:--:-- 94000
@@ -45,11 +46,11 @@ It will expire on 24 March 2027 🗓
2. Create kind cluster with containerd registry config dir enabled
Creating cluster "suite" ...
✓ Ensuring node image (kindest/node:v1.27.3) 🖼
✓ Preparing nodes 📦
✓ Writing configuration 📜
✓ Starting control-plane 🕹️
✓ Installing CNI 🔌
✓ Installing StorageClass 💾
✓ Preparing nodes 📦
✓ Writing configuration 📜
✓ Starting control-plane 🕹️
✓ Installing CNI 🔌
✓ Installing StorageClass 💾
Set kubectl context to "kind-suite"
You can now use your cluster with:
@@ -95,10 +96,9 @@ ingress-nginx-admission-create-t55ph 0/1 Completed 0 2m56s
ingress-nginx-admission-patch-94dvt 0/1 Completed 1 2m56s
ingress-nginx-controller-57c548c4cd-2rx47 1/1 Running 0 2m56s
```
When your k8s cluster is ready (the ingress nginx controller is up), you can start the deployment. This cluster is special because it uses the *.127.0.0.1.nip.io domain and mkcert certificates to have full HTTPS support and easy domain name management.
When your k8s cluster is ready (the ingress nginx controller is up), you can start the deployment. This cluster is special because it uses the \*.127.0.0.1.nip.io domain and mkcert certificates to have full HTTPS support and easy domain name management.
Please remember that \*.127.0.0.1.nip.io will always resolve to 127.0.0.1, except in the k8s cluster where we configure CoreDNS to answer with the ingress-nginx service IP.
Please remember that *.127.0.0.1.nip.io will always resolve to 127.0.0.1, except in the k8s cluster where we configure CoreDNS to answer with the ingress-nginx service IP.
## Preparation
@@ -227,4 +227,5 @@ impress-docs-ws <none> impress.127.0.0.1.nip.io localhost
keycloak <none> keycloak.127.0.0.1.nip.io localhost 80 49m
```
You can use impress on https://impress.127.0.0.1.nip.io. The provisionning user in keycloak is impress/impress.
You can use impress on https://impress.127.0.0.1.nip.io. The provisionning user in keycloak is impress/impress.

View File

@@ -55,11 +55,10 @@ AI_API_KEY=password
AI_MODEL=llama
# Collaboration
COLLABORATION_API_URL=http://y-provider:4444/collaboration/api/
COLLABORATION_BACKEND_BASE_URL=http://app-dev:8000
COLLABORATION_API_URL=http://nginx:8083/collaboration/api/
COLLABORATION_SERVER_ORIGIN=http://localhost:3000
COLLABORATION_SERVER_SECRET=my-secret
COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/
COLLABORATION_WS_URL=ws://localhost:8083/collaboration/ws/
# Frontend
FRONTEND_THEME=default
FRONTEND_THEME=dsfr

View File

@@ -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-zA-Z].*$".format("|".join(emojis))
pattern = r"^({:s})\(.*\)\s[a-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)]

3853
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

6
package.json Normal file
View File

@@ -0,0 +1,6 @@
{
"dependencies": {
"@blocknote/core": "^0.23.4",
"next": "^15.1.7"
}
}

View File

@@ -1,27 +0,0 @@
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"

View File

@@ -14,10 +14,15 @@
"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"
]
}

View File

@@ -151,8 +151,6 @@ class DocumentAdmin(TreeAdmin):
"path",
"depth",
"numchild",
"duplicated_from",
"attachments",
)
},
),
@@ -168,10 +166,8 @@ class DocumentAdmin(TreeAdmin):
"updated_at",
)
readonly_fields = (
"attachments",
"creator",
"depth",
"duplicated_from",
"id",
"numchild",
"path",

View File

@@ -17,10 +17,9 @@ def exception_handler(exc, context):
https://gist.github.com/twidi/9d55486c36b6a51bdcb05ce3a763e79f
"""
if isinstance(exc, ValidationError):
detail = None
if hasattr(exc, "message_dict"):
detail = exc.message_dict
elif hasattr(exc, "message"):
detail = exc.message_dict
if hasattr(exc, "message"):
detail = exc.message
elif hasattr(exc, "messages"):
detail = exc.messages

View File

@@ -12,26 +12,15 @@ 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

View File

@@ -1,8 +1,6 @@
"""Client serializers for the impress core app."""
import binascii
import mimetypes
from base64 import b64decode
from django.conf import settings
from django.db.models import Q
@@ -12,7 +10,7 @@ from django.utils.translation import gettext_lazy as _
import magic
from rest_framework import exceptions, serializers
from core import enums, models, utils
from core import enums, models
from core.services.ai_services import AI_ACTIONS
from core.services.converter_services import (
ConversionError,
@@ -23,26 +21,6 @@ from core.services.converter_services import (
class UserSerializer(serializers.ModelSerializer):
"""Serialize users."""
class Meta:
model = models.User
fields = ["id", "email", "full_name", "short_name", "language"]
read_only_fields = ["id", "email", "full_name", "short_name"]
class UserLightSerializer(UserSerializer):
"""Serialize users with limited fields."""
id = serializers.SerializerMethodField(read_only=True)
email = serializers.SerializerMethodField(read_only=True)
def get_id(self, _user):
"""Return always None. Here to have the same fields than in UserSerializer."""
return None
def get_email(self, _user):
"""Return always None. Here to have the same fields than in UserSerializer."""
return None
class Meta:
model = models.User
fields = ["id", "email", "full_name", "short_name"]
@@ -140,17 +118,6 @@ class DocumentAccessSerializer(BaseAccessSerializer):
read_only_fields = ["id", "abilities"]
class DocumentAccessLightSerializer(DocumentAccessSerializer):
"""Serialize document accesses with limited fields."""
user = UserLightSerializer(read_only=True)
class Meta:
model = models.DocumentAccess
fields = ["id", "user", "team", "role", "abilities"]
read_only_fields = ["id", "team", "role", "abilities"]
class TemplateAccessSerializer(BaseAccessSerializer):
"""Serialize template accesses."""
@@ -161,14 +128,26 @@ class TemplateAccessSerializer(BaseAccessSerializer):
read_only_fields = ["id", "abilities"]
class ListDocumentSerializer(serializers.ModelSerializer):
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):
"""Serialize documents with limited fields for display in lists."""
is_favorite = serializers.BooleanField(read_only=True)
nb_accesses_ancestors = serializers.IntegerField(read_only=True)
nb_accesses_direct = serializers.IntegerField(read_only=True)
nb_accesses = serializers.IntegerField(read_only=True)
user_roles = serializers.SerializerMethodField(read_only=True)
abilities = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.Document
@@ -182,8 +161,7 @@ class ListDocumentSerializer(serializers.ModelSerializer):
"is_favorite",
"link_role",
"link_reach",
"nb_accesses_ancestors",
"nb_accesses_direct",
"nb_accesses",
"numchild",
"path",
"title",
@@ -200,30 +178,13 @@ class ListDocumentSerializer(serializers.ModelSerializer):
"is_favorite",
"link_role",
"link_reach",
"nb_accesses_ancestors",
"nb_accesses_direct",
"nb_accesses",
"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,
@@ -253,8 +214,7 @@ class DocumentSerializer(ListDocumentSerializer):
"is_favorite",
"link_role",
"link_reach",
"nb_accesses_ancestors",
"nb_accesses_direct",
"nb_accesses",
"numchild",
"path",
"title",
@@ -270,8 +230,7 @@ class DocumentSerializer(ListDocumentSerializer):
"is_favorite",
"link_role",
"link_reach",
"nb_accesses_ancestors",
"nb_accesses_direct",
"nb_accesses",
"numchild",
"path",
"updated_at",
@@ -301,65 +260,6 @@ class DocumentSerializer(ListDocumentSerializer):
return value
def validate_content(self, value):
"""Validate the content field."""
if not value:
return None
try:
b64decode(value, validate=True)
except binascii.Error as err:
raise serializers.ValidationError("Invalid base64 content.") from err
return value
def save(self, **kwargs):
"""
Process the content field to extract attachment keys and update the document's
"attachments" field for access control.
"""
content = self.validated_data.get("content", "")
extracted_attachments = set(utils.extract_attachments(content))
existing_attachments = (
set(self.instance.attachments or []) if self.instance else set()
)
new_attachments = extracted_attachments - existing_attachments
if new_attachments:
attachments_documents = (
models.Document.objects.filter(
attachments__overlap=list(new_attachments)
)
.only("path", "attachments")
.order_by("path")
)
user = self.context["request"].user
readable_per_se_paths = (
models.Document.objects.readable_per_se(user)
.order_by("path")
.values_list("path", flat=True)
)
readable_attachments_paths = utils.filter_descendants(
[doc.path for doc in attachments_documents],
readable_per_se_paths,
skip_sorting=True,
)
readable_attachments = set()
for document in attachments_documents:
if document.path not in readable_attachments_paths:
continue
readable_attachments.update(set(document.attachments) & new_attachments)
# Update attachments with readable keys
self.validated_data["attachments"] = list(
existing_attachments | readable_attachments
)
return super().save(**kwargs)
class ServerCreateDocumentSerializer(serializers.Serializer):
"""
@@ -459,7 +359,7 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
raise NotImplementedError("Update is not supported for this serializer.")
class LinkDocumentSerializer(serializers.ModelSerializer):
class LinkDocumentSerializer(BaseResourceSerializer):
"""
Serialize link configuration for documents.
We expose it separately from document in order to simplify and secure access control.
@@ -473,27 +373,6 @@ class LinkDocumentSerializer(serializers.ModelSerializer):
]
class DocumentDuplicationSerializer(serializers.Serializer):
"""
Serializer for duplicating a document.
Allows specifying whether to keep access permissions.
"""
with_accesses = serializers.BooleanField(default=False)
def create(self, validated_data):
"""
This serializer is not intended to create objects.
"""
raise NotImplementedError("This serializer does not support creation.")
def update(self, instance, validated_data):
"""
This serializer is not intended to update objects.
"""
raise NotImplementedError("This serializer does not support updating.")
# Suppress the warning about not implementing `create` and `update` methods
# since we don't use a model and only rely on the serializer for validation
# pylint: disable=abstract-method
@@ -539,7 +418,6 @@ class FileUploadSerializer(serializers.Serializer):
self.context["expected_extension"] = extension
self.context["content_type"] = magic_mime_type
self.context["file_name"] = file.name
return file
@@ -548,16 +426,12 @@ class FileUploadSerializer(serializers.Serializer):
attrs["expected_extension"] = self.context["expected_extension"]
attrs["is_unsafe"] = self.context["is_unsafe"]
attrs["content_type"] = self.context["content_type"]
attrs["file_name"] = self.context["file_name"]
return attrs
class TemplateSerializer(serializers.ModelSerializer):
class TemplateSerializer(BaseResourceSerializer):
"""Serialize templates."""
abilities = serializers.SerializerMethodField(read_only=True)
accesses = TemplateAccessSerializer(many=True, read_only=True)
class Meta:
model = models.Template
fields = [
@@ -571,13 +445,6 @@ class TemplateSerializer(serializers.ModelSerializer):
]
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):

View File

@@ -11,35 +11,6 @@ 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.

View File

@@ -2,8 +2,9 @@
# pylint: disable=too-many-lines
import logging
import re
import uuid
from urllib.parse import unquote, urlparse
from urllib.parse import urlparse
from django.conf import settings
from django.contrib.postgres.aggregates import ArrayAgg
@@ -15,28 +16,35 @@ 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, StreamingHttpResponse
from django.utils.text import capfirst
from django.utils.translation import gettext_lazy as _
from django.http import Http404
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 core.utils import extract_attachments, filter_descendants
from . import permissions, serializers, utils
from .filters import DocumentFilter, ListDocumentFilter
from .filters import DocumentFilter
logger = logging.getLogger(__name__)
ATTACHMENTS_FOLDER = "attachments"
UUID_REGEX = (
r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"
)
FILE_EXT_REGEX = r"\.[a-zA-Z]{3,4}"
MEDIA_STORAGE_URL_PATTERN = re.compile(
f"{settings.MEDIA_URL:s}(?P<pk>{UUID_REGEX:s})/"
f"(?P<key>{ATTACHMENTS_FOLDER:s}/{UUID_REGEX:s}{FILE_EXT_REGEX:s})$"
)
COLLABORATION_WS_URL_PATTERN = re.compile(rf"(?:^|&)room=(?P<pk>{UUID_REGEX})(?:&|$)")
# pylint: disable=too-many-ancestors
@@ -127,35 +135,14 @@ 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.filter(is_active=True)
queryset = models.User.objects.all()
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):
"""
@@ -170,11 +157,11 @@ class UserViewSet(
return queryset
# Exclude all users already in the given document
if document_id := self.request.query_params.get("document_id", ""):
if document_id := self.request.GET.get("document_id", ""):
queryset = queryset.exclude(documentaccess__document_id=document_id)
if not (query := self.request.query_params.get("q", "")) or len(query) < 5:
return queryset.none()
if not (query := self.request.GET.get("q", "")):
return queryset
# For emails, match emails by Levenstein distance to prevent typing errors
if "@" in query:
@@ -183,7 +170,7 @@ class UserViewSet(
distance=RawSQL("levenshtein(email::text, %s::text)", (query,))
)
.filter(distance__lte=3)
.order_by("distance", "email")[: settings.API_USERS_LIST_LIMIT]
.order_by("distance", "email")
)
# Use trigram similarity for non-email-like queries
@@ -193,7 +180,7 @@ class UserViewSet(
queryset.filter(email__trigram_word_similar=query)
.annotate(similarity=TrigramSimilarity("email", query))
.filter(similarity__gt=0.2)
.order_by("-similarity", "email")[: settings.API_USERS_LIST_LIMIT]
.order_by("-similarity", "email")
)
@drf.decorators.action(
@@ -328,6 +315,7 @@ class DocumentViewSet(
SerializerPerActionMixin,
drf.mixins.CreateModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.ListModelMixin,
drf.mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
@@ -380,7 +368,10 @@ class DocumentViewSet(
9. **Media Auth**: Authorize access to document media.
Example: GET /documents/media-auth/
10. **AI Transform**: Apply a transformation action on a piece of text with AI.
10. **Collaboration Auth**: Authorize access to the collaboration server for a document.
Example: GET /documents/collaboration-auth/
11. **AI Transform**: Apply a transformation action on a piece of text with AI.
Example: POST /documents/{id}/ai-transform/
Expected data:
- text (str): The input text.
@@ -388,7 +379,7 @@ class DocumentViewSet(
Returns: JSON response with the processed text.
Throttled by: AIDocumentRateThrottle, AIUserRateThrottle.
11. **AI Translate**: Translate a piece of text with AI.
12. **AI Translate**: Translate a piece of text with AI.
Example: POST /documents/{id}/ai-translate/
Expected data:
- text (str): The input text.
@@ -422,21 +413,20 @@ 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
tree_serializer_class = serializers.ListDocumentSerializer
children_serializer_class = serializers.ListDocumentSerializer
ai_translate_serializer_class = serializers.AITranslateSerializer
def annotate_is_favorite(self, queryset):
"""
@@ -509,42 +499,11 @@ class DocumentViewSet(
)
def filter_queryset(self, queryset):
"""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(
"""Apply annotations and filters sequentially."""
filterset = DocumentFilter(
self.request.GET, queryset=queryset, request=self.request
)
if not filterset.is_valid():
raise drf.exceptions.ValidationError(filterset.errors)
filterset.is_valid()
filter_data = filterset.form.cleaned_data
# Filter as early as possible on fields that are available on the model
@@ -553,19 +512,22 @@ class DocumentViewSet(
queryset = self.annotate_user_roles(queryset)
# 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)
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)
# 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 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 favorite status and filter if applicable as late as possible
queryset = self.annotate_is_favorite(queryset)
@@ -574,11 +536,18 @@ class DocumentViewSet(
)
# Apply ordering only now that everyting is filtered and annotated
queryset = filters.OrderingFilter().filter_queryset(
self.request, queryset, self
)
return filters.OrderingFilter().filter_queryset(self.request, queryset, self)
return self.get_response_for_queryset(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)
result = self.get_paginated_response(serializer.data)
return result
serializer = self.get_serializer(queryset, many=True)
return drf.response.Response(serializer.data)
def retrieve(self, request, *args, **kwargs):
"""
@@ -622,7 +591,6 @@ 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."""
@@ -632,7 +600,7 @@ class DocumentViewSet(
user=user
).values_list("document_id", flat=True)
queryset = self.filter_queryset(self.get_queryset())
queryset = self.get_queryset()
queryset = queryset.filter(id__in=favorite_documents_ids)
return self.get_response_for_queryset(queryset)
@@ -759,6 +727,7 @@ 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"""
@@ -790,193 +759,11 @@ class DocumentViewSet(
)
# GET: List children
queryset = document.get_children().filter(ancestors_deleted_at__isnull=True)
queryset = document.get_children().filter(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 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", "path").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")
# Annotate if the current document is the highest ancestor for the user
queryset = queryset.annotate(
is_highest_ancestor_for_user=db.Case(
db.When(
path=db.Value(highest_readable.path),
then=db.Value(True),
),
default=db.Value(False),
output_field=db.BooleanField(),
)
)
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=["post"],
permission_classes=[permissions.IsAuthenticated, permissions.AccessPermission],
url_path="duplicate",
)
@transaction.atomic
def duplicate(self, request, *args, **kwargs):
"""
Duplicate a document and store the links to attached files in the duplicated
document to allow cross-access.
Optionally duplicates accesses if `with_accesses` is set to true
in the payload.
"""
# Get document while checking permissions
document = self.get_object()
serializer = serializers.DocumentDuplicationSerializer(
data=request.data, partial=True
)
serializer.is_valid(raise_exception=True)
with_accesses = serializer.validated_data.get("with_accesses", False)
base64_yjs_content = document.content
# Duplicate the document instance
link_kwargs = (
{"link_reach": document.link_reach, "link_role": document.link_role}
if with_accesses
else {}
)
extracted_attachments = set(extract_attachments(document.content))
attachments = list(extracted_attachments & set(document.attachments))
duplicated_document = document.add_sibling(
"right",
title=capfirst(_("copy of {title}").format(title=document.title)),
content=base64_yjs_content,
attachments=attachments,
duplicated_from=document,
creator=request.user,
**link_kwargs,
)
# Always add the logged-in user as OWNER
accesses_to_create = [
models.DocumentAccess(
document=duplicated_document,
user=request.user,
role=models.RoleChoices.OWNER,
)
]
# If accesses should be duplicated, add other users' accesses as per original document
if with_accesses:
original_accesses = models.DocumentAccess.objects.filter(
document=document
).exclude(user=request.user)
accesses_to_create.extend(
models.DocumentAccess(
document=duplicated_document,
user_id=access.user_id,
team=access.team,
role=access.role,
)
for access in original_accesses
)
# Bulk create all the duplicated accesses
models.DocumentAccess.objects.bulk_create(accesses_to_create)
return drf_response.Response(
{"id": str(duplicated_document.id)}, status=status.HTTP_201_CREATED
)
queryset = self.annotate_user_roles(queryset)
return self.get_response_for_queryset(queryset)
@drf.decorators.action(detail=True, methods=["get"], url_path="versions")
def versions_list(self, request, *args, **kwargs):
@@ -1019,7 +806,7 @@ class DocumentViewSet(
@drf.decorators.action(
detail=True,
methods=["get", "delete"],
url_path="versions/(?P<version_id>[0-9a-z-]+)",
url_path="versions/(?P<version_id>[0-9a-f-]{36})",
)
# pylint: disable=unused-argument
def versions_detail(self, request, pk, version_id, *args, **kwargs):
@@ -1127,51 +914,31 @@ class DocumentViewSet(
# Generate a generic yet unique filename to store the image in object storage
file_id = uuid.uuid4()
ext = serializer.validated_data["expected_extension"]
extension = serializer.validated_data["expected_extension"]
key = f"{document.key_base}/{ATTACHMENTS_FOLDER:s}/{file_id!s}.{extension:s}"
# Prepare metadata for storage
extra_args = {
"Metadata": {"owner": str(request.user.id)},
"ContentType": serializer.validated_data["content_type"],
}
file_unsafe = ""
if serializer.validated_data["is_unsafe"]:
extra_args["Metadata"]["is_unsafe"] = "true"
file_unsafe = "-unsafe"
key = f"{document.key_base}/{enums.ATTACHMENTS_FOLDER:s}/{file_id!s}{file_unsafe}.{ext:s}"
file_name = serializer.validated_data["file_name"]
if (
not serializer.validated_data["content_type"].startswith("image/")
or serializer.validated_data["is_unsafe"]
):
extra_args.update(
{"ContentDisposition": f'attachment; filename="{file_name:s}"'}
)
else:
extra_args.update(
{"ContentDisposition": f'inline; filename="{file_name:s}"'}
)
file = serializer.validated_data["file"]
default_storage.connection.meta.client.upload_fileobj(
file, default_storage.bucket_name, key, ExtraArgs=extra_args
)
# Make the attachment readable by document readers
document.attachments.append(key)
document.save()
return drf.response.Response(
{"file": f"{settings.MEDIA_URL:s}{key:s}"},
status=drf.status.HTTP_201_CREATED,
)
def _auth_get_original_url(self, request):
def _authorize_subrequest(self, request, pattern):
"""
Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header.
Raises PermissionDenied if the header is missing.
Shared method to authorize access based on the original URL of an Nginx subrequest
and user permissions. Returns a dictionary of URL parameters if authorized.
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
See corresponding ingress configuration in Helm chart and read about the
@@ -1182,6 +949,14 @@ class DocumentViewSet(
to let this request go through (by returning a 200 code) or if we block it (by returning
a 403 error). Note that we return 403 errors without any further details for security
reasons.
Parameters:
- pattern: The regex pattern to extract identifiers from the URL.
Returns:
- A dictionary of URL parameters if the request is authorized.
Raises:
- PermissionDenied if authorization fails.
"""
# Extract the original URL from the request header
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
@@ -1189,21 +964,52 @@ class DocumentViewSet(
logger.debug("Missing HTTP_X_ORIGINAL_URL header in subrequest")
raise drf.exceptions.PermissionDenied()
logger.debug("Original url: '%s'", original_url)
return urlparse(original_url)
parsed_url = urlparse(original_url)
match = pattern.search(parsed_url.path)
# If the path does not match the pattern, try to extract the parameters from the query
if not match:
match = pattern.search(parsed_url.query)
if not match:
logger.debug(
"Subrequest URL '%s' did not match pattern '%s'",
parsed_url.path,
pattern,
)
raise drf.exceptions.PermissionDenied()
def _auth_get_url_params(self, pattern, fragment):
"""
Extracts URL parameters from the given fragment using the specified regex pattern.
Raises PermissionDenied if parameters cannot be extracted.
"""
match = pattern.search(fragment)
try:
return match.groupdict()
url_params = match.groupdict()
except (ValueError, AttributeError) as exc:
logger.debug("Failed to extract parameters from subrequest URL: %s", exc)
raise drf.exceptions.PermissionDenied() from exc
pk = url_params.get("pk")
if not pk:
logger.debug("Document ID (pk) not found in URL parameters: %s", url_params)
raise drf.exceptions.PermissionDenied()
# Fetch the document and check if the user has access
try:
document = models.Document.objects.get(pk=pk)
except models.Document.DoesNotExist as exc:
logger.debug("Document with ID '%s' does not exist", pk)
raise drf.exceptions.PermissionDenied() from exc
user_abilities = document.get_abilities(request.user)
if not user_abilities.get(self.action, False):
logger.debug(
"User '%s' lacks permission for document '%s'", request.user, pk
)
raise drf.exceptions.PermissionDenied()
logger.debug(
"Subrequest authorization successful. Extracted parameters: %s", url_params
)
return url_params, user_abilities, request.user.id
@drf.decorators.action(detail=False, methods=["get"], url_path="media-auth")
def media_auth(self, request, *args, **kwargs):
"""
@@ -1215,42 +1021,36 @@ class DocumentViewSet(
annotation. The request will then be proxied to the object storage backend who will
respond with the file after checking the signature included in headers.
"""
parsed_url = self._auth_get_original_url(request)
url_params = self._auth_get_url_params(
enums.MEDIA_STORAGE_URL_PATTERN, parsed_url.path
url_params, _, _ = self._authorize_subrequest(
request, MEDIA_STORAGE_URL_PATTERN
)
user = request.user
key = f"{url_params['pk']:s}/{url_params['attachment']:s}"
# Look for a document to which the user has access and that includes this attachment
# We must look into all descendants of any document to which the user has access per se
readable_per_se_paths = (
self.queryset.readable_per_se(user)
.order_by("path")
.values_list("path", flat=True)
)
attachments_documents = (
self.queryset.filter(attachments__contains=[key])
.only("path")
.order_by("path")
)
readable_attachments_paths = filter_descendants(
[doc.path for doc in attachments_documents],
readable_per_se_paths,
skip_sorting=True,
)
if not readable_attachments_paths:
logger.debug("User '%s' lacks permission for attachment", user)
raise drf.exceptions.PermissionDenied()
pk, key = url_params.values()
# Generate S3 authorization headers using the extracted URL parameters
request = utils.generate_s3_authorization_headers(key)
request = utils.generate_s3_authorization_headers(f"{pk:s}/{key:s}")
return drf.response.Response("authorized", headers=request.headers, status=200)
@drf.decorators.action(detail=False, methods=["get"], url_path="collaboration-auth")
def collaboration_auth(self, request, *args, **kwargs):
"""
This view is used by an Nginx subrequest to control access to a document's
collaboration server.
"""
_, user_abilities, user_id = self._authorize_subrequest(
request, COLLABORATION_WS_URL_PATTERN
)
can_edit = user_abilities["partial_update"]
# Add the collaboration server secret token to the headers
headers = {
"Authorization": settings.COLLABORATION_SERVER_SECRET,
"X-Can-Edit": str(can_edit),
"X-User-Id": str(user_id),
}
return drf.response.Response("authorized", headers=headers, status=200)
@drf.decorators.action(
detail=True,
methods=["post"],
@@ -1307,70 +1107,15 @@ 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,
viewsets.ModelViewSet,
drf.mixins.CreateModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.ListModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""
API ViewSet for all interactions with document accesses.
@@ -1402,44 +1147,17 @@ class DocumentAccessViewSet(
queryset = models.DocumentAccess.objects.select_related("user").all()
resource_field_name = "document"
serializer_class = serializers.DocumentAccessSerializer
is_current_user_owner_or_admin = False
def get_queryset(self):
"""Return the queryset according to the action."""
queryset = super().get_queryset()
if self.action == "list":
try:
document = models.Document.objects.get(pk=self.kwargs["resource_id"])
except models.Document.DoesNotExist:
return queryset.none()
roles = set(document.get_roles(self.request.user))
is_owner_or_admin = bool(roles.intersection(set(models.PRIVILEGED_ROLES)))
self.is_current_user_owner_or_admin = is_owner_or_admin
if not is_owner_or_admin:
# Return only the document owner access
queryset = queryset.filter(role__in=models.PRIVILEGED_ROLES)
return queryset
def get_serializer_class(self):
if self.action == "list" and not self.is_current_user_owner_or_admin:
return serializers.DocumentAccessLightSerializer
return super().get_serializer_class()
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,
access.user.language
or self.request.user.language
or settings.LANGUAGE_CODE,
language,
)
def perform_update(self, serializer):
@@ -1665,11 +1383,10 @@ 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,
self.request.user.language or settings.LANGUAGE_CODE,
invitation.email, invitation.role, self.request.user, language
)

View File

@@ -2,26 +2,10 @@
Core application enums declaration
"""
import re
from django.conf import global_settings, settings
from django.conf import global_settings
from django.db import models
from django.utils.translation import gettext_lazy as _
ATTACHMENTS_FOLDER = "attachments"
UUID_REGEX = (
r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"
)
FILE_EXT_REGEX = r"\.[a-zA-Z0-9]{1,10}"
MEDIA_STORAGE_URL_PATTERN = re.compile(
f"{settings.MEDIA_URL:s}(?P<pk>{UUID_REGEX:s})/"
f"(?P<attachment>{ATTACHMENTS_FOLDER:s}/{UUID_REGEX:s}(?:-unsafe)?{FILE_EXT_REGEX:s})$"
)
MEDIA_STORAGE_URL_EXTRACT = re.compile(
f"{settings.MEDIA_URL:s}({UUID_REGEX}/{ATTACHMENTS_FOLDER}/{UUID_REGEX}{FILE_EXT_REGEX})"
)
# In Django's code base, `LANGUAGES` is set by default with all supported languages.
# We can use it for the choice of languages which should not be limited to the few languages
# active in the app.

View File

@@ -13,22 +13,6 @@ from core import models
fake = Faker()
YDOC_HELLO_WORLD_BASE64 = (
"AR717vLVDgAHAQ5kb2N1bWVudC1zdG9yZQMKYmxvY2tHcm91cAcA9e7y1Q4AAw5ibG9ja0NvbnRh"
"aW5lcgcA9e7y1Q4BAwdoZWFkaW5nBwD17vLVDgIGBgD17vLVDgMGaXRhbGljAnt9hPXu8tUOBAVI"
"ZWxsb4b17vLVDgkGaXRhbGljBG51bGwoAPXu8tUOAg10ZXh0QWxpZ25tZW50AXcEbGVmdCgA9e7y"
"1Q4CBWxldmVsAX0BKAD17vLVDgECaWQBdyQwNGQ2MjM0MS04MzI2LTQyMzYtYTA4My00ODdlMjZm"
"YWQyMzAoAPXu8tUOAQl0ZXh0Q29sb3IBdwdkZWZhdWx0KAD17vLVDgEPYmFja2dyb3VuZENvbG9y"
"AXcHZGVmYXVsdIf17vLVDgEDDmJsb2NrQ29udGFpbmVyBwD17vLVDhADDmJ1bGxldExpc3RJdGVt"
"BwD17vLVDhEGBAD17vLVDhIBd4b17vLVDhMEYm9sZAJ7fYT17vLVDhQCb3KG9e7y1Q4WBGJvbGQE"
"bnVsbIT17vLVDhcCbGQoAPXu8tUOEQ10ZXh0QWxpZ25tZW50AXcEbGVmdCgA9e7y1Q4QAmlkAXck"
"ZDM1MWUwNjgtM2U1NS00MjI2LThlYTUtYWJiMjYzMTk4ZTJhKAD17vLVDhAJdGV4dENvbG9yAXcH"
"ZGVmYXVsdCgA9e7y1Q4QD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHSH9e7y1Q4QAw5ibG9ja0Nv"
"bnRhaW5lcgcA9e7y1Q4eAwlwYXJhZ3JhcGgoAPXu8tUOHw10ZXh0QWxpZ25tZW50AXcEbGVmdCgA"
"9e7y1Q4eAmlkAXckODk3MDBjMDctZTBlMS00ZmUwLWFjYTItODQ5MzIwOWE3ZTQyKAD17vLVDh4J"
"dGV4dENvbG9yAXcHZGVmYXVsdCgA9e7y1Q4eD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQA"
)
class UserFactory(factory.django.DjangoModelFactory):
"""A factory to random users for testing purposes."""
@@ -91,7 +75,7 @@ class DocumentFactory(factory.django.DjangoModelFactory):
title = factory.Sequence(lambda n: f"document{n}")
excerpt = factory.Sequence(lambda n: f"excerpt{n}")
content = YDOC_HELLO_WORLD_BASE64
content = factory.Sequence(lambda n: f"content{n}")
creator = factory.SubFactory(UserFactory)
deleted_at = None
link_reach = factory.fuzzy.FuzzyChoice(

View File

@@ -1,552 +1,166 @@
# Generated by Django 5.0.3 on 2024-05-28 20:29
import uuid
import django.contrib.auth.models
import django.core.validators
import django.db.models.deletion
import timezone_field.fields
import uuid
from django.conf import settings
from django.db import migrations, models
import timezone_field.fields
class Migration(migrations.Migration):
initial = True
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name="Document",
name='Document',
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
help_text="primary key for the record as UUID",
primary_key=True,
serialize=False,
verbose_name="id",
),
),
(
"created_at",
models.DateTimeField(
auto_now_add=True,
help_text="date and time at which a record was created",
verbose_name="created on",
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True,
help_text="date and time at which a record was last updated",
verbose_name="updated on",
),
),
("title", models.CharField(max_length=255, verbose_name="title")),
(
"is_public",
models.BooleanField(
default=False,
help_text="Whether this document is public for anyone to use.",
verbose_name="public",
),
),
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('title', models.CharField(max_length=255, verbose_name='title')),
('is_public', models.BooleanField(default=False, help_text='Whether this document is public for anyone to use.', verbose_name='public')),
],
options={
"verbose_name": "Document",
"verbose_name_plural": "Documents",
"db_table": "impress_document",
"ordering": ("title",),
'verbose_name': 'Document',
'verbose_name_plural': 'Documents',
'db_table': 'impress_document',
'ordering': ('title',),
},
),
migrations.CreateModel(
name="Template",
name='Template',
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
help_text="primary key for the record as UUID",
primary_key=True,
serialize=False,
verbose_name="id",
),
),
(
"created_at",
models.DateTimeField(
auto_now_add=True,
help_text="date and time at which a record was created",
verbose_name="created on",
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True,
help_text="date and time at which a record was last updated",
verbose_name="updated on",
),
),
("title", models.CharField(max_length=255, verbose_name="title")),
(
"description",
models.TextField(blank=True, verbose_name="description"),
),
("code", models.TextField(blank=True, verbose_name="code")),
("css", models.TextField(blank=True, verbose_name="css")),
(
"is_public",
models.BooleanField(
default=False,
help_text="Whether this template is public for anyone to use.",
verbose_name="public",
),
),
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('title', models.CharField(max_length=255, verbose_name='title')),
('description', models.TextField(blank=True, verbose_name='description')),
('code', models.TextField(blank=True, verbose_name='code')),
('css', models.TextField(blank=True, verbose_name='css')),
('is_public', models.BooleanField(default=False, help_text='Whether this template is public for anyone to use.', verbose_name='public')),
],
options={
"verbose_name": "Template",
"verbose_name_plural": "Templates",
"db_table": "impress_template",
"ordering": ("title",),
'verbose_name': 'Template',
'verbose_name_plural': 'Templates',
'db_table': 'impress_template',
'ordering': ('title',),
},
),
migrations.CreateModel(
name="User",
name='User',
fields=[
("password", models.CharField(max_length=128, verbose_name="password")),
(
"last_login",
models.DateTimeField(
blank=True, null=True, verbose_name="last login"
),
),
(
"is_superuser",
models.BooleanField(
default=False,
help_text="Designates that this user has all permissions without explicitly assigning them.",
verbose_name="superuser status",
),
),
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
help_text="primary key for the record as UUID",
primary_key=True,
serialize=False,
verbose_name="id",
),
),
(
"created_at",
models.DateTimeField(
auto_now_add=True,
help_text="date and time at which a record was created",
verbose_name="created on",
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True,
help_text="date and time at which a record was last updated",
verbose_name="updated on",
),
),
(
"sub",
models.CharField(
blank=True,
help_text="Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.",
max_length=255,
null=True,
unique=True,
validators=[
django.core.validators.RegexValidator(
message="Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.",
regex="^[\\w.@+-]+\\Z",
)
],
verbose_name="sub",
),
),
(
"email",
models.EmailField(
blank=True,
max_length=254,
null=True,
verbose_name="identity email address",
),
),
(
"admin_email",
models.EmailField(
blank=True,
max_length=254,
null=True,
unique=True,
verbose_name="admin email address",
),
),
(
"language",
models.CharField(
choices="(('en-us', 'English'), ('fr-fr', 'French'))",
default="en-us",
help_text="The language in which the user wants to see the interface.",
max_length=10,
verbose_name="language",
),
),
(
"timezone",
timezone_field.fields.TimeZoneField(
choices_display="WITH_GMT_OFFSET",
default="UTC",
help_text="The timezone in which the user wants to see times.",
use_pytz=False,
),
),
(
"is_device",
models.BooleanField(
default=False,
help_text="Whether the user is a device or a real user.",
verbose_name="device",
),
),
(
"is_staff",
models.BooleanField(
default=False,
help_text="Whether the user can log into this admin site.",
verbose_name="staff status",
),
),
(
"is_active",
models.BooleanField(
default=True,
help_text="Whether this user should be treated as active. Unselect this instead of deleting accounts.",
verbose_name="active",
),
),
(
"groups",
models.ManyToManyField(
blank=True,
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
related_name="user_set",
related_query_name="user",
to="auth.group",
verbose_name="groups",
),
),
(
"user_permissions",
models.ManyToManyField(
blank=True,
help_text="Specific permissions for this user.",
related_name="user_set",
related_query_name="user",
to="auth.permission",
verbose_name="user permissions",
),
),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('sub', models.CharField(blank=True, help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='identity email address')),
('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')),
('language', models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
('timezone', timezone_field.fields.TimeZoneField(choices_display='WITH_GMT_OFFSET', default='UTC', help_text='The timezone in which the user wants to see times.', use_pytz=False)),
('is_device', models.BooleanField(default=False, help_text='Whether the user is a device or a real user.', verbose_name='device')),
('is_staff', models.BooleanField(default=False, help_text='Whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
"verbose_name": "user",
"verbose_name_plural": "users",
"db_table": "impress_user",
'verbose_name': 'user',
'verbose_name_plural': 'users',
'db_table': 'impress_user',
},
managers=[
("objects", django.contrib.auth.models.UserManager()),
('objects', django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name="DocumentAccess",
name='DocumentAccess',
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
help_text="primary key for the record as UUID",
primary_key=True,
serialize=False,
verbose_name="id",
),
),
(
"created_at",
models.DateTimeField(
auto_now_add=True,
help_text="date and time at which a record was created",
verbose_name="created on",
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True,
help_text="date and time at which a record was last updated",
verbose_name="updated on",
),
),
("team", models.CharField(blank=True, max_length=100)),
(
"role",
models.CharField(
choices=[
("reader", "Reader"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
(
"document",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="accesses",
to="core.document",
),
),
(
"user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('team', models.CharField(blank=True, max_length=100)),
('role', models.CharField(choices=[('reader', 'Reader'), ('editor', 'Editor'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='reader', max_length=20)),
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accesses', to='core.document')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
"verbose_name": "Document/user relation",
"verbose_name_plural": "Document/user relations",
"db_table": "impress_document_access",
"ordering": ("-created_at",),
'verbose_name': 'Document/user relation',
'verbose_name_plural': 'Document/user relations',
'db_table': 'impress_document_access',
'ordering': ('-created_at',),
},
),
migrations.CreateModel(
name="Invitation",
name='Invitation',
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
help_text="primary key for the record as UUID",
primary_key=True,
serialize=False,
verbose_name="id",
),
),
(
"created_at",
models.DateTimeField(
auto_now_add=True,
help_text="date and time at which a record was created",
verbose_name="created on",
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True,
help_text="date and time at which a record was last updated",
verbose_name="updated on",
),
),
(
"email",
models.EmailField(max_length=254, verbose_name="email address"),
),
(
"role",
models.CharField(
choices=[
("reader", "Reader"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
(
"document",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="invitations",
to="core.document",
),
),
(
"issuer",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="invitations",
to=settings.AUTH_USER_MODEL,
),
),
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('email', models.EmailField(max_length=254, verbose_name='email address')),
('role', models.CharField(choices=[('reader', 'Reader'), ('editor', 'Editor'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='reader', max_length=20)),
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to='core.document')),
('issuer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to=settings.AUTH_USER_MODEL)),
],
options={
"verbose_name": "Document invitation",
"verbose_name_plural": "Document invitations",
"db_table": "impress_invitation",
'verbose_name': 'Document invitation',
'verbose_name_plural': 'Document invitations',
'db_table': 'impress_invitation',
},
),
migrations.CreateModel(
name="TemplateAccess",
name='TemplateAccess',
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
help_text="primary key for the record as UUID",
primary_key=True,
serialize=False,
verbose_name="id",
),
),
(
"created_at",
models.DateTimeField(
auto_now_add=True,
help_text="date and time at which a record was created",
verbose_name="created on",
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True,
help_text="date and time at which a record was last updated",
verbose_name="updated on",
),
),
("team", models.CharField(blank=True, max_length=100)),
(
"role",
models.CharField(
choices=[
("reader", "Reader"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
(
"template",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="accesses",
to="core.template",
),
),
(
"user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('team', models.CharField(blank=True, max_length=100)),
('role', models.CharField(choices=[('reader', 'Reader'), ('editor', 'Editor'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='reader', max_length=20)),
('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accesses', to='core.template')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
"verbose_name": "Template/user relation",
"verbose_name_plural": "Template/user relations",
"db_table": "impress_template_access",
"ordering": ("-created_at",),
'verbose_name': 'Template/user relation',
'verbose_name_plural': 'Template/user relations',
'db_table': 'impress_template_access',
'ordering': ('-created_at',),
},
),
migrations.AddConstraint(
model_name="documentaccess",
constraint=models.UniqueConstraint(
condition=models.Q(("user__isnull", False)),
fields=("user", "document"),
name="unique_document_user",
violation_error_message="This user is already in this document.",
),
model_name='documentaccess',
constraint=models.UniqueConstraint(condition=models.Q(('user__isnull', False)), fields=('user', 'document'), name='unique_document_user', violation_error_message='This user is already in this document.'),
),
migrations.AddConstraint(
model_name="documentaccess",
constraint=models.UniqueConstraint(
condition=models.Q(("team__gt", "")),
fields=("team", "document"),
name="unique_document_team",
violation_error_message="This team is already in this document.",
),
model_name='documentaccess',
constraint=models.UniqueConstraint(condition=models.Q(('team__gt', '')), fields=('team', 'document'), name='unique_document_team', violation_error_message='This team is already in this document.'),
),
migrations.AddConstraint(
model_name="documentaccess",
constraint=models.CheckConstraint(
check=models.Q(
models.Q(("team", ""), ("user__isnull", False)),
models.Q(("team__gt", ""), ("user__isnull", True)),
_connector="OR",
),
name="check_document_access_either_user_or_team",
violation_error_message="Either user or team must be set, not both.",
),
model_name='documentaccess',
constraint=models.CheckConstraint(check=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_document_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'),
),
migrations.AddConstraint(
model_name="invitation",
constraint=models.UniqueConstraint(
fields=("email", "document"), name="email_and_document_unique_together"
),
model_name='invitation',
constraint=models.UniqueConstraint(fields=('email', 'document'), name='email_and_document_unique_together'),
),
migrations.AddConstraint(
model_name="templateaccess",
constraint=models.UniqueConstraint(
condition=models.Q(("user__isnull", False)),
fields=("user", "template"),
name="unique_template_user",
violation_error_message="This user is already in this template.",
),
model_name='templateaccess',
constraint=models.UniqueConstraint(condition=models.Q(('user__isnull', False)), fields=('user', 'template'), name='unique_template_user', violation_error_message='This user is already in this template.'),
),
migrations.AddConstraint(
model_name="templateaccess",
constraint=models.UniqueConstraint(
condition=models.Q(("team__gt", "")),
fields=("team", "template"),
name="unique_template_team",
violation_error_message="This team is already in this template.",
),
model_name='templateaccess',
constraint=models.UniqueConstraint(condition=models.Q(('team__gt', '')), fields=('team', 'template'), name='unique_template_team', violation_error_message='This team is already in this template.'),
),
migrations.AddConstraint(
model_name="templateaccess",
constraint=models.CheckConstraint(
check=models.Q(
models.Q(("team", ""), ("user__isnull", False)),
models.Q(("team__gt", ""), ("user__isnull", True)),
_connector="OR",
),
name="check_template_access_either_user_or_team",
violation_error_message="Either user or team must be set, not both.",
),
model_name='templateaccess',
constraint=models.CheckConstraint(check=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_template_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'),
),
]

View File

@@ -1,9 +1,9 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("core", "0001_initial"),
('core', '0001_initial'),
]
operations = [

View File

@@ -1,114 +1,52 @@
# Generated by Django 5.1 on 2024-09-08 16:55
import uuid
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0002_create_pg_trgm_extension"),
('core', '0002_create_pg_trgm_extension'),
]
operations = [
migrations.AddField(
model_name="document",
name="link_reach",
field=models.CharField(
choices=[
("restricted", "Restricted"),
("authenticated", "Authenticated"),
("public", "Public"),
],
default="authenticated",
max_length=20,
),
model_name='document',
name='link_reach',
field=models.CharField(choices=[('restricted', 'Restricted'), ('authenticated', 'Authenticated'), ('public', 'Public')], default='authenticated', max_length=20),
),
migrations.AddField(
model_name="document",
name="link_role",
field=models.CharField(
choices=[("reader", "Reader"), ("editor", "Editor")],
default="reader",
max_length=20,
),
model_name='document',
name='link_role',
field=models.CharField(choices=[('reader', 'Reader'), ('editor', 'Editor')], default='reader', max_length=20),
),
migrations.AlterField(
model_name="document",
name="is_public",
model_name='document',
name='is_public',
field=models.BooleanField(null=True),
),
migrations.AlterField(
model_name="user",
name="language",
field=models.CharField(
choices="(('en-us', 'English'), ('fr-fr', 'French'))",
default="en-us",
help_text="The language in which the user wants to see the interface.",
max_length=10,
verbose_name="language",
),
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
migrations.CreateModel(
name="LinkTrace",
name='LinkTrace',
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
help_text="primary key for the record as UUID",
primary_key=True,
serialize=False,
verbose_name="id",
),
),
(
"created_at",
models.DateTimeField(
auto_now_add=True,
help_text="date and time at which a record was created",
verbose_name="created on",
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True,
help_text="date and time at which a record was last updated",
verbose_name="updated on",
),
),
(
"document",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="link_traces",
to="core.document",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="link_traces",
to=settings.AUTH_USER_MODEL,
),
),
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='link_traces', to='core.document')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='link_traces', to=settings.AUTH_USER_MODEL)),
],
options={
"verbose_name": "Document/user link trace",
"verbose_name_plural": "Document/user link traces",
"db_table": "impress_link_trace",
"constraints": [
models.UniqueConstraint(
fields=("user", "document"),
name="unique_link_trace_document_user",
violation_error_message="A link trace already exists for this document/user.",
)
],
'verbose_name': 'Document/user link trace',
'verbose_name_plural': 'Document/user link traces',
'db_table': 'impress_link_trace',
'constraints': [models.UniqueConstraint(fields=('user', 'document'), name='unique_link_trace_document_user', violation_error_message='A link trace already exists for this document/user.')],
},
),
]

View File

@@ -1,14 +1,13 @@
# Generated by Django 5.1 on 2024-09-08 17:04
from django.db import migrations
def migrate_is_public_to_link_reach(apps, schema_editor):
"""
Forward migration: Migrate 'is_public' to 'link_reach'.
If is_public == True, set link_reach to 'public'
"""
Document = apps.get_model("core", "Document")
Document.objects.filter(is_public=True).update(link_reach="public")
Document = apps.get_model('core', 'Document')
Document.objects.filter(is_public=True).update(link_reach='public')
def reverse_migrate_link_reach_to_is_public(apps, schema_editor):
@@ -17,20 +16,20 @@ def reverse_migrate_link_reach_to_is_public(apps, schema_editor):
- If link_reach == 'public', set is_public to True
- Else set is_public to False
"""
Document = apps.get_model("core", "Document")
Document.objects.filter(link_reach="public").update(is_public=True)
Document.objects.filter(link_reach__in=["restricted", "authenticated"]).update(
is_public=False
)
Document = apps.get_model('core', 'Document')
Document.objects.filter(link_reach='public').update(is_public=True)
Document.objects.filter(link_reach__in=['restricted', "authenticated"]).update(is_public=False)
class Migration(migrations.Migration):
dependencies = [
("core", "0003_document_link_reach_document_link_role_and_more"),
('core', '0003_document_link_reach_document_link_role_and_more'),
]
operations = [
migrations.RunPython(
migrate_is_public_to_link_reach, reverse_migrate_link_reach_to_is_public
migrate_is_public_to_link_reach,
reverse_migrate_link_reach_to_is_public
),
]

View File

@@ -4,16 +4,15 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0004_migrate_is_public_to_link_reach"),
('core', '0004_migrate_is_public_to_link_reach'),
]
operations = [
migrations.AlterField(
model_name="document",
name="title",
field=models.CharField(
blank=True, max_length=255, null=True, verbose_name="title"
),
model_name='document',
name='title',
field=models.CharField(blank=True, max_length=255, null=True, verbose_name='title'),
),
]

View File

@@ -4,34 +4,25 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0005_remove_document_is_public_alter_document_link_reach_and_more"),
('core', '0005_remove_document_is_public_alter_document_link_reach_and_more'),
]
operations = [
migrations.AddField(
model_name="user",
name="full_name",
field=models.CharField(
blank=True, max_length=100, null=True, verbose_name="full name"
),
model_name='user',
name='full_name',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='full name'),
),
migrations.AddField(
model_name="user",
name="short_name",
field=models.CharField(
blank=True, max_length=20, null=True, verbose_name="short name"
),
model_name='user',
name='short_name',
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='short name'),
),
migrations.AlterField(
model_name="user",
name="language",
field=models.CharField(
choices="(('en-us', 'English'), ('fr-fr', 'French'))",
default="en-us",
help_text="The language in which the user wants to see the interface.",
max_length=10,
verbose_name="language",
),
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
]

View File

@@ -117,10 +117,10 @@ BEGIN
END $$;
"""
class Migration(migrations.Migration):
dependencies = [
("core", "0006_add_user_full_name_and_short_name"),
('core', '0006_add_user_full_name_and_short_name'),
]
operations = [

View File

@@ -4,22 +4,15 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0007_fix_users_duplicate"),
('core', '0007_fix_users_duplicate'),
]
operations = [
migrations.AlterField(
model_name="document",
name="link_reach",
field=models.CharField(
choices=[
("restricted", "Restricted"),
("authenticated", "Authenticated"),
("public", "Public"),
],
default="restricted",
max_length=20,
),
model_name='document',
name='link_reach',
field=models.CharField(choices=[('restricted', 'Restricted'), ('authenticated', 'Authenticated'), ('public', 'Public')], default='restricted', max_length=20),
),
]

View File

@@ -1,87 +1,37 @@
# Generated by Django 5.1.2 on 2024-11-08 07:59
import uuid
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0008_alter_document_link_reach"),
('core', '0008_alter_document_link_reach'),
]
operations = [
migrations.AlterField(
model_name="user",
name="language",
field=models.CharField(
choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))",
default="en-us",
help_text="The language in which the user wants to see the interface.",
max_length=10,
verbose_name="language",
),
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
migrations.CreateModel(
name="DocumentFavorite",
name='DocumentFavorite',
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
help_text="primary key for the record as UUID",
primary_key=True,
serialize=False,
verbose_name="id",
),
),
(
"created_at",
models.DateTimeField(
auto_now_add=True,
help_text="date and time at which a record was created",
verbose_name="created on",
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True,
help_text="date and time at which a record was last updated",
verbose_name="updated on",
),
),
(
"document",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="favorited_by_users",
to="core.document",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="favorite_documents",
to=settings.AUTH_USER_MODEL,
),
),
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorited_by_users', to='core.document')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorite_documents', to=settings.AUTH_USER_MODEL)),
],
options={
"verbose_name": "Document favorite",
"verbose_name_plural": "Document favorites",
"db_table": "impress_document_favorite",
"constraints": [
models.UniqueConstraint(
fields=("user", "document"),
name="unique_document_favorite_user",
violation_error_message="This document is already targeted by a favorite relation instance for the same user.",
)
],
'verbose_name': 'Document favorite',
'verbose_name_plural': 'Document favorites',
'db_table': 'impress_document_favorite',
'constraints': [models.UniqueConstraint(fields=('user', 'document'), name='unique_document_favorite_user', violation_error_message='This document is already targeted by a favorite relation instance for the same user.')],
},
),
]

View File

@@ -7,48 +7,25 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0009_add_document_favorite"),
('core', '0009_add_document_favorite'),
]
operations = [
migrations.AddField(
model_name="document",
name="creator",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.RESTRICT,
related_name="documents_created",
to=settings.AUTH_USER_MODEL,
),
model_name='document',
name='creator',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='documents_created', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name="user",
name="language",
field=models.CharField(
choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))",
default="en-us",
help_text="The language in which the user wants to see the interface.",
max_length=10,
verbose_name="language",
),
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
migrations.AlterField(
model_name="user",
name="sub",
field=models.CharField(
blank=True,
help_text="Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only.",
max_length=255,
null=True,
unique=True,
validators=[
django.core.validators.RegexValidator(
message="Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters.",
regex="^[\\w.@+-:]+\\Z",
)
],
verbose_name="sub",
),
model_name='user',
name='sub',
field=models.CharField(blank=True, help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters.', regex='^[\\w.@+-:]+\\Z')], verbose_name='sub'),
),
]

View File

@@ -3,7 +3,7 @@
import django.db.models.deletion
from django.conf import settings
from django.db import migrations
from django.db.models import F, ForeignKey, OuterRef, Q, Subquery
from django.db.models import F, ForeignKey, Subquery, OuterRef, Q
def set_creator_from_document_access(apps, schema_editor):
@@ -25,37 +25,28 @@ def set_creator_from_document_access(apps, schema_editor):
DocumentAccess = apps.get_model("core", "DocumentAccess")
# Update `creator` using the "owner" role
owner_subquery = (
DocumentAccess.objects.filter(
document=OuterRef("pk"),
user__isnull=False,
role="owner",
)
.order_by("created_at")
.values("user_id")[:1]
)
owner_subquery = DocumentAccess.objects.filter(
document=OuterRef('pk'),
user__isnull=False,
role='owner',
).order_by('created_at').values('user_id')[:1]
Document.objects.filter(creator__isnull=True).update(
creator=Subquery(owner_subquery)
)
Document.objects.filter(
creator__isnull=True
).update(creator=Subquery(owner_subquery))
class Migration(migrations.Migration):
dependencies = [
("core", "0010_add_field_creator_to_document"),
('core', '0010_add_field_creator_to_document'),
]
operations = [
migrations.RunPython(
set_creator_from_document_access, reverse_code=migrations.RunPython.noop
),
migrations.RunPython(set_creator_from_document_access, reverse_code=migrations.RunPython.noop),
migrations.AlterField(
model_name="document",
name="creator",
field=ForeignKey(
on_delete=django.db.models.deletion.RESTRICT,
related_name="documents_created",
to=settings.AUTH_USER_MODEL,
),
model_name='document',
name='creator',
field=ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='documents_created', to=settings.AUTH_USER_MODEL),
),
]

View File

@@ -6,42 +6,25 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0011_populate_creator_field_and_make_it_required"),
('core', '0011_populate_creator_field_and_make_it_required'),
]
operations = [
migrations.AlterField(
model_name="document",
name="creator",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.RESTRICT,
related_name="documents_created",
to=settings.AUTH_USER_MODEL,
),
model_name='document',
name='creator',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='documents_created', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name="invitation",
name="issuer",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="invitations",
to=settings.AUTH_USER_MODEL,
),
model_name='invitation',
name='issuer',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name="user",
name="language",
field=models.CharField(
choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))",
default="en-us",
help_text="The language in which the user wants to see the interface.",
max_length=10,
verbose_name="language",
),
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
]

View File

@@ -2,10 +2,10 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("core", "0012_make_document_creator_and_invitation_issuer_optional"),
('core', '0012_make_document_creator_and_invitation_issuer_optional'),
]
operations = [

View File

@@ -4,29 +4,28 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0013_activate_fuzzystrmatch_extension"),
('core', '0013_activate_fuzzystrmatch_extension'),
]
operations = [
migrations.AddField(
model_name="document",
name="depth",
model_name='document',
name='depth',
field=models.PositiveIntegerField(default=0),
preserve_default=False,
),
migrations.AddField(
model_name="document",
name="numchild",
model_name='document',
name='numchild',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name="document",
name="path",
model_name='document',
name='path',
# Allow null values pending the next datamigration to populate the field
field=models.CharField(
db_collation="C", max_length=252, null=True, unique=True
),
field=models.CharField(db_collation='C', max_length=252, null=True, unique=True),
preserve_default=False,
),
]

View File

@@ -7,10 +7,9 @@ from treebeard.numconv import NumConv
ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
STEPLEN = 7
def set_path_on_existing_documents(apps, schema_editor):
"""
Updates the `path` and `depth` fields for all existing Document records
Updates the `path` and `depth` fields for all existing Document records
to ensure valid materialized paths.
This function assigns a unique `path` to each Document as a root node
@@ -27,25 +26,27 @@ def set_path_on_existing_documents(apps, schema_editor):
updates = []
for i, pk in enumerate(documents):
key = numconv.int2str(i)
path = "{0}{1}".format(ALPHABET[0] * (STEPLEN - len(key)), key)
path = "{0}{1}".format(
ALPHABET[0] * (STEPLEN - len(key)),
key
)
updates.append(Document(pk=pk, path=path, depth=1))
# Bulk update using the prepared updates list
Document.objects.bulk_update(updates, ["depth", "path"])
Document.objects.bulk_update(updates, ['depth', 'path'])
class Migration(migrations.Migration):
dependencies = [
("core", "0014_add_tree_structure_to_documents"),
('core', '0014_add_tree_structure_to_documents'),
]
operations = [
migrations.RunPython(
set_path_on_existing_documents, reverse_code=migrations.RunPython.noop
),
migrations.RunPython(set_path_on_existing_documents, reverse_code=migrations.RunPython.noop),
migrations.AlterField(
model_name="document",
name="path",
field=models.CharField(db_collation="C", max_length=252, unique=True),
model_name='document',
name='path',
field=models.CharField(db_collation='C', max_length=252, unique=True),
),
]

View File

@@ -4,27 +4,20 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0015_set_path_on_existing_documents"),
('core', '0015_set_path_on_existing_documents'),
]
operations = [
migrations.AddField(
model_name="document",
name="excerpt",
field=models.TextField(
blank=True, max_length=300, null=True, verbose_name="excerpt"
),
model_name='document',
name='excerpt',
field=models.TextField(blank=True, max_length=300, null=True, verbose_name='excerpt'),
),
migrations.AlterField(
model_name="user",
name="language",
field=models.CharField(
choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))",
default="en-us",
help_text="The language in which the user wants to see the interface.",
max_length=10,
verbose_name="language",
),
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
]

View File

@@ -4,49 +4,33 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0016_add_document_excerpt"),
('core', '0016_add_document_excerpt'),
]
operations = [
migrations.AlterModelOptions(
name="document",
options={
"ordering": ("path",),
"verbose_name": "Document",
"verbose_name_plural": "Documents",
},
name='document',
options={'ordering': ('path',), 'verbose_name': 'Document', 'verbose_name_plural': 'Documents'},
),
migrations.AddField(
model_name="document",
name="ancestors_deleted_at",
model_name='document',
name='ancestors_deleted_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="document",
name="deleted_at",
model_name='document',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AlterField(
model_name="user",
name="language",
field=models.CharField(
choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))",
default="en-us",
help_text="The language in which the user wants to see the interface.",
max_length=10,
verbose_name="language",
),
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
migrations.AddConstraint(
model_name="document",
constraint=models.CheckConstraint(
condition=models.Q(
("deleted_at__isnull", True),
("deleted_at", models.F("ancestors_deleted_at")),
_connector="OR",
),
name="check_deleted_at_matches_ancestors_deleted_at_when_set",
),
model_name='document',
constraint=models.CheckConstraint(condition=models.Q(('deleted_at__isnull', True), ('deleted_at', models.F('ancestors_deleted_at')), _connector='OR'), name='check_deleted_at_matches_ancestors_deleted_at_when_set'),
),
]

View File

@@ -1,24 +0,0 @@
from django.db import migrations
def update_titles_to_null(apps, schema_editor):
"""
If the titles are "Untitled document" or "Unbenanntes Dokument" or "Document sans titre"
we set them to Null
"""
Document = apps.get_model("core", "Document")
Document.objects.filter(
title__in=["Untitled document", "Unbenanntes Dokument", "Document sans titre"]
).update(title=None)
class Migration(migrations.Migration):
dependencies = [
("core", "0017_add_fields_for_soft_delete"),
]
operations = [
migrations.RunPython(
update_titles_to_null, reverse_code=migrations.RunPython.noop
),
]

View File

@@ -1,36 +0,0 @@
# 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",
),
),
]

View File

@@ -1,77 +0,0 @@
# Generated by Django 5.1.4 on 2025-01-18 11:53
import re
import django.contrib.postgres.fields
import django.db.models.deletion
from django.core.files.storage import default_storage
from django.db import migrations, models
from botocore.exceptions import ClientError
import core.models
from core.utils import extract_attachments
def populate_attachments_on_all_documents(apps, schema_editor):
"""Populate "attachments" field on all existing documents in the database."""
Document = apps.get_model("core", "Document")
for document in Document.objects.all():
try:
response = default_storage.connection.meta.client.get_object(
Bucket=default_storage.bucket_name, Key=f"{document.pk!s}/file"
)
except (FileNotFoundError, ClientError):
pass
else:
content = response["Body"].read().decode("utf-8")
document.attachments = extract_attachments(content)
document.save(update_fields=["attachments"])
class Migration(migrations.Migration):
dependencies = [
("core", "0019_alter_user_language_default_to_null"),
]
operations = [
# v2.0.0 was released so we can now remove BC field "is_public"
migrations.RemoveField(
model_name="document",
name="is_public",
),
migrations.AlterModelManagers(
name="user",
managers=[
("objects", core.models.UserManager()),
],
),
migrations.AddField(
model_name="document",
name="attachments",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(max_length=255),
blank=True,
default=list,
editable=False,
null=True,
size=None,
),
),
migrations.AddField(
model_name="document",
name="duplicated_from",
field=models.ForeignKey(
blank=True,
editable=False,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="duplicates",
to="core.document",
),
),
migrations.RunPython(
populate_attachments_on_all_documents,
reverse_code=migrations.RunPython.noop,
),
]

View File

@@ -6,14 +6,12 @@ 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
from django.conf import settings
from django.contrib.auth import models as auth_models
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.postgres.fields import ArrayField
from django.contrib.sites.models import Site
from django.core import mail, validators
from django.core.cache import cache
@@ -24,14 +22,14 @@ from django.db import models, transaction
from django.db.models.functions import Left, Length
from django.template.loader import render_to_string
from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.functional import cached_property, lazy
from django.utils.translation import get_language, override
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, MP_NodeManager, MP_NodeQuerySet
from treebeard.mp_tree import MP_Node
logger = getLogger(__name__)
@@ -82,55 +80,6 @@ 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."""
@@ -244,12 +193,10 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
language = models.CharField(
max_length=10,
choices=settings.LANGUAGES,
default=None,
choices=lazy(lambda: settings.LANGUAGES, tuple)(),
default=settings.LANGUAGE_CODE,
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",
@@ -364,9 +311,10 @@ class BaseAccess(BaseModel):
class Meta:
abstract = True
def _get_roles(self, resource, user):
def _get_abilities(self, resource, user):
"""
Get the roles a user has on a resource.
Compute and return abilities for a given user taking into account
the current state of the object.
"""
roles = []
if user.is_authenticated:
@@ -381,15 +329,6 @@ class BaseAccess(BaseModel):
except (self._meta.model.DoesNotExist, IndexError):
roles = []
return roles
def _get_abilities(self, resource, user):
"""
Compute and return abilities for a given user taking into account
the current state of the object.
"""
roles = self._get_roles(resource, user)
is_owner_or_admin = bool(
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
@@ -428,42 +367,6 @@ 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 on which the given user has
direct access, team access or link access. This will not return all the
documents that a user can read because it can be obtained via an ancestor.
:param user: The user for whom readable documents are to be fetched.
:return: A queryset of documents for which the user has direct access,
team access or link access.
"""
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.from_queryset(DocumentQuerySet)):
"""
Custom manager for the Document model, enabling the use of the custom
queryset methods directly from the model manager.
"""
def get_queryset(self):
"""Sets the custom queryset as the default."""
return self._queryset_class(self.model).order_by("path")
class Document(MP_Node, BaseModel):
"""Pad document carrying the content."""
@@ -486,21 +389,6 @@ class Document(MP_Node, BaseModel):
)
deleted_at = models.DateTimeField(null=True, blank=True)
ancestors_deleted_at = models.DateTimeField(null=True, blank=True)
duplicated_from = models.ForeignKey(
"self",
on_delete=models.SET_NULL,
related_name="duplicates",
editable=False,
blank=True,
null=True,
)
attachments = ArrayField(
models.CharField(max_length=255),
default=list,
editable=False,
blank=True,
null=True,
)
_content = None
@@ -511,8 +399,6 @@ 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",)
@@ -597,13 +483,9 @@ class Document(MP_Node, BaseModel):
def get_content_response(self, version_id=""):
"""Get the content in a specific version of the document"""
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)
return default_storage.connection.meta.client.get_object(
Bucket=default_storage.bucket_name, Key=self.file_key, VersionId=version_id
)
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"""
@@ -673,47 +555,24 @@ class Document(MP_Node, BaseModel):
"""Generate a unique cache key for each document."""
return f"document_{self.id!s}_nb_accesses"
def get_nb_accesses(self):
"""
Calculate the number of accesses:
- directly attached to the document
- attached to any of the document's ancestors
"""
@property
def nb_accesses(self):
"""Calculate the number of accesses."""
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=self).count(),
DocumentAccess.objects.filter(
document__path=Left(
models.Value(self.path), Length("document__path")
),
document__ancestors_deleted_at__isnull=True,
).count(),
)
nb_accesses = DocumentAccess.objects.filter(
document__path=Left(models.Value(self.path), Length("document__path")),
).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)
@@ -737,53 +596,25 @@ class Document(MP_Node, BaseModel):
roles = []
return roles
def get_links_definitions(self, ancestors_links):
@cached_property
def links_definitions(self):
"""Get links reach/role definitions for the current document and its ancestors."""
links_definitions = {self.link_reach: {self.link_role}}
links_definitions = defaultdict(set)
links_definitions[self.link_reach].add(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"]
)
# Merge ancestor link definitions
for ancestor in ancestors_links:
links_definitions[ancestor["link_reach"]].add(ancestor["link_role"])
return links_definitions
return dict(links_definitions) # Convert defaultdict back to a normal dict
def compute_ancestors_links(self, user):
"""
Compute the ancestors links for the current document up to the highest readable ancestor.
"""
ancestors = (
(self.get_ancestors() | self._meta.model.objects.filter(pk=self.pk))
.filter(ancestors_deleted_at__isnull=True)
.order_by("path")
)
highest_readable = ancestors.readable_per_se(user).only("depth").first()
if highest_readable is None:
return []
ancestors_links = []
paths_links_mapping = {}
for ancestor in ancestors.filter(depth__gte=highest_readable.depth):
ancestors_links.append(
{"link_reach": ancestor.link_reach, "link_role": ancestor.link_role}
)
paths_links_mapping[ancestor.path] = ancestors_links.copy()
ancestors_links = paths_links_mapping.get(self.path[: -self.steplen], [])
return ancestors_links
def get_abilities(self, user, ancestors_links=None):
def get_abilities(self, user):
"""
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.compute_ancestors_links(user=user)
roles = set(
self.get_roles(user)
) # at this point only roles based on specific access
@@ -803,7 +634,9 @@ class Document(MP_Node, BaseModel):
) and not is_deleted
# Add roles provided by the document link, taking into account its ancestors
links_definitions = self.get_links_definitions(ancestors_links)
# Add roles provided by the document link
links_definitions = self.links_definitions
public_roles = links_definitions.get(LinkReachChoices.PUBLIC, set())
authenticated_roles = (
links_definitions.get(LinkReachChoices.AUTHENTICATED, set())
@@ -838,10 +671,7 @@ 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,
"duplicate": can_get,
"favorite": can_get and user.is_authenticated,
"link_configuration": is_owner_or_admin,
"invite_owner": is_owner,
@@ -850,8 +680,6 @@ 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,
@@ -869,7 +697,6 @@ 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,
}
)
@@ -911,12 +738,8 @@ 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 = (
context["title"]
if not self.title
else _("{name} shared a document with you: {title}").format(
name=sender_name, title=self.title
)
subject = _("{name} shared a document with you: {title}").format(
name=sender_name, title=self.title
)
self.send_email(subject, [email], context, language)
@@ -927,26 +750,19 @@ 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._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()
):
if self.deleted_at or self.ancestors_deleted_at:
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
)
# Mark all descendants as soft deleted
self.get_descendants().filter(ancestors_deleted_at__isnull=True).update(
@@ -957,19 +773,20 @@ class Document(MP_Node, BaseModel):
def restore(self):
"""Cancelling a soft delete with checks."""
# This should not happen
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 is None:
raise ValidationError({"deleted_at": [_("This document is not deleted.")]})
if self.deleted_at < get_trashbin_cutoff():
raise RuntimeError(
"This document was permanently deleted and cannot be restored."
raise ValidationError(
{
"deleted_at": [
_(
"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
@@ -977,23 +794,26 @@ class Document(MP_Node, BaseModel):
ancestors_deleted_at = (
self.get_ancestors()
.filter(deleted_at__isnull=False)
.order_by("deleted_at")
.values_list("deleted_at", flat=True)
.first()
)
self.ancestors_deleted_at = ancestors_deleted_at
self.save(update_fields=["deleted_at", "ancestors_deleted_at"])
self.invalidate_nb_accesses_cache()
self.ancestors_deleted_at = min(ancestors_deleted_at, default=None)
self.save()
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
)
# 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
)
class LinkTrace(BaseModel):
@@ -1111,41 +931,7 @@ class DocumentAccess(BaseAccess):
"""
Compute and return abilities for a given user on the document access.
"""
roles = self._get_roles(self.document, user)
is_owner_or_admin = bool(set(roles).intersection(set(PRIVILEGED_ROLES)))
if self.role == RoleChoices.OWNER:
can_delete = (
RoleChoices.OWNER in roles
and self.document.accesses.filter(role=RoleChoices.OWNER).count() > 1
)
set_role_to = (
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
if can_delete
else []
)
else:
can_delete = is_owner_or_admin
set_role_to = []
if RoleChoices.OWNER in roles:
set_role_to.append(RoleChoices.OWNER)
if is_owner_or_admin:
set_role_to.extend(
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
)
# Remove the current role as we don't want to propose it as an option
try:
set_role_to.remove(self.role)
except ValueError:
pass
return {
"destroy": can_delete,
"update": bool(set_role_to) and is_owner_or_admin,
"partial_update": bool(set_role_to) and is_owner_or_admin,
"retrieve": self.user and self.user.id == user.id or is_owner_or_admin,
"set_role_to": set_role_to,
}
return self._get_abilities(self.document, user)
class Template(BaseModel):

View File

@@ -1,5 +1,8 @@
"""AI services."""
import json
import re
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
@@ -9,44 +12,32 @@ from core import enums
AI_ACTIONS = {
"prompt": (
"Answer the prompt in markdown format. "
"Preserve the language and markdown formatting. "
"Do not provide any other information. "
"Preserve the language."
"Answer the prompt in markdown format. Return JSON: "
'{"answer": "Your markdown answer"}. '
"Do not provide any other information."
),
"correct": (
"Correct grammar and spelling of the markdown text, "
"preserving language and markdown formatting. "
"Do not provide any other information. "
"Preserve the language."
'Return JSON: {"answer": "your corrected markdown text"}. '
"Do not provide any other information."
),
"rephrase": (
"Rephrase the given markdown text, "
"preserving language and markdown formatting. "
"Do not provide any other information. "
"Preserve the language."
'Return JSON: {"answer": "your rephrased markdown text"}. '
"Do not provide any other information."
),
"summarize": (
"Summarize the markdown text, preserving language and markdown formatting. "
"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."
'Return JSON: {"answer": "your markdown summary"}. '
"Do not provide any other information."
),
}
AI_TRANSLATE = (
"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. "
"Translate the markdown text to {language:s}, preserving markdown formatting. "
'Return JSON: {{"answer": "your translated markdown text in {language:s}"}}. '
"Do not provide any other information."
)
@@ -68,18 +59,32 @@ 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": text},
{"role": "user", "content": json.dumps({"markdown_input": text})},
],
)
content = response.choices[0].message.content
if not 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:
raise RuntimeError("AI response does not contain an answer")
return {"answer": content}
return json_response
def transform(self, text, action):
"""Transform text based on specified action."""

View File

@@ -59,32 +59,8 @@ def test_api_document_accesses_list_authenticated_unrelated():
}
def test_api_document_accesses_list_unexisting_document():
"""
Listing document accesses for an unexisting document should return an empty list.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/documents/{uuid4()!s}/accesses/")
assert response.status_code == 200
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize(
"role", [role for role in models.RoleChoices if role not in models.PRIVILEGED_ROLES]
)
def test_api_document_accesses_list_authenticated_related_non_privileged(
via, role, mock_user_teams
):
def test_api_document_accesses_list_authenticated_related(via, mock_user_teams):
"""
Authenticated users should be able to list document accesses for a document
to which they are directly related, whatever their role in the document.
@@ -94,114 +70,24 @@ def test_api_document_accesses_list_authenticated_related_non_privileged(
client = APIClient()
client.force_login(user)
owner = factories.UserFactory()
accesses = []
document_access = factories.UserDocumentAccessFactory(
user=owner, role=models.RoleChoices.OWNER
)
accesses.append(document_access)
document = document_access.document
if via == USER:
models.DocumentAccess.objects.create(
document=document,
user=user,
role=role,
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
models.DocumentAccess.objects.create(
document=document,
team="lasuite",
role=role,
)
access1 = factories.TeamDocumentAccessFactory(document=document)
access2 = factories.UserDocumentAccessFactory(document=document)
accesses.append(access1)
accesses.append(access2)
# Accesses for other documents to which the user is related should not be listed either
other_access = factories.UserDocumentAccessFactory(user=user)
factories.UserDocumentAccessFactory(document=other_access.document)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/accesses/",
)
# Return only owners
owners_accesses = [
access for access in accesses if access.role in models.PRIVILEGED_ROLES
]
assert response.status_code == 200
content = response.json()
assert content["count"] == len(owners_accesses)
assert sorted(content["results"], key=lambda x: x["id"]) == sorted(
[
{
"id": str(access.id),
"user": {
"id": None,
"email": None,
"full_name": access.user.full_name,
"short_name": access.user.short_name,
}
if access.user
else None,
"team": access.team,
"role": access.role,
"abilities": access.get_abilities(user),
}
for access in owners_accesses
],
key=lambda x: x["id"],
)
for access in content["results"]:
assert access["role"] in models.PRIVILEGED_ROLES
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize("role", models.PRIVILEGED_ROLES)
def test_api_document_accesses_list_authenticated_related_privileged_roles(
via, role, mock_user_teams
):
"""
Authenticated users should be able to list document accesses for a document
to which they are directly related, whatever their role in the document.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
owner = factories.UserFactory()
accesses = []
document_access = factories.UserDocumentAccessFactory(
user=owner, role=models.RoleChoices.OWNER
)
accesses.append(document_access)
document = document_access.document
document = factories.DocumentFactory()
user_access = None
if via == USER:
user_access = models.DocumentAccess.objects.create(
document=document,
user=user,
role=role,
role=random.choice(models.RoleChoices.values),
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
user_access = models.DocumentAccess.objects.create(
document=document,
team="lasuite",
role=role,
role=random.choice(models.RoleChoices.values),
)
access1 = factories.TeamDocumentAccessFactory(document=document)
access2 = factories.UserDocumentAccessFactory(document=document)
accesses.append(access1)
accesses.append(access2)
# Accesses for other documents to which the user is related should not be listed either
other_access = factories.UserDocumentAccessFactory(user=user)
@@ -216,7 +102,7 @@ def test_api_document_accesses_list_authenticated_related_privileged_roles(
assert response.status_code == 200
content = response.json()
assert len(content["results"]) == 4
assert len(content["results"]) == 3
assert sorted(content["results"], key=lambda x: x["id"]) == sorted(
[
{
@@ -240,13 +126,6 @@ def test_api_document_accesses_list_authenticated_related_privileged_roles(
"role": access2.role,
"abilities": access2.get_abilities(user),
},
{
"id": str(document_access.id),
"user": serializers.UserSerializer(instance=owner).data,
"team": "",
"role": models.RoleChoices.OWNER,
"abilities": document_access.get_abilities(user),
},
],
key=lambda x: x["id"],
)
@@ -305,10 +184,7 @@ def test_api_document_accesses_retrieve_authenticated_unrelated():
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize("role", models.RoleChoices)
def test_api_document_accesses_retrieve_authenticated_related(
via, role, mock_user_teams
):
def test_api_document_accesses_retrieve_authenticated_related(via, mock_user_teams):
"""
A user who is related to a document should be allowed to retrieve the
associated document user accesses.
@@ -320,12 +196,10 @@ def test_api_document_accesses_retrieve_authenticated_related(
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
factories.UserDocumentAccessFactory(document=document, user=user)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
access = factories.UserDocumentAccessFactory(document=document)
@@ -333,19 +207,16 @@ def test_api_document_accesses_retrieve_authenticated_related(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
if not role in models.PRIVILEGED_ROLES:
assert response.status_code == 403
else:
access_user = serializers.UserSerializer(instance=access.user).data
access_user = serializers.UserSerializer(instance=access.user).data
assert response.status_code == 200
assert response.json() == {
"id": str(access.id),
"user": access_user,
"team": "",
"role": access.role,
"abilities": access.get_abilities(user),
}
assert response.status_code == 200
assert response.json() == {
"id": str(access.id),
"user": access_user,
"team": "",
"role": access.role,
"abilities": access.get_abilities(user),
}
def test_api_document_accesses_update_anonymous():

View File

@@ -16,9 +16,6 @@ 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()
@@ -126,7 +123,7 @@ def test_api_document_accesses_create_authenticated_administrator(via, mock_user
document=document, team="lasuite", role="administrator"
)
other_user = factories.UserFactory(language="en-us")
other_user = factories.UserFactory()
# It should not be allowed to create an owner access
response = client.post(
@@ -202,7 +199,7 @@ def test_api_document_accesses_create_authenticated_owner(via, mock_user_teams):
document=document, team="lasuite", role="owner"
)
other_user = factories.UserFactory(language="en-us")
other_user = factories.UserFactory()
role = random.choice([role[0] for role in models.RoleChoices.choices])
@@ -238,73 +235,3 @@ 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()

View File

@@ -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(language="en-us")
user = factories.UserFactory()
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=inviting)
@@ -422,12 +422,11 @@ def test_api_document_invitations_create_privileged_members(
}
def test_api_document_invitations_create_email_from_senders_language():
def test_api_document_invitations_create_email_from_content_language():
"""
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.
The email generated is from the language set in the Content-Language header
"""
user = factories.UserFactory(language="fr-fr")
user = factories.UserFactory()
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
@@ -445,6 +444,7 @@ def test_api_document_invitations_create_email_from_senders_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,11 +464,50 @@ def test_api_document_invitations_create_email_from_senders_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="", language="en-us")
user = factories.UserFactory(full_name="")
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
@@ -560,11 +599,9 @@ def test_api_document_invitations_create_cannot_duplicate_invitation():
)
assert response.status_code == 400
assert response.json() == {
"__all__": [
"Document invitation with this Email address and Document already exists."
],
}
assert response.json() == [
"Document invitation with this Email address and Document already exists."
]
def test_api_document_invitations_create_cannot_invite_existing_users():

View File

@@ -71,8 +71,9 @@ 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="Salut"))]
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
@@ -82,15 +83,17 @@ 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. "
"Do not provide any other information. Preserve the language."
'Return JSON: {"answer": "your markdown summary"}. Do not provide any other '
"information."
),
},
{"role": "user", "content": "Hello"},
{"role": "user", "content": '{"markdown_input": "Hello"}'},
],
)
@@ -167,8 +170,9 @@ 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="Salut"))]
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
@@ -178,15 +182,16 @@ 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. Preserve the language and markdown "
"formatting. Do not provide any other information. Preserve the language."
'Answer the prompt in markdown format. Return JSON: {"answer": '
'"Your markdown answer"}. Do not provide any other information.'
),
},
{"role": "user", "content": "Hello"},
{"role": "user", "content": '{"markdown_input": "Hello"}'},
],
)
@@ -241,8 +246,9 @@ 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="Salut"))]
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
@@ -252,15 +258,16 @@ 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. Preserve the language and markdown "
"formatting. Do not provide any other information. Preserve the language."
'Answer the prompt in markdown format. Return JSON: {"answer": '
'"Your markdown answer"}. Do not provide any other information.'
),
},
{"role": "user", "content": "Hello"},
{"role": "user", "content": '{"markdown_input": "Hello"}'},
],
)
@@ -308,8 +315,9 @@ 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="Salut"))]
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
@@ -342,8 +350,9 @@ 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="Salut"))]
choices=[MagicMock(message=MagicMock(content=answer))]
)
for _ in range(3):

View File

@@ -91,28 +91,29 @@ 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="Ola"))]
choices=[MagicMock(message=MagicMock(content=answer))]
)
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": "Ola"}
assert response.json() == {"answer": "Salut"}
mock_create.assert_called_once_with(
model="llama",
response_format={"type": "json_object"},
messages=[
{
"role": "system",
"content": (
"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. "
"Translate the markdown text to Spanish, preserving markdown formatting. "
'Return JSON: {"answer": "your translated markdown text in Spanish"}. '
"Do not provide any other information."
),
},
{"role": "user", "content": "Hello"},
{"role": "user", "content": '{"markdown_input": "Hello"}'},
],
)
@@ -189,8 +190,9 @@ 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="Salut"))]
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
@@ -200,18 +202,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": (
"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. "
"Translate the markdown text to Colombian Spanish, "
"preserving markdown formatting. Return JSON: "
'{"answer": "your translated markdown text in Colombian Spanish"}. '
"Do not provide any other information."
),
},
{"role": "user", "content": "Hello"},
{"role": "user", "content": '{"markdown_input": "Hello"}'},
],
)
@@ -266,8 +268,9 @@ 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="Salut"))]
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
@@ -277,18 +280,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": (
"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. "
"Translate the markdown text to Colombian Spanish, "
"preserving markdown formatting. Return JSON: "
'{"answer": "your translated markdown text in Colombian Spanish"}. '
"Do not provide any other information."
),
},
{"role": "user", "content": "Hello"},
{"role": "user", "content": '{"markdown_input": "Hello"}'},
],
)
@@ -336,8 +339,9 @@ 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="Salut"))]
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
@@ -370,8 +374,9 @@ 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="Salut"))]
choices=[MagicMock(message=MagicMock(content=answer))]
)
for _ in range(3):

View File

@@ -67,12 +67,10 @@ def test_api_documents_attachment_upload_anonymous_success():
file_path = response.json()["file"]
match = pattern.search(file_path)
file_id = match.group(1)
# Validate that file_id is a valid UUID
uuid.UUID(file_id)
document.refresh_from_db()
assert document.attachments == [f"{document.id!s}/attachments/{file_id!s}.png"]
# Now, check the metadata of the uploaded file
key = file_path.replace("/media", "")
file_head = default_storage.connection.meta.client.head_object(
@@ -81,7 +79,6 @@ def test_api_documents_attachment_upload_anonymous_success():
assert file_head["Metadata"] == {"owner": "None"}
assert file_head["ContentType"] == "image/png"
assert file_head["ContentDisposition"] == 'inline; filename="test.png"'
@pytest.mark.parametrize(
@@ -114,9 +111,6 @@ def test_api_documents_attachment_upload_authenticated_forbidden(reach, role):
"detail": "You do not have permission to perform this action."
}
document.refresh_from_db()
assert document.attachments == []
@pytest.mark.parametrize(
"reach, role",
@@ -127,8 +121,8 @@ def test_api_documents_attachment_upload_authenticated_forbidden(reach, role):
)
def test_api_documents_attachment_upload_authenticated_success(reach, role):
"""
Autenticated users who are not related to a document should be able to upload
a file when the link reach and role permit it.
Autenticated who are not related to a document should be able to upload a file
if the link reach and role permit it.
"""
user = factories.UserFactory()
@@ -150,9 +144,6 @@ def test_api_documents_attachment_upload_authenticated_success(reach, role):
# Validate that file_id is a valid UUID
uuid.UUID(file_id)
document.refresh_from_db()
assert document.attachments == [f"{document.id!s}/attachments/{file_id!s}.png"]
@pytest.mark.parametrize("via", VIA)
def test_api_documents_attachment_upload_reader(via, mock_user_teams):
@@ -183,9 +174,6 @@ def test_api_documents_attachment_upload_reader(via, mock_user_teams):
"detail": "You do not have permission to perform this action."
}
document.refresh_from_db()
assert document.attachments == []
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
@pytest.mark.parametrize("via", VIA)
@@ -222,9 +210,6 @@ def test_api_documents_attachment_upload_success(via, role, mock_user_teams):
# Validate that file_id is a valid UUID
uuid.UUID(file_id)
document.refresh_from_db()
assert document.attachments == [f"{document.id!s}/attachments/{file_id!s}.png"]
# Now, check the metadata of the uploaded file
key = file_path.replace("/media", "")
file_head = default_storage.connection.meta.client.head_object(
@@ -232,7 +217,6 @@ def test_api_documents_attachment_upload_success(via, role, mock_user_teams):
)
assert file_head["Metadata"] == {"owner": str(user.id)}
assert file_head["ContentType"] == "image/png"
assert file_head["ContentDisposition"] == 'inline; filename="test.png"'
def test_api_documents_attachment_upload_invalid(client):
@@ -250,9 +234,6 @@ def test_api_documents_attachment_upload_invalid(client):
assert response.status_code == 400
assert response.json() == {"file": ["No file was submitted."]}
document.refresh_from_db()
assert document.attachments == []
def test_api_documents_attachment_upload_size_limit_exceeded(settings):
"""The uploaded file should not exceeed the maximum size in settings."""
@@ -275,9 +256,6 @@ def test_api_documents_attachment_upload_size_limit_exceeded(settings):
assert response.status_code == 400
assert response.json() == {"file": ["File size exceeds the maximum limit of 1 MB."]}
document.refresh_from_db()
assert document.attachments == []
@pytest.mark.parametrize(
"name,content,extension,content_type",
@@ -313,14 +291,7 @@ def test_api_documents_attachment_upload_fix_extension(
match = pattern.search(file_path)
file_id = match.group(1)
document.refresh_from_db()
assert document.attachments == [
f"{document.id!s}/attachments/{file_id!s}.{extension:s}"
]
assert "-unsafe" in file_id
# Validate that file_id is a valid UUID
file_id = file_id.replace("-unsafe", "")
uuid.UUID(file_id)
# Now, check the metadata of the uploaded file
@@ -330,7 +301,6 @@ def test_api_documents_attachment_upload_fix_extension(
)
assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"}
assert file_head["ContentType"] == content_type
assert file_head["ContentDisposition"] == f'attachment; filename="{name:s}"'
def test_api_documents_attachment_upload_empty_file():
@@ -348,9 +318,6 @@ def test_api_documents_attachment_upload_empty_file():
assert response.status_code == 400
assert response.json() == {"file": ["The submitted file is empty."]}
document.refresh_from_db()
assert document.attachments == []
def test_api_documents_attachment_upload_unsafe():
"""A file with an unsafe mime type should be tagged as such."""
@@ -373,12 +340,7 @@ def test_api_documents_attachment_upload_unsafe():
match = pattern.search(file_path)
file_id = match.group(1)
document.refresh_from_db()
assert document.attachments == [f"{document.id!s}/attachments/{file_id!s}.exe"]
assert "-unsafe" in file_id
# Validate that file_id is a valid UUID
file_id = file_id.replace("-unsafe", "")
uuid.UUID(file_id)
# Now, check the metadata of the uploaded file
@@ -388,4 +350,3 @@ def test_api_documents_attachment_upload_unsafe():
)
assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"}
assert file_head["ContentType"] == "application/octet-stream"
assert file_head["ContentDisposition"] == 'attachment; filename="script.exe"'

View File

@@ -1,5 +1,5 @@
"""
Tests for Documents API endpoint in impress's core app: children create
Tests for Documents API endpoint in impress's core app: create
"""
from uuid import uuid4

View File

@@ -1,5 +1,5 @@
"""
Tests for Documents API endpoint in impress's core app: children list
Tests for Documents API endpoint in impress's core app: retrieve
"""
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 document."""
"""Anonymous users should be allowed to retrieve the children of a public documents."""
document = factories.DocumentFactory(link_reach="public")
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1)
@@ -39,8 +39,7 @@ def test_api_documents_children_list_anonymous_public_standalone():
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 1,
"nb_accesses": 1,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
@@ -57,8 +56,7 @@ def test_api_documents_children_list_anonymous_public_standalone():
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses_ancestors": 0,
"nb_accesses_direct": 0,
"nb_accesses": 0,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
@@ -102,8 +100,7 @@ def test_api_documents_children_list_anonymous_public_parent():
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 1,
"nb_accesses": 1,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
@@ -120,8 +117,7 @@ def test_api_documents_children_list_anonymous_public_parent():
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses_ancestors": 0,
"nb_accesses_direct": 0,
"nb_accesses": 0,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
@@ -183,8 +179,7 @@ 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_ancestors": 1,
"nb_accesses_direct": 1,
"nb_accesses": 1,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
@@ -201,8 +196,7 @@ 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_ancestors": 0,
"nb_accesses_direct": 0,
"nb_accesses": 0,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
@@ -250,8 +244,7 @@ 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_ancestors": 1,
"nb_accesses_direct": 1,
"nb_accesses": 1,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
@@ -268,8 +261,7 @@ 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_ancestors": 0,
"nb_accesses_direct": 0,
"nb_accesses": 0,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
@@ -339,8 +331,7 @@ def test_api_documents_children_list_authenticated_related_direct():
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
"nb_accesses_ancestors": 3,
"nb_accesses_direct": 1,
"nb_accesses": 3,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
@@ -357,8 +348,7 @@ def test_api_documents_children_list_authenticated_related_direct():
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses_ancestors": 2,
"nb_accesses_direct": 0,
"nb_accesses": 2,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
@@ -409,8 +399,7 @@ def test_api_documents_children_list_authenticated_related_parent():
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
"nb_accesses_ancestors": 2,
"nb_accesses_direct": 1,
"nb_accesses": 2,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
@@ -427,8 +416,7 @@ def test_api_documents_children_list_authenticated_related_parent():
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 0,
"nb_accesses": 1,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
@@ -526,8 +514,7 @@ def test_api_documents_children_list_authenticated_related_team_members(
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 0,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 0,
"nb_accesses": 1,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
@@ -544,8 +531,7 @@ def test_api_documents_children_list_authenticated_related_team_members(
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 0,
"nb_accesses": 1,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),

View File

@@ -1,121 +0,0 @@
"""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

View File

@@ -1,696 +0,0 @@
"""
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],
},
],
}

View File

@@ -1,88 +0,0 @@
"""
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()

View File

@@ -1,207 +0,0 @@
"""
Test file uploads API endpoint for users in impress's core app.
"""
import base64
import uuid
from io import BytesIO
from urllib.parse import urlparse
from django.conf import settings
from django.core.files.storage import default_storage
from django.utils import timezone
import pycrdt
import pytest
import requests
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
PIXEL = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00"
b"\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\xf8\xff\xff?\x00\x05\xfe\x02\xfe"
b"\xa7V\xbd\xfa\x00\x00\x00\x00IEND\xaeB`\x82"
)
def get_image_refs(document_id):
"""Generate an image key for testing."""
image_key = f"{document_id!s}/attachments/{uuid.uuid4()!s}.png"
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=image_key,
Body=BytesIO(PIXEL),
ContentType="image/png",
)
return image_key, f"http://localhost/media/{image_key:s}"
def test_api_documents_duplicate_forbidden():
"""A user who doesn't have read access to a document should not be allowed to duplicate it."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(
link_reach="restricted",
users=[factories.UserFactory()],
title="my document",
)
response = client.post(f"/api/v1.0/documents/{document.id!s}/duplicate/")
assert response.status_code == 403
assert models.Document.objects.count() == 1
def test_api_documents_duplicate_anonymous():
"""Anonymous users should not be able to duplicate documents even with read access."""
document = factories.DocumentFactory(link_reach="public")
response = APIClient().post(f"/api/v1.0/documents/{document.id!s}/duplicate/")
assert response.status_code == 401
assert models.Document.objects.count() == 1
@pytest.mark.parametrize("index", range(3))
def test_api_documents_duplicate_success(index):
"""
Anonymous users should be able to retrieve attachments linked to a public document.
Accesses should not be duplicated if the user does not request it specifically.
Attachments that are not in the content should not be passed for access in the
duplicated document's "attachments" list.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document_ids = [uuid.uuid4() for _ in range(3)]
image_refs = [get_image_refs(doc_id) for doc_id in document_ids]
# Create document content with the first image only
ydoc = pycrdt.Doc()
fragment = pycrdt.XmlFragment(
[
pycrdt.XmlElement("img", {"src": image_refs[0][1]}),
]
)
ydoc["document-store"] = fragment
update = ydoc.get_update()
base64_content = base64.b64encode(update).decode("utf-8")
# Create documents
document = factories.DocumentFactory(
id=document_ids[index],
content=base64_content,
link_reach="restricted",
users=[user, factories.UserFactory()],
title="document with an image",
attachments=[key for key, _ in image_refs],
)
factories.DocumentFactory(id=document_ids[(index + 1) % 3])
# Don't create document for third ID to check that it doesn't impact access to attachments
# Duplicate the document via the API endpoint
response = client.post(f"/api/v1.0/documents/{document.id}/duplicate/")
assert response.status_code == 201
duplicated_document = models.Document.objects.get(id=response.json()["id"])
assert duplicated_document.title == "Copy of document with an image"
assert duplicated_document.content == document.content
assert duplicated_document.creator == user
assert duplicated_document.link_reach == "restricted"
assert duplicated_document.link_role == "reader"
assert duplicated_document.duplicated_from == document
assert duplicated_document.attachments == [
image_refs[0][0]
] # Only the first image key
assert duplicated_document.get_parent() == document.get_parent()
assert duplicated_document.path == document.get_next_sibling().path
# Check that accesses were not duplicated.
# The user who did the duplicate is forced as owner
assert duplicated_document.accesses.count() == 1
access = duplicated_document.accesses.first()
assert access.user == user
assert access.role == "owner"
# Ensure access persists after the owner loses access to the original document
models.DocumentAccess.objects.filter(document=document).delete()
response = client.get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=image_refs[0][1]
)
assert response.status_code == 200
authorization = response["Authorization"]
assert "AWS4-HMAC-SHA256 Credential=" in authorization
assert (
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
response = requests.get(
f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{image_refs[0][0]:s}",
headers={
"authorization": authorization,
"x-amz-date": response["x-amz-date"],
"x-amz-content-sha256": response["x-amz-content-sha256"],
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
},
timeout=1,
)
assert response.content == PIXEL
# Ensure the other images are not accessible
for _, url in image_refs[1:]:
response = client.get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=url
)
assert response.status_code == 403
def test_api_documents_duplicate_with_accesses():
"""Accesses should be duplicated if the user requests it specifically."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(
users=[user],
title="document with accesses",
)
user_access = factories.UserDocumentAccessFactory(document=document)
team_access = factories.TeamDocumentAccessFactory(document=document)
# Duplicate the document via the API endpoint requesting to duplicate accesses
response = client.post(
f"/api/v1.0/documents/{document.id!s}/duplicate/",
{"with_accesses": True},
format="json",
)
assert response.status_code == 201
duplicated_document = models.Document.objects.get(id=response.json()["id"])
assert duplicated_document.title == "Copy of document with accesses"
assert duplicated_document.content == document.content
assert duplicated_document.link_reach == document.link_reach
assert duplicated_document.link_role == document.link_role
assert duplicated_document.creator == user
assert duplicated_document.duplicated_from == document
assert duplicated_document.attachments == []
# Check that accesses were duplicated and the user who did the duplicate is forced as owner
duplicated_accesses = duplicated_document.accesses
assert duplicated_accesses.count() == 3
assert duplicated_accesses.get(user=user).role == "owner"
assert duplicated_accesses.get(user=user_access.user).role == user_access.role
assert duplicated_accesses.get(team=team_access.team).role == team_access.role

View File

@@ -1,80 +0,0 @@
"""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"],
}
],
}

View File

@@ -70,8 +70,7 @@ def test_api_documents_list_format():
"is_favorite": True,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses_ancestors": 3,
"nb_accesses_direct": 3,
"nb_accesses": 3,
"numchild": 0,
"path": document.path,
"title": document.title,
@@ -148,7 +147,7 @@ def test_api_documents_list_authenticated_direct(django_assert_num_queries):
str(child4_with_access.id),
}
with django_assert_num_queries(12):
with django_assert_num_queries(8):
response = client.get("/api/v1.0/documents/")
# nb_accesses should now be cached
@@ -186,7 +185,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(14):
with django_assert_num_queries(9):
response = client.get("/api/v1.0/documents/")
# nb_accesses should now be cached
@@ -219,7 +218,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(6):
with django_assert_num_queries(5):
response = client.get("/api/v1.0/documents/")
# nb_accesses should now be cached
@@ -268,7 +267,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(10):
with django_assert_num_queries(7):
response = client.get("/api/v1.0/documents/")
# nb_accesses should now be cached
@@ -329,35 +328,6 @@ 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()
@@ -392,7 +362,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(14):
with django_assert_num_queries(9):
response = client.get(url)
# nb_accesses should now be cached

View File

@@ -1,10 +1,10 @@
"""
Test media-auth authorization API endpoint in docs core app.
Test file uploads API endpoint for users in impress's core app.
"""
import uuid
from io import BytesIO
from urllib.parse import urlparse
from uuid import uuid4
from django.conf import settings
from django.core.files.storage import default_storage
@@ -14,32 +14,19 @@ import pytest
import requests
from rest_framework.test import APIClient
from core import factories, models
from core import factories
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
def test_api_documents_media_auth_unkown_document():
"""
Trying to download a media related to a document ID that does not exist
should not have the side effect to create it (no regression test).
"""
original_url = f"http://localhost/media/{uuid4()!s}/attachments/{uuid4()!s}.jpg"
response = APIClient().get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
assert models.Document.objects.exists() is False
def test_api_documents_media_auth_anonymous_public():
"""Anonymous users should be able to retrieve attachments linked to a public document"""
document_id = uuid4()
filename = f"{uuid4()!s}.jpg"
key = f"{document_id!s}/attachments/{filename:s}"
document = factories.DocumentFactory(link_reach="public")
filename = f"{uuid.uuid4()!s}.jpg"
key = f"{document.pk!s}/attachments/{filename:s}"
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=key,
@@ -47,8 +34,6 @@ def test_api_documents_media_auth_anonymous_public():
ContentType="text/plain",
)
factories.DocumentFactory(id=document_id, link_reach="public", attachments=[key])
original_url = f"http://localhost/media/{key:s}"
response = APIClient().get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
@@ -79,44 +64,16 @@ def test_api_documents_media_auth_anonymous_public():
assert response.content.decode("utf-8") == "my prose"
def test_api_documents_media_auth_extensions():
"""Files with extensions of any format should work."""
extensions = [
"c",
"go",
"gif",
"mp4",
"woff2",
"appimage",
]
document_id = uuid4()
keys = []
for ext in extensions:
filename = f"{uuid4()!s}.{ext:s}"
keys.append(f"{document_id!s}/attachments/{filename:s}")
factories.DocumentFactory(link_reach="public", attachments=keys)
for key in keys:
original_url = f"http://localhost/media/{key:s}"
response = APIClient().get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
@pytest.mark.parametrize("reach", ["authenticated", "restricted"])
def test_api_documents_media_auth_anonymous_authenticated_or_restricted(reach):
"""
Anonymous users should not be allowed to retrieve attachments linked to a document
with link reach set to authenticated or restricted.
"""
document_id = uuid4()
filename = f"{uuid4()!s}.jpg"
media_url = f"http://localhost/media/{document_id!s}/attachments/{filename:s}"
document = factories.DocumentFactory(link_reach=reach)
factories.DocumentFactory(id=document_id, link_reach=reach)
filename = f"{uuid.uuid4()!s}.jpg"
media_url = f"http://localhost/media/{document.pk!s}/attachments/{filename:s}"
response = APIClient().get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
@@ -126,79 +83,20 @@ def test_api_documents_media_auth_anonymous_authenticated_or_restricted(reach):
assert "Authorization" not in response
def test_api_documents_media_auth_anonymous_attachments():
"""
Declaring a media key as original attachment on a document to which
a user has access should give them access to the attachment file
regardless of their access rights on the original document.
"""
document_id = uuid4()
filename = f"{uuid4()!s}.jpg"
key = f"{document_id!s}/attachments/{filename:s}"
media_url = f"http://localhost/media/{key:s}"
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=key,
Body=BytesIO(b"my prose"),
ContentType="text/plain",
)
factories.DocumentFactory(id=document_id, link_reach="restricted")
response = APIClient().get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
)
assert response.status_code == 403
# Let's now add a document to which the anonymous user has access and
# pointing to the attachment
parent = factories.DocumentFactory(link_reach="public")
factories.DocumentFactory(parent=parent, link_reach="restricted", attachments=[key])
response = APIClient().get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
)
assert response.status_code == 200
authorization = response["Authorization"]
assert "AWS4-HMAC-SHA256 Credential=" in authorization
assert (
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
response = requests.get(
file_url,
headers={
"authorization": authorization,
"x-amz-date": response["x-amz-date"],
"x-amz-content-sha256": response["x-amz-content-sha256"],
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
},
timeout=1,
)
assert response.content.decode("utf-8") == "my prose"
@pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_media_auth_authenticated_public_or_authenticated(reach):
"""
Authenticated users who are not related to a document should be able to retrieve
attachments related to a document with public or authenticated link reach.
"""
document = factories.DocumentFactory(link_reach=reach)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document_id = uuid4()
filename = f"{uuid4()!s}.jpg"
key = f"{document_id!s}/attachments/{filename:s}"
media_url = f"http://localhost/media/{key:s}"
filename = f"{uuid.uuid4()!s}.jpg"
key = f"{document.pk!s}/attachments/{filename:s}"
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
@@ -207,10 +105,9 @@ def test_api_documents_media_auth_authenticated_public_or_authenticated(reach):
ContentType="text/plain",
)
factories.DocumentFactory(id=document_id, link_reach=reach, attachments=[key])
original_url = f"http://localhost/media/{key:s}"
response = client.get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
@@ -243,18 +140,14 @@ def test_api_documents_media_auth_authenticated_restricted():
Authenticated users who are not related to a document should not be allowed to
retrieve attachments linked to a document that is restricted.
"""
document = factories.DocumentFactory(link_reach="restricted")
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document_id = uuid4()
filename = f"{uuid4()!s}.jpg"
key = f"{document_id!s}/attachments/{filename:s}"
media_url = f"http://localhost/media/{key:s}"
factories.DocumentFactory(
id=document_id, link_reach="restricted", attachments=[key]
)
filename = f"{uuid.uuid4()!s}.jpg"
media_url = f"http://localhost/media/{document.pk!s}/attachments/{filename:s}"
response = client.get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
@@ -274,10 +167,16 @@ def test_api_documents_media_auth_related(via, mock_user_teams):
client = APIClient()
client.force_login(user)
document_id = uuid4()
filename = f"{uuid4()!s}.jpg"
key = f"{document_id!s}/attachments/{filename:s}"
media_url = f"http://localhost/media/{key:s}"
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
filename = f"{uuid.uuid4()!s}.jpg"
key = f"{document.pk!s}/attachments/{filename:s}"
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=key,
@@ -285,17 +184,9 @@ def test_api_documents_media_auth_related(via, mock_user_teams):
ContentType="text/plain",
)
document = factories.DocumentFactory(
id=document_id, link_reach="restricted", attachments=[key]
)
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
original_url = f"http://localhost/media/{key:s}"
response = client.get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200

View File

@@ -34,25 +34,16 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"cors_proxy": True,
"descendants": True,
"destroy": False,
"duplicate": True,
# 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,
@@ -66,8 +57,7 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"is_favorite": False,
"link_reach": "public",
"link_role": document.link_role,
"nb_accesses_ancestors": 0,
"nb_accesses_direct": 0,
"nb_accesses": 0,
"numchild": 0,
"path": document.path,
"title": document.title,
@@ -89,7 +79,6 @@ 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": {
@@ -101,21 +90,16 @@ def test_api_documents_retrieve_anonymous_public_parent():
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"descendants": True,
"cors_proxy": True,
"destroy": False,
"duplicate": True,
# 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,
@@ -129,8 +113,7 @@ def test_api_documents_retrieve_anonymous_public_parent():
"is_favorite": False,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses_ancestors": 0,
"nb_accesses_direct": 0,
"nb_accesses": 0,
"numchild": 0,
"path": document.path,
"title": document.title,
@@ -197,24 +180,15 @@ 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,
"duplicate": True,
"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,
@@ -228,8 +202,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"is_favorite": False,
"link_reach": reach,
"link_role": document.link_role,
"nb_accesses_ancestors": 0,
"nb_accesses_direct": 0,
"nb_accesses": 0,
"numchild": 0,
"path": document.path,
"title": document.title,
@@ -259,7 +232,6 @@ 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": {
@@ -271,20 +243,15 @@ 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,
"duplicate": True,
"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,
@@ -298,8 +265,7 @@ 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_ancestors": 0,
"nb_accesses_direct": 0,
"nb_accesses": 0,
"numchild": 0,
"path": document.path,
"title": document.title,
@@ -408,8 +374,7 @@ def test_api_documents_retrieve_authenticated_related_direct():
"is_favorite": False,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses_ancestors": 2,
"nb_accesses_direct": 2,
"nb_accesses": 2,
"numchild": 0,
"path": document.path,
"title": document.title,
@@ -439,7 +404,6 @@ 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": {
@@ -451,20 +415,15 @@ 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",
"duplicate": True,
"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,
@@ -478,8 +437,7 @@ def test_api_documents_retrieve_authenticated_related_parent():
"is_favorite": False,
"link_reach": "restricted",
"link_role": document.link_role,
"nb_accesses_ancestors": 2,
"nb_accesses_direct": 0,
"nb_accesses": 2,
"numchild": 0,
"path": document.path,
"title": document.title,
@@ -507,8 +465,7 @@ 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_ancestors"] == 3
assert response.json()["nb_accesses_direct"] == 1
assert response.json()["nb_accesses"] == 3
factories.UserDocumentAccessFactory(document=grand_parent)
@@ -516,8 +473,7 @@ 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_ancestors"] == 4
assert response.json()["nb_accesses_direct"] == 1
assert response.json()["nb_accesses"] == 4
def test_api_documents_retrieve_authenticated_related_child():
@@ -598,10 +554,12 @@ 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"
)
@@ -630,8 +588,7 @@ def test_api_documents_retrieve_authenticated_related_team_members(
"is_favorite": False,
"link_reach": "restricted",
"link_role": document.link_role,
"nb_accesses_ancestors": 5,
"nb_accesses_direct": 5,
"nb_accesses": 5,
"numchild": 0,
"path": document.path,
"title": document.title,
@@ -692,8 +649,7 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
"is_favorite": False,
"link_reach": "restricted",
"link_role": document.link_role,
"nb_accesses_ancestors": 5,
"nb_accesses_direct": 5,
"nb_accesses": 5,
"numchild": 0,
"path": document.path,
"title": document.title,
@@ -754,8 +710,7 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
"is_favorite": False,
"link_reach": "restricted",
"link_role": document.link_role,
"nb_accesses_ancestors": 5,
"nb_accesses_direct": 5,
"nb_accesses": 5,
"numchild": 0,
"path": document.path,
"title": document.title,
@@ -764,7 +719,7 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
}
def test_api_documents_retrieve_user_roles(django_assert_max_num_queries):
def test_api_documents_retrieve_user_roles(django_assert_num_queries):
"""
Roles should be annotated on querysets taking into account all documents ancestors.
"""
@@ -789,7 +744,7 @@ def test_api_documents_retrieve_user_roles(django_assert_max_num_queries):
)
expected_roles = {access.role for access in accesses}
with django_assert_max_num_queries(14):
with django_assert_num_queries(10):
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 200
@@ -806,7 +761,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(5):
with django_assert_num_queries(4):
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
with django_assert_num_queries(3):

View File

@@ -78,24 +78,15 @@ def test_api_documents_trashbin_format():
"children_create": True,
"children_list": True,
"collaboration_auth": True,
"descendants": True,
"cors_proxy": True,
"destroy": True,
"duplicate": 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,
@@ -107,8 +98,7 @@ def test_api_documents_trashbin_format():
"excerpt": document.excerpt,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses_ancestors": 0,
"nb_accesses_direct": 3,
"nb_accesses": 3,
"numchild": 0,
"path": document.path,
"title": document.title,
@@ -157,7 +147,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(10):
with django_assert_num_queries(7):
response = client.get("/api/v1.0/documents/trashbin/")
with django_assert_num_queries(4):
@@ -199,7 +189,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(7):
with django_assert_num_queries(5):
response = client.get("/api/v1.0/documents/trashbin/")
with django_assert_num_queries(3):

File diff suppressed because it is too large Load Diff

View File

@@ -275,8 +275,7 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
"depth",
"link_reach",
"link_role",
"nb_accesses_ancestors",
"nb_accesses_direct",
"nb_accesses",
"numchild",
"path",
]:
@@ -328,22 +327,3 @@ def test_api_documents_update_administrator_or_owner_of_another(via, mock_user_t
other_document.refresh_from_db()
other_document_values = serializers.DocumentSerializer(instance=other_document).data
assert other_document_values == old_document_values
def test_api_documents_update_invalid_content():
"""
Updating a document with a non base64 encoded content should raise a validation error.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[[user, "owner"]])
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
{"content": "invalid content"},
format="json",
)
assert response.status_code == 400
assert response.json() == {"content": ["Invalid base64 content."]}

View File

@@ -1,154 +0,0 @@
"""
Test extract-attachments on document update in docs core app.
"""
import base64
from uuid import uuid4
import pycrdt
import pytest
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
def get_ydoc_with_mages(image_keys):
"""Return a ydoc from text for testing purposes."""
ydoc = pycrdt.Doc()
fragment = pycrdt.XmlFragment(
[
pycrdt.XmlElement("img", {"src": f"http://localhost/media/{key:s}"})
for key in image_keys
]
)
ydoc["document-store"] = fragment
update = ydoc.get_update()
return base64.b64encode(update).decode("utf-8")
def test_api_documents_update_new_attachment_keys_anonymous(django_assert_num_queries):
"""
When an anonymous user updates a document, the attachment keys extracted from the
updated content should be added to the list of "attachments" ot the document if these
attachments are already readable by anonymous users.
"""
image_keys = [f"{uuid4()!s}/attachments/{uuid4()!s}.png" for _ in range(4)]
document = factories.DocumentFactory(
content=get_ydoc_with_mages(image_keys[:1]),
attachments=[image_keys[0]],
link_reach="public",
link_role="editor",
)
factories.DocumentFactory(attachments=[image_keys[1]], link_reach="public")
factories.DocumentFactory(attachments=[image_keys[2]], link_reach="authenticated")
factories.DocumentFactory(attachments=[image_keys[3]], link_reach="restricted")
expected_keys = {image_keys[i] for i in [0, 1]}
with django_assert_num_queries(9):
response = APIClient().put(
f"/api/v1.0/documents/{document.id!s}/",
{"content": get_ydoc_with_mages(image_keys)},
format="json",
)
assert response.status_code == 200
document.refresh_from_db()
assert set(document.attachments) == expected_keys
# Check that the db query to check attachments readability for extracted
# keys is not done if the content changes but no new keys are found
with django_assert_num_queries(5):
response = APIClient().put(
f"/api/v1.0/documents/{document.id!s}/",
{"content": get_ydoc_with_mages(image_keys[:2])},
format="json",
)
assert response.status_code == 200
document.refresh_from_db()
assert len(document.attachments) == 2
assert set(document.attachments) == expected_keys
def test_api_documents_update_new_attachment_keys_authenticated(
django_assert_num_queries,
):
"""
When an authenticated user updates a document, the attachment keys extracted from the
updated content should be added to the list of "attachments" ot the document if these
attachments are already readable by the editing user.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
image_keys = [f"{uuid4()!s}/attachments/{uuid4()!s}.png" for _ in range(5)]
document = factories.DocumentFactory(
content=get_ydoc_with_mages(image_keys[:1]),
attachments=[image_keys[0]],
users=[(user, "editor")],
)
factories.DocumentFactory(attachments=[image_keys[1]], link_reach="public")
factories.DocumentFactory(attachments=[image_keys[2]], link_reach="authenticated")
factories.DocumentFactory(attachments=[image_keys[3]], link_reach="restricted")
factories.DocumentFactory(attachments=[image_keys[4]], users=[user])
expected_keys = {image_keys[i] for i in [0, 1, 2, 4]}
with django_assert_num_queries(10):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
{"content": get_ydoc_with_mages(image_keys)},
format="json",
)
assert response.status_code == 200
document.refresh_from_db()
assert set(document.attachments) == expected_keys
# Check that the db query to check attachments readability for extracted
# keys is not done if the content changes but no new keys are found
with django_assert_num_queries(6):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
{"content": get_ydoc_with_mages(image_keys[:2])},
format="json",
)
assert response.status_code == 200
document.refresh_from_db()
assert len(document.attachments) == 4
assert set(document.attachments) == expected_keys
def test_api_documents_update_new_attachment_keys_duplicate():
"""
Duplicate keys in the content should not result in duplicates in the document's attachments.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
image_key1 = f"{uuid4()!s}/attachments/{uuid4()!s}.png"
image_key2 = f"{uuid4()!s}/attachments/{uuid4()!s}.png"
document = factories.DocumentFactory(
content=get_ydoc_with_mages([image_key1]),
attachments=[image_key1],
users=[(user, "editor")],
)
factories.DocumentFactory(attachments=[image_key2], users=[user])
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
{"content": get_ydoc_with_mages([image_key1, image_key2, image_key2])},
format="json",
)
assert response.status_code == 200
document.refresh_from_db()
assert len(document.attachments) == 2
assert set(document.attachments) == {image_key1, image_key2}

View File

@@ -1,47 +0,0 @@
import pytest
from core import models
@pytest.mark.django_db
def test_update_blank_title_migration(migrator):
"""
Test that the migration fixes the titles of documents that are
"Untitled document", "Unbenanntes Dokument" or "Document sans titre"
"""
old_state = migrator.apply_initial_migration(
("core", "0017_add_fields_for_soft_delete")
)
OldDocument = old_state.apps.get_model("core", "Document")
old_english_doc = OldDocument.objects.create(
title="Untitled document", depth=1, path="0000001"
)
old_german_doc = OldDocument.objects.create(
title="Unbenanntes Dokument", depth=1, path="0000002"
)
old_french_doc = OldDocument.objects.create(
title="Document sans titre", depth=1, path="0000003"
)
old_other_doc = OldDocument.objects.create(
title="My document", depth=1, path="0000004"
)
assert old_english_doc.title == "Untitled document"
assert old_german_doc.title == "Unbenanntes Dokument"
assert old_french_doc.title == "Document sans titre"
assert old_other_doc.title == "My document"
# Apply the migration
new_state = migrator.apply_tested_migration(("core", "0018_update_blank_title"))
NewDocument = new_state.apps.get_model("core", "Document")
new_english_doc = NewDocument.objects.get(pk=old_english_doc.pk)
new_german_doc = NewDocument.objects.get(pk=old_german_doc.pk)
new_french_doc = NewDocument.objects.get(pk=old_french_doc.pk)
new_other_doc = NewDocument.objects.get(pk=old_other_doc.pk)
assert new_english_doc.title == None
assert new_german_doc.title == None
assert new_french_doc.title == None
assert new_other_doc.title == "My document"

View File

@@ -1,54 +0,0 @@
import base64
import uuid
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
import pycrdt
import pytest
from core import models
@pytest.mark.django_db
def test_populate_attachments_on_all_documents(migrator):
"""Test that the migration populates attachments on existing documents."""
old_state = migrator.apply_initial_migration(
("core", "0019_alter_user_language_default_to_null")
)
OldDocument = old_state.apps.get_model("core", "Document")
old_doc_without_attachments = OldDocument.objects.create(
title="Doc without attachments", depth=1, path="0000002"
)
old_doc_with_attachments = OldDocument.objects.create(
title="Doc with attachments", depth=1, path="0000001"
)
# Create document content with an image
file_key = f"{old_doc_with_attachments.id!s}/file"
image_key = f"{old_doc_with_attachments.id!s}/attachments/{uuid.uuid4()!s}.png"
ydoc = pycrdt.Doc()
fragment = pycrdt.XmlFragment(
[pycrdt.XmlElement("img", {"src": f"http://localhost/media/{image_key:s}"})]
)
ydoc["document-store"] = fragment
update = ydoc.get_update()
base64_content = base64.b64encode(update).decode("utf-8")
bytes_content = base64_content.encode("utf-8")
content_file = ContentFile(bytes_content)
default_storage.save(file_key, content_file)
# Apply the migration
new_state = migrator.apply_tested_migration(
("core", "0020_remove_is_public_add_field_attachments_and_duplicated_from")
)
NewDocument = new_state.apps.get_model("core", "Document")
new_doc_with_attachments = NewDocument.objects.get(pk=old_doc_with_attachments.pk)
new_doc_without_attachments = NewDocument.objects.get(
pk=old_doc_without_attachments.pk
)
assert new_doc_without_attachments.attachments == []
assert new_doc_with_attachments.attachments == [image_key]

View File

@@ -33,7 +33,7 @@ def test_openapi_client_schema():
)
assert output.getvalue() == ""
response = Client().get("/api/v1.0/swagger.json")
response = Client().get("/v1.0/swagger.json")
assert response.status_code == 200
with open(

View File

@@ -39,12 +39,7 @@ def test_api_config(is_authenticated):
"CRISP_WEBSITE_ID": "123",
"ENVIRONMENT": "test",
"FRONTEND_THEME": "test-theme",
"LANGUAGES": [
["en-us", "English"],
["fr-fr", "Français"],
["de-de", "Deutsch"],
["nl-nl", "Nederlands"],
],
"LANGUAGES": [["en-us", "English"], ["fr-fr", "French"], ["de-de", "German"]],
"LANGUAGE_CODE": "en-us",
"MEDIA_BASE_URL": "http://testserver/",
"POSTHOG_KEY": {"id": "132456", "host": "https://eu.i.posthog-test.com"},

View File

@@ -24,7 +24,7 @@ def test_api_users_list_anonymous():
def test_api_users_list_authenticated():
"""
Authenticated users should not be able to list users without a query.
Authenticated users should be able to list users.
"""
user = factories.UserFactory()
@@ -37,7 +37,7 @@ def test_api_users_list_authenticated():
)
assert response.status_code == 200
content = response.json()
assert content == []
assert len(content["results"]) == 3
def test_api_users_list_query_email():
@@ -58,76 +58,24 @@ 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()]
user_ids = [user["id"] for user in response.json()["results"]]
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()]
user_ids = [user["id"] for user in response.json()["results"]]
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()]
user_ids = [user["id"] for user in response.json()["results"]]
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()
@@ -146,13 +94,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()]
user_ids = [user["id"] for user in response.json()["results"]]
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()]
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(user4.id), str(user2.id), str(user1.id), str(user5.id)]
@@ -178,50 +126,10 @@ def test_api_users_list_query_email_exclude_doc_user():
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
user_ids = [user["id"] for user in response.json()["results"]]
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)
@@ -250,7 +158,6 @@ 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,
}

View File

@@ -1,107 +0,0 @@
"""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

View File

@@ -7,7 +7,7 @@ from django.core.exceptions import ValidationError
import pytest
from core import factories, models
from core import factories
pytestmark = pytest.mark.django_db
@@ -294,7 +294,7 @@ def test_models_document_access_get_abilities_for_editor_of_owner():
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
@@ -311,7 +311,7 @@ def test_models_document_access_get_abilities_for_editor_of_administrator():
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
@@ -333,7 +333,7 @@ def test_models_document_access_get_abilities_for_editor_of_editor_user(
assert abilities == {
"destroy": False,
"retrieve": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
@@ -353,7 +353,7 @@ def test_models_document_access_get_abilities_for_reader_of_owner():
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
@@ -370,7 +370,7 @@ def test_models_document_access_get_abilities_for_reader_of_administrator():
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
@@ -392,7 +392,7 @@ def test_models_document_access_get_abilities_for_reader_of_reader_user(
assert abilities == {
"destroy": False,
"retrieve": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
@@ -412,16 +412,8 @@ def test_models_document_access_get_abilities_preset_role(django_assert_num_quer
assert abilities == {
"destroy": False,
"retrieve": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
@pytest.mark.parametrize("role", models.RoleChoices)
def test_models_document_access_get_abilities_retrieve_own_access(role):
"""Check abilities of self access for the owner of a document."""
access = factories.UserDocumentAccessFactory(role=role)
abilities = access.get_abilities(access.user)
assert abilities["retrieve"] is True

View File

@@ -1,7 +1,6 @@
"""
Unit tests for the Document model
"""
# pylint: disable=too-many-lines
import random
import smtplib
@@ -158,24 +157,15 @@ def test_models_documents_get_abilities_forbidden(
"children_create": False,
"children_list": False,
"collaboration_auth": False,
"descendants": False,
"cors_proxy": False,
"destroy": False,
"duplicate": 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,
@@ -218,24 +208,15 @@ def test_models_documents_get_abilities_reader(
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"descendants": True,
"cors_proxy": True,
"destroy": False,
"duplicate": True,
"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,
@@ -244,14 +225,9 @@ 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 key, value in document.get_abilities(user).items()
if key != "link_select_options"
)
assert all(value is False for value in document.get_abilities(user).values())
@pytest.mark.parametrize(
@@ -280,24 +256,15 @@ def test_models_documents_get_abilities_editor(
"children_create": is_authenticated,
"children_list": True,
"collaboration_auth": True,
"descendants": True,
"cors_proxy": True,
"destroy": False,
"duplicate": True,
"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,
@@ -308,11 +275,7 @@ 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 key, value in document.get_abilities(user).items()
if key != "link_select_options"
)
assert all(value is False for value in document.get_abilities(user).values())
@override_settings(
@@ -331,24 +294,15 @@ 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,
"duplicate": 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,
@@ -379,24 +333,15 @@ 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,
"duplicate": True,
"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,
@@ -407,11 +352,7 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
document.soft_delete()
document.refresh_from_db()
assert all(
value is False
for key, value in document.get_abilities(user).items()
if key != "link_select_options"
)
assert all(value is False for value in document.get_abilities(user).values())
@override_settings(
@@ -430,24 +371,15 @@ 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,
"duplicate": True,
"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,
@@ -458,11 +390,7 @@ 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 key, value in document.get_abilities(user).items()
if key != "link_select_options"
)
assert all(value is False for value in document.get_abilities(user).values())
@pytest.mark.parametrize("ai_access_setting", ["public", "authenticated", "restricted"])
@@ -488,24 +416,15 @@ 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,
"duplicate": True,
"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,
@@ -518,11 +437,7 @@ def test_models_documents_get_abilities_reader_user(
document.soft_delete()
document.refresh_from_db()
assert all(
value is False
for key, value in document.get_abilities(user).items()
if key != "link_select_options"
)
assert all(value is False for value in document.get_abilities(user).values())
def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
@@ -544,24 +459,15 @@ 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,
"duplicate": True,
"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,
@@ -730,37 +636,6 @@ 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 &quot;editor&quot; "
"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.
@@ -836,89 +711,40 @@ 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_ancestors(
def test_models_documents_nb_accesses_cache_is_set_and_retrieved(
django_assert_num_queries,
):
"""Test that nb_accesses is cached when calling nb_accesses_ancestors."""
parent = factories.DocumentFactory()
document = factories.DocumentFactory(parent=parent)
"""Test that nb_accesses is cached after the first computation."""
document = factories.DocumentFactory()
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
)
nb_accesses = random.randint(1, 4)
factories.UserDocumentAccessFactory.create_batch(nb_accesses, 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_ancestors == nb_accesses_ancestors
with django_assert_num_queries(1):
assert document.nb_accesses == nb_accesses
# Ensure that the nb_accesses is now cached
with django_assert_num_queries(0):
assert document.nb_accesses_ancestors == nb_accesses_ancestors
assert cache.get(key) == (nb_accesses_direct, nb_accesses_ancestors)
assert document.nb_accesses == nb_accesses
assert cache.get(key) == nb_accesses
# 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_ancestors == nb_accesses_ancestors + 1
assert cache.get(key) == (nb_accesses_direct + 1, nb_accesses_ancestors + 1)
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
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."""
@@ -927,425 +753,15 @@ 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 getattr(document, field) == 1
assert cache.get(key) == (1, 1)
assert document.nb_accesses == 1
assert cache.get(key) == 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(2):
new_nb_accesses = getattr(document, field)
with django_assert_num_queries(1):
new_nb_accesses = document.nb_accesses
assert new_nb_accesses == 0
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_compute_ancestors_links_no_highest_readable():
"""Test the compute_ancestors_links method."""
document = factories.DocumentFactory(link_reach="public")
assert document.compute_ancestors_links(user=AnonymousUser()) == []
def test_models_documents_compute_ancestors_links_highest_readable(
django_assert_num_queries,
):
"""Test the compute_ancestors_links method."""
user = factories.UserFactory()
other_user = factories.UserFactory()
root = factories.DocumentFactory(
link_reach="restricted", link_role="reader", users=[user]
)
factories.DocumentFactory(
parent=root, link_reach="public", link_role="reader", users=[user]
)
child2 = factories.DocumentFactory(
parent=root,
link_reach="authenticated",
link_role="editor",
users=[user, other_user],
)
child3 = factories.DocumentFactory(
parent=child2,
link_reach="authenticated",
link_role="reader",
users=[user, other_user],
)
with django_assert_num_queries(2):
assert child3.compute_ancestors_links(user=user) == [
{"link_reach": root.link_reach, "link_role": root.link_role},
{"link_reach": child2.link_reach, "link_role": child2.link_role},
]
with django_assert_num_queries(2):
assert child3.compute_ancestors_links(user=other_user) == [
{"link_reach": child2.link_reach, "link_role": child2.link_role},
]
assert cache.get(key) == 0 # Cache should now contain the new value

View File

@@ -2,6 +2,7 @@
Test ai API endpoints in the impress core app.
"""
import json
from unittest.mock import MagicMock, patch
from django.core.exceptions import ImproperlyConfigured
@@ -57,8 +58,9 @@ 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=None))]
choices=[MagicMock(message=MagicMock(content=json.dumps(answer)))]
)
with pytest.raises(
@@ -75,10 +77,49 @@ 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="Salut"))]
choices=[MagicMock(message=MagicMock(content=answer))]
)
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"}

View File

@@ -1,77 +0,0 @@
"""Test util base64_yjs_to_text."""
import base64
import uuid
import pycrdt
from core import utils
# This base64 string is an example of what is saved in the database.
# This base64 is generated from the blocknote editor, it contains
# the text \n# *Hello* \n- w**or**ld
TEST_BASE64_STRING = (
"AR717vLVDgAHAQ5kb2N1bWVudC1zdG9yZQMKYmxvY2tHcm91cAcA9e7y1Q4AAw5ibG9ja0NvbnRh"
"aW5lcgcA9e7y1Q4BAwdoZWFkaW5nBwD17vLVDgIGBgD17vLVDgMGaXRhbGljAnt9hPXu8tUOBAVI"
"ZWxsb4b17vLVDgkGaXRhbGljBG51bGwoAPXu8tUOAg10ZXh0QWxpZ25tZW50AXcEbGVmdCgA9e7y"
"1Q4CBWxldmVsAX0BKAD17vLVDgECaWQBdyQwNGQ2MjM0MS04MzI2LTQyMzYtYTA4My00ODdlMjZm"
"YWQyMzAoAPXu8tUOAQl0ZXh0Q29sb3IBdwdkZWZhdWx0KAD17vLVDgEPYmFja2dyb3VuZENvbG9y"
"AXcHZGVmYXVsdIf17vLVDgEDDmJsb2NrQ29udGFpbmVyBwD17vLVDhADDmJ1bGxldExpc3RJdGVt"
"BwD17vLVDhEGBAD17vLVDhIBd4b17vLVDhMEYm9sZAJ7fYT17vLVDhQCb3KG9e7y1Q4WBGJvbGQE"
"bnVsbIT17vLVDhcCbGQoAPXu8tUOEQ10ZXh0QWxpZ25tZW50AXcEbGVmdCgA9e7y1Q4QAmlkAXck"
"ZDM1MWUwNjgtM2U1NS00MjI2LThlYTUtYWJiMjYzMTk4ZTJhKAD17vLVDhAJdGV4dENvbG9yAXcH"
"ZGVmYXVsdCgA9e7y1Q4QD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHSH9e7y1Q4QAw5ibG9ja0Nv"
"bnRhaW5lcgcA9e7y1Q4eAwlwYXJhZ3JhcGgoAPXu8tUOHw10ZXh0QWxpZ25tZW50AXcEbGVmdCgA"
"9e7y1Q4eAmlkAXckODk3MDBjMDctZTBlMS00ZmUwLWFjYTItODQ5MzIwOWE3ZTQyKAD17vLVDh4J"
"dGV4dENvbG9yAXcHZGVmYXVsdCgA9e7y1Q4eD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQA"
)
def test_utils_base64_yjs_to_text():
"""Test extract text from saved yjs document"""
assert utils.base64_yjs_to_text(TEST_BASE64_STRING) == "Hello w or ld"
def test_utils_base64_yjs_to_xml():
"""Test extract xml from saved yjs document"""
content = utils.base64_yjs_to_xml(TEST_BASE64_STRING)
assert (
'<heading textAlignment="left" level="1"><italic>Hello</italic></heading>'
in content
or '<heading level="1" textAlignment="left"><italic>Hello</italic></heading>'
in content
)
assert (
'<bulletListItem textAlignment="left">w<bold>or</bold>ld</bulletListItem>'
in content
)
def test_utils_extract_attachments():
"""
All attachment keys in the document content should be extracted.
"""
document_id = uuid.uuid4()
image_key1 = f"{document_id!s}/attachments/{uuid.uuid4()!s}.png"
image_url1 = f"http://localhost/media/{image_key1:s}"
image_key2 = f"{uuid.uuid4()!s}/attachments/{uuid.uuid4()!s}.png"
image_url2 = f"http://localhost/{image_key2:s}"
image_key3 = f"{uuid.uuid4()!s}/attachments/{uuid.uuid4()!s}.png"
image_url3 = f"http://localhost/media/{image_key3:s}"
ydoc = pycrdt.Doc()
frag = pycrdt.XmlFragment(
[
pycrdt.XmlElement("img", {"src": image_url1}),
pycrdt.XmlElement("img", {"src": image_url2}),
pycrdt.XmlElement("p", {}, [pycrdt.XmlText(image_url3)]),
]
)
ydoc["document-store"] = frag
update = ydoc.get_update()
base64_string = base64.b64encode(update).decode("utf-8")
# image_key2 is missing the "/media/" part and shouldn't get extracted
assert utils.extract_attachments(base64_string) == [image_key1, image_key3]

View File

@@ -1,163 +0,0 @@
"""
Unit tests for the filter_root_paths utility function.
"""
from core.utils import filter_descendants
def test_utils_filter_descendants_success():
"""
The `filter_descendants` function should correctly identify descendant paths
from a given list of paths and root paths.
This test verifies that the function returns only the paths that have a prefix
matching one of the root paths.
"""
paths = [
"0001",
"00010001",
"000100010001",
"000100010002",
"000100020001",
"000100020002",
"0002",
"00020001",
"00020002",
"00030001",
"000300010001",
"00030002",
"0004",
"000400010003",
"0004000100030001",
"000400010004",
]
root_paths = [
"0001",
"0002",
"000400010003",
]
filtered_paths = filter_descendants(paths, root_paths, skip_sorting=True)
assert filtered_paths == [
"0001",
"00010001",
"000100010001",
"000100010002",
"000100020001",
"000100020002",
"0002",
"00020001",
"00020002",
"000400010003",
"0004000100030001",
]
def test_utils_filter_descendants_sorting():
"""
The `filter_descendants` function should handle unsorted input when sorting is enabled.
This test verifies that the function sorts the input if sorting is not skipped
and still correctly identifies accessible descendant paths.
"""
paths = [
"000300010001",
"000100010002",
"0001",
"00010001",
"000100010001",
"000100020002",
"000100020001",
"0002",
"00020001",
"00020002",
"00030001",
"00030002",
"0004000100030001",
"0004",
"000400010003",
"000400010004",
]
root_paths = [
"0002",
"000400010003",
"0001",
]
filtered_paths = filter_descendants(paths, root_paths)
assert filtered_paths == [
"0001",
"00010001",
"000100010001",
"000100010002",
"000100020001",
"000100020002",
"0002",
"00020001",
"00020002",
"000400010003",
"0004000100030001",
]
filtered_paths = filter_descendants(paths, root_paths, skip_sorting=True)
assert filtered_paths == [
"0001",
"00010001",
"000100010001",
"000100010002",
"000100020001",
"000100020002",
"0002",
"00020001",
"00020002",
"000400010003",
"0004000100030001",
]
def test_utils_filter_descendants_empty():
"""
The function should return an empty list if one or both inputs are empty.
"""
assert not filter_descendants([], ["0001"])
assert not filter_descendants(["0001"], [])
assert not filter_descendants([], [])
def test_utils_filter_descendants_no_match():
"""
The function should return an empty list if no path starts with any root path.
"""
paths = ["0001", "0002", "0003"]
root_paths = ["0004", "0005"]
assert not filter_descendants(paths, root_paths, skip_sorting=True)
def test_utils_filter_descendants_exact_match():
"""
The function should include paths that exactly match a root path.
"""
paths = ["0001", "0002", "0003"]
root_paths = ["0001", "0002"]
assert filter_descendants(paths, root_paths, skip_sorting=True) == ["0001", "0002"]
def test_utils_filter_descendants_single_root_matches_all():
"""
A single root path should match all its descendants.
"""
paths = ["0001", "00010001", "000100010001", "00010002"]
root_paths = ["0001"]
assert filter_descendants(paths, root_paths) == [
"0001",
"00010001",
"000100010001",
"00010002",
]
def test_utils_filter_descendants_path_shorter_than_root():
"""
A path shorter than any root path should not match.
"""
paths = ["0001", "0002"]
root_paths = ["00010001"]
assert not filter_descendants(paths, root_paths)

View File

@@ -1,76 +0,0 @@
"""Utils for the core app."""
import base64
import re
import pycrdt
from bs4 import BeautifulSoup
from core import enums
def filter_descendants(paths, root_paths, skip_sorting=False):
"""
Filters paths to keep only those that are descendants of any path in root_paths.
A path is considered a descendant of a root path if it starts with the root path.
If `skip_sorting` is not set to True, the function will sort both lists before
processing because both `paths` and `root_paths` need to be in lexicographic order
before going through the algorithm.
Args:
paths (iterable of str): List of paths to be filtered.
root_paths (iterable of str): List of paths to check as potential prefixes.
skip_sorting (bool): If True, assumes both `paths` and `root_paths` are already sorted.
Returns:
list of str: A list of sorted paths that are descendants of any path in `root_paths`.
"""
results = []
i = 0
n = len(root_paths)
if not skip_sorting:
paths.sort()
root_paths.sort()
for path in paths:
# Try to find a matching prefix in the sorted accessible paths
while i < n:
if path.startswith(root_paths[i]):
results.append(path)
break
if root_paths[i] < path:
i += 1
else:
# If paths[i] > path, no need to keep searching
break
return results
def base64_yjs_to_xml(base64_string):
"""Extract xml from base64 yjs document."""
decoded_bytes = base64.b64decode(base64_string)
# uint8_array = bytearray(decoded_bytes)
doc = pycrdt.Doc()
doc.apply_update(decoded_bytes)
return str(doc.get("document-store", type=pycrdt.XmlFragment))
def base64_yjs_to_text(base64_string):
"""Extract text from base64 yjs document."""
blocknote_structure = base64_yjs_to_xml(base64_string)
soup = BeautifulSoup(blocknote_structure, "lxml-xml")
return soup.get_text(separator=" ", strip=True)
def extract_attachments(content):
"""Helper method to extract media paths from a document's content."""
if not content:
return []
xml_content = base64_yjs_to_xml(content)
return re.findall(enums.MEDIA_STORAGE_URL_EXTRACT, xml_content)

View File

@@ -1,2 +1,2 @@
<img width="200" src="http://localhost:3000/assets/logo-gouv.png" />
<img width="200" src="https://impress-staging.beta.numerique.gouv.fr/assets/logo-gouv.png" />
<br/>

View File

@@ -7,12 +7,17 @@ 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": "user-e2e-chromium",
"email": "user@chromium.e2e",
"language": "en-us",
"username": "impress",
"email": "impress@impress.world",
},
{
"username": "user-e2e-webkit",
"email": "user@webkit.e2e",
},
{
"username": "user-e2e-firefox",
"email": "user@firefox.e2e",
},
{"username": "user-e2e-chromium", "email": "user@chromium.e2e"},
]

View File

@@ -179,8 +179,7 @@ def create_demo(stdout):
is_superuser=False,
is_active=True,
is_staff=False,
language=dev_user["language"]
or random.choice(settings.LANGUAGES)[0],
language=random.choice(settings.LANGUAGES)[0],
)
)

View File

@@ -19,7 +19,6 @@ 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__)))
@@ -211,6 +210,7 @@ class Base(Configuration):
"application/x-ms-regedit",
"application/x-msdownload",
"application/xml",
"image/svg+xml",
]
# Document versions
@@ -221,9 +221,7 @@ class Base(Configuration):
# Languages
LANGUAGE_CODE = values.Value("en-us")
# cookie & language is set from frontend
LANGUAGE_COOKIE_NAME = "docs_language"
LANGUAGE_COOKIE_PATH = "/"
LANGUAGE_COOKIE_NAME = "docs_language" # cookie & language is set from frontend
DRF_NESTED_MULTIPART_PARSER = {
# output of parser is converted to querydict
@@ -235,10 +233,9 @@ class Base(Configuration):
# fallback/default languages throughout the app.
LANGUAGES = values.SingleNestedTupleValue(
(
("en-us", "English"),
("fr-fr", "Français"),
("de-de", "Deutsch"),
("nl-nl", "Nederlands"),
("en-us", _("English")),
("fr-fr", _("French")),
("de-de", _("German")),
)
)
@@ -337,18 +334,6 @@ 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 = {
@@ -586,16 +571,14 @@ class Base(Configuration):
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"simple": {
"format": "{asctime} {name} {levelname} {message}",
"style": "{",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "simple",
"level": values.Value(
"ERROR",
environ_name="LOGGING_LEVEL_HANDLERS_CONSOLE",
environ_prefix=None,
),
},
},
# Override root logger to send it to console
@@ -618,12 +601,6 @@ 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):
@@ -670,10 +647,8 @@ class Base(Configuration):
release=get_release(),
integrations=[DjangoIntegration()],
)
sentry_sdk.set_tag("application", "backend")
# Ignore the logs added by the DockerflowMiddleware
ignore_logger("request.summary")
with sentry_sdk.configure_scope() as scope:
scope.set_extra("application", "backend")
if (
cls.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION
@@ -721,28 +696,6 @@ 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

View File

@@ -28,7 +28,7 @@ if settings.DEBUG:
if settings.USE_SWAGGER or settings.DEBUG:
urlpatterns += [
path(
f"api/{settings.API_VERSION}/swagger.json",
f"{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"api/{settings.API_VERSION}/swagger/",
f"{settings.API_VERSION}//swagger/",
SpectacularSwaggerView.as_view(url_name="client-api-schema"),
name="swagger-ui-schema",
),
re_path(
f"api/{settings.API_VERSION}/redoc/",
f"{settings.API_VERSION}//redoc/",
SpectacularRedocView.as_view(url_name="client-api-schema"),
name="redoc-schema",
),

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-03-13 11:41+0000\n"
"PO-Revision-Date: 2025-03-17 13:58\n"
"POT-Creation-Date: 2025-02-06 15:30+0000\n"
"PO-Revision-Date: 2025-02-10 14:14\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 "Baumstruktur"
msgstr ""
#: build/lib/core/api/filters.py:16 core/api/filters.py:16
msgid "Title"
msgstr "Titel"
#: 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
#: build/lib/core/api/filters.py:19 core/api/filters.py:19
msgid "Favorite"
msgstr "Favorit"
#: build/lib/core/api/serializers.py:354 core/api/serializers.py:354
#: 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
msgid "A new document was created on your behalf!"
msgstr "Ein neues Dokument wurde in Ihrem Namen erstellt!"
#: build/lib/core/api/serializers.py:358 core/api/serializers.py:358
#: build/lib/core/api/serializers.py:350 core/api/serializers.py:350
msgid "You have been granted ownership of a new document:"
msgstr "Sie sind Besitzer eines neuen Dokuments:"
#: build/lib/core/api/serializers.py:473 core/api/serializers.py:473
#: build/lib/core/api/serializers.py:453 core/api/serializers.py:453
msgid "Body"
msgstr "Inhalt"
#: build/lib/core/api/serializers.py:476 core/api/serializers.py:476
#: build/lib/core/api/serializers.py:456 core/api/serializers.py:456
msgid "Body type"
msgstr "Typ"
#: build/lib/core/api/serializers.py:482 core/api/serializers.py:482
#: build/lib/core/api/serializers.py:462 core/api/serializers.py:462
msgid "Format"
msgstr ""
@@ -78,300 +78,319 @@ msgstr "Benutzerkonto ist deaktiviert"
#: build/lib/core/enums.py:19 core/enums.py:19
msgid "First child"
msgstr "Erstes Unterelement"
msgstr ""
#: build/lib/core/enums.py:20 core/enums.py:20
msgid "Last child"
msgstr "Letztes Unterelement"
msgstr ""
#: build/lib/core/enums.py:21 core/enums.py:21
msgid "First sibling"
msgstr "Erstes Nebenelement"
msgstr ""
#: build/lib/core/enums.py:22 core/enums.py:22
msgid "Last sibling"
msgstr "Letztes Nebenelement"
msgstr ""
#: build/lib/core/enums.py:23 core/enums.py:23
msgid "Left"
msgstr "Links"
msgstr ""
#: build/lib/core/enums.py:24 core/enums.py:24
msgid "Right"
msgstr "Rechts"
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:54 build/lib/core/models.py:61 core/models.py:54
#: core/models.py:61
msgid "Reader"
msgstr "Lesen"
#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56
#: core/models.py:63
#: build/lib/core/models.py:55 build/lib/core/models.py:62 core/models.py:55
#: core/models.py:62
msgid "Editor"
msgstr "Bearbeiten"
#: build/lib/core/models.py:64 core/models.py:64
#: build/lib/core/models.py:63 core/models.py:63
msgid "Administrator"
msgstr "Administrator"
msgstr ""
#: build/lib/core/models.py:65 core/models.py:65
#: build/lib/core/models.py:64 core/models.py:64
msgid "Owner"
msgstr "Besitzer"
#: build/lib/core/models.py:76 core/models.py:76
#: build/lib/core/models.py:75 core/models.py:75
msgid "Restricted"
msgstr "Beschränkt"
#: build/lib/core/models.py:80 core/models.py:80
#: build/lib/core/models.py:79 core/models.py:79
msgid "Authenticated"
msgstr "Authentifiziert"
#: build/lib/core/models.py:82 core/models.py:82
#: build/lib/core/models.py:81 core/models.py:81
msgid "Public"
msgstr "Öffentlich"
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:103 core/models.py:103
msgid "id"
msgstr ""
#: build/lib/core/models.py:154 core/models.py:154
#: build/lib/core/models.py:104 core/models.py:104
msgid "primary key for the record as UUID"
msgstr "primärer Schlüssel für den Datensatz als UUID"
msgstr ""
#: build/lib/core/models.py:160 core/models.py:160
#: build/lib/core/models.py:110 core/models.py:110
msgid "created on"
msgstr "Erstellt"
#: build/lib/core/models.py:161 core/models.py:161
#: build/lib/core/models.py:111 core/models.py:111
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:166 core/models.py:166
#: build/lib/core/models.py:116 core/models.py:116
msgid "updated on"
msgstr "Aktualisiert"
#: build/lib/core/models.py:167 core/models.py:167
#: build/lib/core/models.py:117 core/models.py:117
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:203 core/models.py:203
#: 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 "Wir konnten keinen Benutzer mit diesem Abo finden, aber die E-Mail-Adresse ist bereits einem registrierten Benutzer zugeordnet."
msgstr ""
#: build/lib/core/models.py:216 core/models.py:216
#: build/lib/core/models.py:166 core/models.py:166
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:222 core/models.py:222
#: build/lib/core/models.py:172 core/models.py:172
msgid "sub"
msgstr "unter"
#: build/lib/core/models.py:224 core/models.py:224
#: build/lib/core/models.py:174 core/models.py:174
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:233 core/models.py:233
#: build/lib/core/models.py:183 core/models.py:183
msgid "full name"
msgstr "Name"
#: build/lib/core/models.py:234 core/models.py:234
#: build/lib/core/models.py:184 core/models.py:184
msgid "short name"
msgstr "Kurzbezeichnung"
#: build/lib/core/models.py:236 core/models.py:236
#: build/lib/core/models.py:186 core/models.py:186
msgid "identity email address"
msgstr "Identitäts-E-Mail-Adresse"
#: build/lib/core/models.py:241 core/models.py:241
#: build/lib/core/models.py:191 core/models.py:191
msgid "admin email address"
msgstr "Admin E-Mail-Adresse"
#: build/lib/core/models.py:248 core/models.py:248
#: build/lib/core/models.py:198 core/models.py:198
msgid "language"
msgstr "Sprache"
#: build/lib/core/models.py:249 core/models.py:249
#: build/lib/core/models.py:199 core/models.py:199
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:257 core/models.py:257
#: build/lib/core/models.py:205 core/models.py:205
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:260 core/models.py:260
#: build/lib/core/models.py:208 core/models.py:208
msgid "device"
msgstr "Gerät"
#: build/lib/core/models.py:262 core/models.py:262
#: build/lib/core/models.py:210 core/models.py:210
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:265 core/models.py:265
#: build/lib/core/models.py:213 core/models.py:213
msgid "staff status"
msgstr "Status des Teammitgliedes"
#: build/lib/core/models.py:267 core/models.py:267
#: build/lib/core/models.py:215 core/models.py:215
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:270 core/models.py:270
#: build/lib/core/models.py:218 core/models.py:218
msgid "active"
msgstr "aktiviert"
#: build/lib/core/models.py:273 core/models.py:273
#: 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 "Ob dieser Benutzer als aktiviert behandelt werden soll. Deaktivieren Sie diese Option, anstatt Konten zu löschen."
#: build/lib/core/models.py:285 core/models.py:285
#: build/lib/core/models.py:233 core/models.py:233
msgid "user"
msgstr "Benutzer"
#: build/lib/core/models.py:286 core/models.py:286
#: build/lib/core/models.py:234 core/models.py:234
msgid "users"
msgstr "Benutzer"
#: build/lib/core/models.py:470 build/lib/core/models.py:1074
#: core/models.py:470 core/models.py:1074
#: build/lib/core/models.py:373 build/lib/core/models.py:925 core/models.py:373
#: core/models.py:925
msgid "title"
msgstr "Titel"
#: build/lib/core/models.py:471 core/models.py:471
#: build/lib/core/models.py:374 core/models.py:374
msgid "excerpt"
msgstr "Auszug"
msgstr ""
#: build/lib/core/models.py:504 core/models.py:504
#: build/lib/core/models.py:405 core/models.py:405
msgid "Document"
msgstr "Dokument"
#: build/lib/core/models.py:505 core/models.py:505
#: build/lib/core/models.py:406 core/models.py:406
msgid "Documents"
msgstr "Dokumente"
#: build/lib/core/models.py:517 build/lib/core/models.py:826 core/models.py:517
#: core/models.py:826
#: build/lib/core/models.py:418 core/models.py:418
msgid "Untitled Document"
msgstr "Unbenanntes Dokument"
#: build/lib/core/models.py:861 core/models.py:861
#: build/lib/core/models.py:719 core/models.py:719
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} hat ein Dokument mit Ihnen geteilt!"
#: build/lib/core/models.py:865 core/models.py:865
#: build/lib/core/models.py:723 core/models.py:723
#, 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:871 core/models.py:871
#: build/lib/core/models.py:726 core/models.py:726
#, 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:969 core/models.py:969
#: 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
msgid "Document/user link trace"
msgstr "Dokument/Benutzer Linkverfolgung"
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:821 core/models.py:821
msgid "Document/user link traces"
msgstr "Dokument/Benutzer Linkverfolgung"
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:827 core/models.py:827
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:999 core/models.py:999
#: build/lib/core/models.py:850 core/models.py:850
msgid "Document favorite"
msgstr "Dokumentenfavorit"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:851 core/models.py:851
msgid "Document favorites"
msgstr "Dokumentfavoriten"
#: build/lib/core/models.py:1006 core/models.py:1006
#: 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 "Dieses Dokument ist bereits durch den gleichen Benutzer favorisiert worden."
#: build/lib/core/models.py:1028 core/models.py:1028
#: build/lib/core/models.py:879 core/models.py:879
msgid "Document/user relation"
msgstr "Dokument/Benutzerbeziehung"
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:880 core/models.py:880
msgid "Document/user relations"
msgstr "Dokument/Benutzerbeziehungen"
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:886 core/models.py:886
msgid "This user is already in this document."
msgstr "Dieser Benutzer befindet sich bereits in diesem Dokument."
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:892 core/models.py:892
msgid "This team is already in this document."
msgstr "Dieses Team befindet sich bereits in diesem Dokument."
#: build/lib/core/models.py:1047 build/lib/core/models.py:1161
#: core/models.py:1047 core/models.py:1161
#: 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 "Benutzer oder Team müssen gesetzt werden, nicht beides."
#: build/lib/core/models.py:1075 core/models.py:1075
#: build/lib/core/models.py:926 core/models.py:926
msgid "description"
msgstr "Beschreibung"
#: build/lib/core/models.py:1076 core/models.py:1076
#: build/lib/core/models.py:927 core/models.py:927
msgid "code"
msgstr "Code"
#: build/lib/core/models.py:1077 core/models.py:1077
#: build/lib/core/models.py:928 core/models.py:928
msgid "css"
msgstr "CSS"
#: build/lib/core/models.py:1079 core/models.py:1079
#: build/lib/core/models.py:930 core/models.py:930
msgid "public"
msgstr "öffentlich"
#: build/lib/core/models.py:1081 core/models.py:1081
#: build/lib/core/models.py:932 core/models.py:932
msgid "Whether this template is public for anyone to use."
msgstr "Ob diese Vorlage für jedermann öffentlich ist."
#: build/lib/core/models.py:1087 core/models.py:1087
#: build/lib/core/models.py:938 core/models.py:938
msgid "Template"
msgstr "Vorlage"
#: build/lib/core/models.py:1088 core/models.py:1088
#: build/lib/core/models.py:939 core/models.py:939
msgid "Templates"
msgstr "Vorlagen"
#: build/lib/core/models.py:1142 core/models.py:1142
#: build/lib/core/models.py:993 core/models.py:993
msgid "Template/user relation"
msgstr "Vorlage/Benutzer-Beziehung"
#: build/lib/core/models.py:1143 core/models.py:1143
#: build/lib/core/models.py:994 core/models.py:994
msgid "Template/user relations"
msgstr "Vorlage/Benutzerbeziehungen"
#: build/lib/core/models.py:1149 core/models.py:1149
#: build/lib/core/models.py:1000 core/models.py:1000
msgid "This user is already in this template."
msgstr "Dieser Benutzer ist bereits in dieser Vorlage."
#: build/lib/core/models.py:1155 core/models.py:1155
#: build/lib/core/models.py:1006 core/models.py:1006
msgid "This team is already in this template."
msgstr "Dieses Team ist bereits in diesem Template."
#: build/lib/core/models.py:1178 core/models.py:1178
#: build/lib/core/models.py:1029 core/models.py:1029
msgid "email address"
msgstr "E-Mail-Adresse"
#: build/lib/core/models.py:1197 core/models.py:1197
#: build/lib/core/models.py:1048 core/models.py:1048
msgid "Document invitation"
msgstr "Einladung zum Dokument"
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1049 core/models.py:1049
msgid "Document invitations"
msgstr "Dokumenteinladungen"
#: build/lib/core/models.py:1218 core/models.py:1218
#: build/lib/core/models.py:1069 core/models.py:1069
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"

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-03-13 11:41+0000\n"
"PO-Revision-Date: 2025-03-17 13:58\n"
"POT-Creation-Date: 2025-02-06 15:30+0000\n"
"PO-Revision-Date: 2025-02-10 14:14\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 "Title"
msgstr ""
#: 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
#: build/lib/core/api/filters.py:19 core/api/filters.py:19
msgid "Favorite"
msgstr ""
#: build/lib/core/api/serializers.py:354 core/api/serializers.py:354
#: 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
msgid "A new document was created on your behalf!"
msgstr ""
#: build/lib/core/api/serializers.py:358 core/api/serializers.py:358
#: build/lib/core/api/serializers.py:350 core/api/serializers.py:350
msgid "You have been granted ownership of a new document:"
msgstr ""
#: build/lib/core/api/serializers.py:473 core/api/serializers.py:473
#: build/lib/core/api/serializers.py:453 core/api/serializers.py:453
msgid "Body"
msgstr ""
#: build/lib/core/api/serializers.py:476 core/api/serializers.py:476
#: build/lib/core/api/serializers.py:456 core/api/serializers.py:456
msgid "Body type"
msgstr ""
#: build/lib/core/api/serializers.py:482 core/api/serializers.py:482
#: build/lib/core/api/serializers.py:462 core/api/serializers.py:462
msgid "Format"
msgstr ""
@@ -100,278 +100,297 @@ msgstr ""
msgid "Right"
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:54 build/lib/core/models.py:61 core/models.py:54
#: core/models.py:61
msgid "Reader"
msgstr ""
#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56
#: core/models.py:63
#: build/lib/core/models.py:55 build/lib/core/models.py:62 core/models.py:55
#: core/models.py:62
msgid "Editor"
msgstr ""
#: build/lib/core/models.py:64 core/models.py:64
#: build/lib/core/models.py:63 core/models.py:63
msgid "Administrator"
msgstr ""
#: build/lib/core/models.py:65 core/models.py:65
#: build/lib/core/models.py:64 core/models.py:64
msgid "Owner"
msgstr ""
#: build/lib/core/models.py:76 core/models.py:76
#: build/lib/core/models.py:75 core/models.py:75
msgid "Restricted"
msgstr ""
#: build/lib/core/models.py:80 core/models.py:80
#: build/lib/core/models.py:79 core/models.py:79
msgid "Authenticated"
msgstr ""
#: build/lib/core/models.py:82 core/models.py:82
#: build/lib/core/models.py:81 core/models.py:81
msgid "Public"
msgstr ""
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:103 core/models.py:103
msgid "id"
msgstr ""
#: build/lib/core/models.py:154 core/models.py:154
#: build/lib/core/models.py:104 core/models.py:104
msgid "primary key for the record as UUID"
msgstr ""
#: build/lib/core/models.py:160 core/models.py:160
#: build/lib/core/models.py:110 core/models.py:110
msgid "created on"
msgstr ""
#: build/lib/core/models.py:161 core/models.py:161
#: 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:166 core/models.py:166
#: build/lib/core/models.py:116 core/models.py:116
msgid "updated on"
msgstr ""
#: build/lib/core/models.py:167 core/models.py:167
#: build/lib/core/models.py:117 core/models.py:117
msgid "date and time at which a record was last updated"
msgstr ""
#: build/lib/core/models.py:203 core/models.py:203
#: 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 ""
#: build/lib/core/models.py:216 core/models.py:216
#: build/lib/core/models.py:166 core/models.py:166
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr ""
#: build/lib/core/models.py:222 core/models.py:222
#: build/lib/core/models.py:172 core/models.py:172
msgid "sub"
msgstr ""
#: build/lib/core/models.py:224 core/models.py:224
#: build/lib/core/models.py:174 core/models.py:174
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr ""
#: build/lib/core/models.py:233 core/models.py:233
#: build/lib/core/models.py:183 core/models.py:183
msgid "full name"
msgstr ""
#: build/lib/core/models.py:234 core/models.py:234
#: build/lib/core/models.py:184 core/models.py:184
msgid "short name"
msgstr ""
#: build/lib/core/models.py:236 core/models.py:236
#: build/lib/core/models.py:186 core/models.py:186
msgid "identity email address"
msgstr ""
#: build/lib/core/models.py:241 core/models.py:241
#: build/lib/core/models.py:191 core/models.py:191
msgid "admin email address"
msgstr ""
#: build/lib/core/models.py:248 core/models.py:248
#: build/lib/core/models.py:198 core/models.py:198
msgid "language"
msgstr ""
#: build/lib/core/models.py:249 core/models.py:249
#: 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:257 core/models.py:257
#: 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:260 core/models.py:260
#: build/lib/core/models.py:208 core/models.py:208
msgid "device"
msgstr ""
#: build/lib/core/models.py:262 core/models.py:262
#: 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:265 core/models.py:265
#: build/lib/core/models.py:213 core/models.py:213
msgid "staff status"
msgstr ""
#: build/lib/core/models.py:267 core/models.py:267
#: 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:270 core/models.py:270
#: build/lib/core/models.py:218 core/models.py:218
msgid "active"
msgstr ""
#: build/lib/core/models.py:273 core/models.py:273
#: 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 ""
#: build/lib/core/models.py:285 core/models.py:285
#: build/lib/core/models.py:233 core/models.py:233
msgid "user"
msgstr ""
#: build/lib/core/models.py:286 core/models.py:286
#: build/lib/core/models.py:234 core/models.py:234
msgid "users"
msgstr ""
#: build/lib/core/models.py:470 build/lib/core/models.py:1074
#: core/models.py:470 core/models.py:1074
#: build/lib/core/models.py:373 build/lib/core/models.py:925 core/models.py:373
#: core/models.py:925
msgid "title"
msgstr ""
#: build/lib/core/models.py:471 core/models.py:471
#: build/lib/core/models.py:374 core/models.py:374
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:504 core/models.py:504
#: build/lib/core/models.py:405 core/models.py:405
msgid "Document"
msgstr ""
#: build/lib/core/models.py:505 core/models.py:505
#: build/lib/core/models.py:406 core/models.py:406
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:517 build/lib/core/models.py:826 core/models.py:517
#: core/models.py:826
#: build/lib/core/models.py:418 core/models.py:418
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:861 core/models.py:861
#: build/lib/core/models.py:719 core/models.py:719
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:865 core/models.py:865
#: build/lib/core/models.py:723 core/models.py:723
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:871 core/models.py:871
#: build/lib/core/models.py:726 core/models.py:726
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:969 core/models.py:969
#: 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
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:821 core/models.py:821
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:827 core/models.py:827
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:999 core/models.py:999
#: build/lib/core/models.py:850 core/models.py:850
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:851 core/models.py:851
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1006 core/models.py:1006
#: 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:1028 core/models.py:1028
#: build/lib/core/models.py:879 core/models.py:879
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:880 core/models.py:880
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:886 core/models.py:886
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:892 core/models.py:892
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1047 build/lib/core/models.py:1161
#: core/models.py:1047 core/models.py:1161
#: 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:1075 core/models.py:1075
#: build/lib/core/models.py:926 core/models.py:926
msgid "description"
msgstr ""
#: build/lib/core/models.py:1076 core/models.py:1076
#: build/lib/core/models.py:927 core/models.py:927
msgid "code"
msgstr ""
#: build/lib/core/models.py:1077 core/models.py:1077
#: build/lib/core/models.py:928 core/models.py:928
msgid "css"
msgstr ""
#: build/lib/core/models.py:1079 core/models.py:1079
#: build/lib/core/models.py:930 core/models.py:930
msgid "public"
msgstr ""
#: build/lib/core/models.py:1081 core/models.py:1081
#: 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:1087 core/models.py:1087
#: build/lib/core/models.py:938 core/models.py:938
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1088 core/models.py:1088
#: build/lib/core/models.py:939 core/models.py:939
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1142 core/models.py:1142
#: build/lib/core/models.py:993 core/models.py:993
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1143 core/models.py:1143
#: build/lib/core/models.py:994 core/models.py:994
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1149 core/models.py:1149
#: build/lib/core/models.py:1000 core/models.py:1000
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1155 core/models.py:1155
#: build/lib/core/models.py:1006 core/models.py:1006
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1178 core/models.py:1178
#: build/lib/core/models.py:1029 core/models.py:1029
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1197 core/models.py:1197
#: build/lib/core/models.py:1048 core/models.py:1048
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1049 core/models.py:1049
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1218 core/models.py:1218
#: build/lib/core/models.py:1069 core/models.py:1069
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"

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-03-13 11:41+0000\n"
"PO-Revision-Date: 2025-03-17 13:58\n"
"POT-Creation-Date: 2025-02-06 15:30+0000\n"
"PO-Revision-Date: 2025-02-10 14:14\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 "Title"
msgstr ""
#: 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
#: build/lib/core/api/filters.py:19 core/api/filters.py:19
msgid "Favorite"
msgstr ""
#: build/lib/core/api/serializers.py:354 core/api/serializers.py:354
#: 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
msgid "A new document was created on your behalf!"
msgstr "Un nouveau document a été créé pour vous !"
#: build/lib/core/api/serializers.py:358 core/api/serializers.py:358
#: build/lib/core/api/serializers.py:350 core/api/serializers.py:350
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:473 core/api/serializers.py:473
#: build/lib/core/api/serializers.py:453 core/api/serializers.py:453
msgid "Body"
msgstr ""
#: build/lib/core/api/serializers.py:476 core/api/serializers.py:476
#: build/lib/core/api/serializers.py:456 core/api/serializers.py:456
msgid "Body type"
msgstr ""
#: build/lib/core/api/serializers.py:482 core/api/serializers.py:482
#: build/lib/core/api/serializers.py:462 core/api/serializers.py:462
msgid "Format"
msgstr ""
@@ -100,278 +100,297 @@ msgstr ""
msgid "Right"
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:54 build/lib/core/models.py:61 core/models.py:54
#: core/models.py:61
msgid "Reader"
msgstr "Lecteur"
#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56
#: core/models.py:63
#: build/lib/core/models.py:55 build/lib/core/models.py:62 core/models.py:55
#: core/models.py:62
msgid "Editor"
msgstr "Éditeur"
#: build/lib/core/models.py:64 core/models.py:64
#: build/lib/core/models.py:63 core/models.py:63
msgid "Administrator"
msgstr "Administrateur"
#: build/lib/core/models.py:65 core/models.py:65
#: build/lib/core/models.py:64 core/models.py:64
msgid "Owner"
msgstr "Propriétaire"
#: build/lib/core/models.py:76 core/models.py:76
#: build/lib/core/models.py:75 core/models.py:75
msgid "Restricted"
msgstr "Restreint"
#: build/lib/core/models.py:80 core/models.py:80
#: build/lib/core/models.py:79 core/models.py:79
msgid "Authenticated"
msgstr "Authentifié"
#: build/lib/core/models.py:82 core/models.py:82
#: build/lib/core/models.py:81 core/models.py:81
msgid "Public"
msgstr ""
#: build/lib/core/models.py:153 core/models.py:153
#: build/lib/core/models.py:103 core/models.py:103
msgid "id"
msgstr ""
#: build/lib/core/models.py:154 core/models.py:154
#: build/lib/core/models.py:104 core/models.py:104
msgid "primary key for the record as UUID"
msgstr ""
#: build/lib/core/models.py:160 core/models.py:160
#: build/lib/core/models.py:110 core/models.py:110
msgid "created on"
msgstr ""
#: build/lib/core/models.py:161 core/models.py:161
#: 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:166 core/models.py:166
#: build/lib/core/models.py:116 core/models.py:116
msgid "updated on"
msgstr ""
#: build/lib/core/models.py:167 core/models.py:167
#: build/lib/core/models.py:117 core/models.py:117
msgid "date and time at which a record was last updated"
msgstr ""
#: build/lib/core/models.py:203 core/models.py:203
#: 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 ""
#: build/lib/core/models.py:216 core/models.py:216
#: build/lib/core/models.py:166 core/models.py:166
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr ""
#: build/lib/core/models.py:222 core/models.py:222
#: build/lib/core/models.py:172 core/models.py:172
msgid "sub"
msgstr ""
#: build/lib/core/models.py:224 core/models.py:224
#: build/lib/core/models.py:174 core/models.py:174
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr ""
#: build/lib/core/models.py:233 core/models.py:233
#: build/lib/core/models.py:183 core/models.py:183
msgid "full name"
msgstr ""
#: build/lib/core/models.py:234 core/models.py:234
#: build/lib/core/models.py:184 core/models.py:184
msgid "short name"
msgstr ""
#: build/lib/core/models.py:236 core/models.py:236
#: build/lib/core/models.py:186 core/models.py:186
msgid "identity email address"
msgstr ""
#: build/lib/core/models.py:241 core/models.py:241
#: build/lib/core/models.py:191 core/models.py:191
msgid "admin email address"
msgstr ""
#: build/lib/core/models.py:248 core/models.py:248
#: build/lib/core/models.py:198 core/models.py:198
msgid "language"
msgstr ""
#: build/lib/core/models.py:249 core/models.py:249
#: 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:257 core/models.py:257
#: 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:260 core/models.py:260
#: build/lib/core/models.py:208 core/models.py:208
msgid "device"
msgstr ""
#: build/lib/core/models.py:262 core/models.py:262
#: 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:265 core/models.py:265
#: build/lib/core/models.py:213 core/models.py:213
msgid "staff status"
msgstr ""
#: build/lib/core/models.py:267 core/models.py:267
#: 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:270 core/models.py:270
#: build/lib/core/models.py:218 core/models.py:218
msgid "active"
msgstr ""
#: build/lib/core/models.py:273 core/models.py:273
#: 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 ""
#: build/lib/core/models.py:285 core/models.py:285
#: build/lib/core/models.py:233 core/models.py:233
msgid "user"
msgstr ""
#: build/lib/core/models.py:286 core/models.py:286
#: build/lib/core/models.py:234 core/models.py:234
msgid "users"
msgstr ""
#: build/lib/core/models.py:470 build/lib/core/models.py:1074
#: core/models.py:470 core/models.py:1074
#: build/lib/core/models.py:373 build/lib/core/models.py:925 core/models.py:373
#: core/models.py:925
msgid "title"
msgstr ""
#: build/lib/core/models.py:471 core/models.py:471
#: build/lib/core/models.py:374 core/models.py:374
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:504 core/models.py:504
#: build/lib/core/models.py:405 core/models.py:405
msgid "Document"
msgstr ""
#: build/lib/core/models.py:505 core/models.py:505
#: build/lib/core/models.py:406 core/models.py:406
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:517 build/lib/core/models.py:826 core/models.py:517
#: core/models.py:826
#: build/lib/core/models.py:418 core/models.py:418
msgid "Untitled Document"
msgstr "Document sans titre"
msgstr ""
#: build/lib/core/models.py:861 core/models.py:861
#: build/lib/core/models.py:719 core/models.py:719
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} a partagé un document avec vous!"
#: build/lib/core/models.py:865 core/models.py:865
#: build/lib/core/models.py:723 core/models.py:723
#, 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:871 core/models.py:871
#: build/lib/core/models.py:726 core/models.py:726
#, 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:969 core/models.py:969
#: 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
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:821 core/models.py:821
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:827 core/models.py:827
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:999 core/models.py:999
#: build/lib/core/models.py:850 core/models.py:850
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:851 core/models.py:851
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1006 core/models.py:1006
#: 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:1028 core/models.py:1028
#: build/lib/core/models.py:879 core/models.py:879
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:880 core/models.py:880
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:886 core/models.py:886
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:892 core/models.py:892
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1047 build/lib/core/models.py:1161
#: core/models.py:1047 core/models.py:1161
#: 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:1075 core/models.py:1075
#: build/lib/core/models.py:926 core/models.py:926
msgid "description"
msgstr ""
#: build/lib/core/models.py:1076 core/models.py:1076
#: build/lib/core/models.py:927 core/models.py:927
msgid "code"
msgstr ""
#: build/lib/core/models.py:1077 core/models.py:1077
#: build/lib/core/models.py:928 core/models.py:928
msgid "css"
msgstr ""
#: build/lib/core/models.py:1079 core/models.py:1079
#: build/lib/core/models.py:930 core/models.py:930
msgid "public"
msgstr ""
#: build/lib/core/models.py:1081 core/models.py:1081
#: 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:1087 core/models.py:1087
#: build/lib/core/models.py:938 core/models.py:938
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1088 core/models.py:1088
#: build/lib/core/models.py:939 core/models.py:939
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1142 core/models.py:1142
#: build/lib/core/models.py:993 core/models.py:993
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1143 core/models.py:1143
#: build/lib/core/models.py:994 core/models.py:994
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1149 core/models.py:1149
#: build/lib/core/models.py:1000 core/models.py:1000
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1155 core/models.py:1155
#: build/lib/core/models.py:1006 core/models.py:1006
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1178 core/models.py:1178
#: build/lib/core/models.py:1029 core/models.py:1029
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1197 core/models.py:1197
#: build/lib/core/models.py:1048 core/models.py:1048
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1049 core/models.py:1049
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1218 core/models.py:1218
#: build/lib/core/models.py:1069 core/models.py:1069
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"

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-03-13 11:41+0000\n"
"PO-Revision-Date: 2025-03-17 13:58\n"
"POT-Creation-Date: 2025-02-06 15:30+0000\n"
"PO-Revision-Date: 2025-02-10 14:14\n"
"Last-Translator: \n"
"Language-Team: Dutch\n"
"Language: nl_NL\n"
@@ -19,377 +19,396 @@ msgstr ""
#: build/lib/core/admin.py:37 core/admin.py:37
msgid "Personal info"
msgstr "Persoonlijke informatie"
msgstr ""
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr "Toestemmingen"
msgstr ""
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
msgstr "Belangrijke datums"
msgstr ""
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr "Document structuur"
msgstr ""
#: build/lib/core/api/filters.py:16 core/api/filters.py:16
msgid "Title"
msgstr "Titel"
#: build/lib/core/api/filters.py:30 core/api/filters.py:30
msgid "Creator is me"
msgstr "Ik ben Eigenaar"
msgstr ""
#: build/lib/core/api/filters.py:33 core/api/filters.py:33
#: build/lib/core/api/filters.py:19 core/api/filters.py:19
msgid "Favorite"
msgstr "Favoriete"
msgstr ""
#: build/lib/core/api/serializers.py:354 core/api/serializers.py:354
#: 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
msgid "A new document was created on your behalf!"
msgstr "Een nieuw document was gecreëerd voor u!"
msgstr ""
#: build/lib/core/api/serializers.py:358 core/api/serializers.py:358
#: build/lib/core/api/serializers.py:350 core/api/serializers.py:350
msgid "You have been granted ownership of a new document:"
msgstr "U heeft eigenaarschap van een nieuw document:"
msgstr ""
#: build/lib/core/api/serializers.py:473 core/api/serializers.py:473
#: build/lib/core/api/serializers.py:453 core/api/serializers.py:453
msgid "Body"
msgstr "Text"
msgstr ""
#: build/lib/core/api/serializers.py:476 core/api/serializers.py:476
#: build/lib/core/api/serializers.py:456 core/api/serializers.py:456
msgid "Body type"
msgstr "Text type"
msgstr ""
#: build/lib/core/api/serializers.py:482 core/api/serializers.py:482
#: build/lib/core/api/serializers.py:462 core/api/serializers.py:462
msgid "Format"
msgstr "Formaat"
msgstr ""
#: build/lib/core/authentication/backends.py:61
#: core/authentication/backends.py:61
msgid "Invalid response format or token verification failed"
msgstr "Invalide response formaat of token verificatie gefaald"
msgstr ""
#: build/lib/core/authentication/backends.py:108
#: core/authentication/backends.py:108
msgid "User account is disabled"
msgstr "Gebruikersaccount is buiten gebruik gesteld"
msgstr ""
#: build/lib/core/enums.py:19 core/enums.py:19
msgid "First child"
msgstr "Eerste node"
msgstr ""
#: build/lib/core/enums.py:20 core/enums.py:20
msgid "Last child"
msgstr "Laatste node"
msgstr ""
#: build/lib/core/enums.py:21 core/enums.py:21
msgid "First sibling"
msgstr "Eerste naaste"
msgstr ""
#: build/lib/core/enums.py:22 core/enums.py:22
msgid "Last sibling"
msgstr "Laatste naaste"
msgstr ""
#: build/lib/core/enums.py:23 core/enums.py:23
msgid "Left"
msgstr "Links"
msgstr ""
#: build/lib/core/enums.py:24 core/enums.py:24
msgid "Right"
msgstr "Rechts"
msgstr ""
#: build/lib/core/models.py:54 build/lib/core/models.py:61 core/models.py:54
#: core/models.py:61
msgid "Reader"
msgstr ""
#: build/lib/core/models.py:55 build/lib/core/models.py:62 core/models.py:55
#: core/models.py:62
msgid "Reader"
msgstr "Lezer"
#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56
#: core/models.py:63
msgid "Editor"
msgstr "Bewerker"
msgstr ""
#: 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 "Eigenaar"
msgstr ""
#: build/lib/core/models.py:76 core/models.py:76
#: build/lib/core/models.py:75 core/models.py:75
msgid "Restricted"
msgstr "Niet toegestaan"
msgstr ""
#: build/lib/core/models.py:80 core/models.py:80
#: build/lib/core/models.py:79 core/models.py:79
msgid "Authenticated"
msgstr "Geauthenticeerd"
msgstr ""
#: build/lib/core/models.py:82 core/models.py:82
#: build/lib/core/models.py:81 core/models.py:81
msgid "Public"
msgstr "Publiek"
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 ""
#: build/lib/core/models.py:153 core/models.py:153
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"
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
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 ".Geef een valide id. De waarde mag alleen letters, nummers en @/./.+/-/_: karakters bevatten."
msgstr ""
#: build/lib/core/models.py:222 core/models.py:222
#: build/lib/core/models.py:172 core/models.py:172
msgid "sub"
msgstr "id"
msgstr ""
#: build/lib/core/models.py:224 core/models.py:224
#: build/lib/core/models.py:174 core/models.py:174
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr "Verplicht. 255 karakters of minder. Alleen letters, nummers en @/./+/-/_/: karakters zijn toegestaan."
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 ""
#: build/lib/core/models.py:233 core/models.py:233
msgid "full name"
msgstr "volledige naam"
msgid "user"
msgstr ""
#: 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 "gebruikers"
msgstr ""
#: build/lib/core/models.py:470 build/lib/core/models.py:1074
#: core/models.py:470 core/models.py:1074
#: build/lib/core/models.py:373 build/lib/core/models.py:925 core/models.py:373
#: core/models.py:925
msgid "title"
msgstr "titel"
msgstr ""
#: build/lib/core/models.py:471 core/models.py:471
#: build/lib/core/models.py:374 core/models.py:374
msgid "excerpt"
msgstr "uittreksel"
msgstr ""
#: build/lib/core/models.py:504 core/models.py:504
#: build/lib/core/models.py:405 core/models.py:405
msgid "Document"
msgstr "Document"
msgstr ""
#: build/lib/core/models.py:505 core/models.py:505
#: build/lib/core/models.py:406 core/models.py:406
msgid "Documents"
msgstr "Documenten"
msgstr ""
#: build/lib/core/models.py:517 build/lib/core/models.py:826 core/models.py:517
#: core/models.py:826
#: build/lib/core/models.py:418 core/models.py:418
msgid "Untitled Document"
msgstr "Naamloos Document"
msgstr ""
#: build/lib/core/models.py:861 core/models.py:861
#: build/lib/core/models.py:719 core/models.py:719
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} heeft een document met gedeeld!"
msgstr ""
#: build/lib/core/models.py:865 core/models.py:865
#: build/lib/core/models.py:723 core/models.py:723
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} heeft u uitgenodigd met de rol \"{role}\" op het volgende document:"
msgstr ""
#: build/lib/core/models.py:871 core/models.py:871
#: build/lib/core/models.py:726 core/models.py:726
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} heeft een document met u gedeeld: {title}"
msgstr ""
#: build/lib/core/models.py:969 core/models.py:969
#: 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
msgid "Document/user link trace"
msgstr "Document/gebruiker url"
msgstr ""
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:821 core/models.py:821
msgid "Document/user link traces"
msgstr "Document/gebruiker url"
msgstr ""
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:827 core/models.py:827
msgid "A link trace already exists for this document/user."
msgstr "Een url bestaat al voor dit document/deze gebruiker."
msgstr ""
#: build/lib/core/models.py:999 core/models.py:999
#: build/lib/core/models.py:850 core/models.py:850
msgid "Document favorite"
msgstr "Document favoriet"
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 ""
#: build/lib/core/models.py:1000 core/models.py:1000
msgid "Document favorites"
msgstr "Document favorieten"
msgid "This user is already in this template."
msgstr ""
#: 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 "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"
msgid "This team is already in this template."
msgstr ""
#: 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 "email adres"
msgstr ""
#: build/lib/core/models.py:1197 core/models.py:1197
#: build/lib/core/models.py:1048 core/models.py:1048
msgid "Document invitation"
msgstr "Document uitnodiging"
msgstr ""
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1049 core/models.py:1049
msgid "Document invitations"
msgstr "Document uitnodigingen"
msgstr ""
#: build/lib/core/models.py:1218 core/models.py:1218
#: build/lib/core/models.py:1069 core/models.py:1069
msgid "This email is already associated to a registered user."
msgstr "Deze email is al geassocieerd met een geregistreerde gebruiker."
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"
msgstr "Logo email"
msgstr ""
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
msgid "Open"
msgstr "Open"
msgstr ""
#: 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 " Docs, jouw nieuwe essentiële tool voor het organiseren, delen en collaboreren van documenten als team. "
msgstr ""
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr " Geleverd door %(brandname)s "
msgstr ""

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "3.0.0"
version = "2.2.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,41 +25,38 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"beautifulsoup4==4.12.3",
"boto3==1.37.18",
"boto3==1.36.7",
"Brotli==1.1.0",
"celery[redis]==5.4.0",
"django-configurations==2.5.1",
"django-cors-headers==4.7.0",
"django-cors-headers==4.6.0",
"django-countries==7.6.1",
"django-filter==25.1",
"django-filter==24.3",
"django-parler==2.3",
"redis==5.2.1",
"django-redis==5.4.0",
"django-storages[s3]==1.14.5",
"django-storages[s3]==1.14.4",
"django-timezone-field>=5.1",
"django==5.1.7",
"django==5.1.5",
"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.3",
"factory_boy==3.3.1",
"gunicorn==23.0.0",
"jsonschema==4.23.0",
"lxml==5.3.1",
"markdown==3.7",
"mozilla-django-oidc==4.0.1",
"nested-multipart-parser==1.5.0",
"openai==1.68.2",
"psycopg[binary]==3.2.6",
"pycrdt==0.12.10",
"openai==1.60.2",
"psycopg[binary]==3.2.4",
"PyJWT==2.10.1",
"python-magic==0.4.27",
"requests==2.32.3",
"sentry-sdk==2.24.0",
"sentry-sdk==2.20.0",
"url-normalize==1.4.3",
"whitenoise==6.9.0",
"whitenoise==6.8.2",
"mozilla-django-oidc==4.0.1",
]
[project.urls]
@@ -71,22 +68,21 @@ dependencies = [
[project.optional-dependencies]
dev = [
"django-extensions==3.2.3",
"django-test-migrations==1.4.0",
"drf-spectacular-sidecar==2025.3.1",
"drf-spectacular-sidecar==2024.12.1",
"freezegun==1.5.1",
"ipdb==0.13.13",
"ipython==9.0.2",
"pyfakefs==5.8.0",
"ipython==8.31.0",
"pyfakefs==5.7.4",
"pylint-django==2.6.1",
"pylint==3.3.6",
"pylint==3.3.4",
"pytest-cov==6.0.0",
"pytest-django==4.10.0",
"pytest==8.3.5",
"pytest-django==4.9.0",
"pytest==8.3.4",
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.7",
"ruff==0.11.2",
"types-requests==2.32.0.20250306",
"responses==0.25.6",
"ruff==0.9.3",
"types-requests==2.32.0.20241016",
]
[tool.setuptools]
@@ -103,6 +99,7 @@ exclude = [
"build",
"venv",
"__pycache__",
"*/migrations/*",
]
line-length = 88

View File

@@ -4,4 +4,3 @@ report/
blob-report/
playwright/.auth/
playwright/.cache/
screenshots/

View File

@@ -17,13 +17,13 @@ test.describe('404', () => {
'It seems that the page you are looking for does not exist or cannot be displayed correctly.',
),
).toBeVisible();
await expect(page.getByText('Home')).toBeVisible();
await expect(page.getByText('Back to home page')).toBeVisible();
});
test('checks go back to home page redirects to home page', async ({
page,
}) => {
await page.getByText('Home').click();
await page.getByText('Back to home page').click();
await expect(page).toHaveURL('/');
});
});

View File

@@ -1,22 +0,0 @@
<html>
<head>
<title>Test unsafe file</title>
</head>
<body>
<h1>Hello svg</h1>
<img src="test.jpg" alt="test" />
<svg
xmlns="http://www.w3.org/2000/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>
</body>
</html>

View File

@@ -1,8 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 292 B

View File

@@ -1,59 +1,27 @@
import { FullConfig, FullProject, chromium, expect } from '@playwright/test';
import { test as setup } from '@playwright/test';
import { keyCloakSignIn } from './common';
const saveStorageState = async (
browserConfig: FullProject<unknown, unknown>,
) => {
const browserName = browserConfig?.name || 'chromium';
setup('authenticate-chromium', async ({ page }) => {
await page.goto('/');
await keyCloakSignIn(page, 'chromium');
await page
.context()
.storageState({ path: `playwright/.auth/user-chromium.json` });
});
const { storageState, ...useConfig } = browserConfig?.use;
const browser = await chromium.launch();
const context = await browser.newContext(useConfig);
const page = await context.newPage();
setup('authenticate-webkit', async ({ page }) => {
await page.goto('/');
await keyCloakSignIn(page, 'webkit');
await page
.context()
.storageState({ path: `playwright/.auth/user-webkit.json` });
});
try {
await page.goto('/', { waitUntil: 'networkidle' });
await page.content();
await expect(page.getByText('Docs').first()).toBeVisible();
await keyCloakSignIn(page, browserName);
await expect(
page.locator('header').first().getByRole('button', {
name: 'Logout',
}),
).toBeVisible();
await page.context().storageState({
path: storageState as string,
});
} catch (error) {
console.log(error);
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) {
/* eslint-disable @typescript-eslint/no-non-null-assertion */
const chromeConfig = config.projects.find((p) => p.name === 'chromium')!;
const firefoxConfig = config.projects.find((p) => p.name === 'firefox')!;
const webkitConfig = config.projects.find((p) => p.name === 'webkit')!;
/* eslint-enable @typescript-eslint/no-non-null-assertion */
await saveStorageState(chromeConfig);
await saveStorageState(webkitConfig);
await saveStorageState(firefoxConfig);
}
export default globalSetup;
setup('authenticate-firefox', async ({ page }) => {
await page.goto('/');
await keyCloakSignIn(page, 'firefox');
await page
.context()
.storageState({ path: `playwright/.auth/user-firefox.json` });
});

Some files were not shown because too many files have changed in this diff Show More