Compare commits

..

21 Commits

Author SHA1 Message Date
Fabre Florian 464c4ee542 WIP 2025-11-03 15:25:15 +01:00
Fabre Florian 4dd7976803 WIP 💩(front) hack to use the fulltext search api 2025-11-03 15:18:44 +01:00
Fabre Florian 923abe9110 🔧(compose) disable indexer in default configuration
Set SEARCH_INDEXER_CLASS=None as default configuration for dev.
Rename docker network 'lasuite-net' as 'lasuite' to match with Drive configuration.

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-11-03 15:18:44 +01:00
Fabre Florian 99f41edfee (backend) keep ordering from fulltext search in results
Keep ordering by score from Find API on search/ results and
fallback search still uses "-update_at" ordering as default

Refactor pagination to work with a list instead of a queryset

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-11-03 15:18:44 +01:00
Fabre Florian 73ea54d02d (backend) throttle indexation tasks instead of debounce (simplier)
Replace indexer_debounce_lock|release functions by indexer_throttle_acquire()
Instead of mutex-like mechanism, simply set a flag in cache for an amount of time
that block any other task creation.

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-11-03 15:18:43 +01:00
Fabre Florian c5ae5ac6e9 (backend) some refactor of indexer classes & modules
Rename FindDocumentIndexer as SearchIndexer
Rename FindDocumentSerializer as SearchDocumentSerializer
Rename package core.tasks.find as core.task.search
Remove logs on http errors in SearchIndexer
Factorise some code in search API view.

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-11-03 15:18:43 +01:00
Fabre Florian aab5b78c3f 🔧(backend) force a valid key for token storage in development mode
Set a valid Ferney for OIDC_STORE_REFRESH_TOKEN_KEY in env.d/development/common
Add .gitguardian.yaml configuration to ignore this key.

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-11-03 15:18:43 +01:00
Fabre Florian 4643b627fe 🔧(backend) setup Docs app dockers to work with Find
Add nginx with 'nginx' alias to the 'lasuite-net' network (keycloak calls)
Add celery-dev to the 'lasuite-net' network (Find API calls in jobs)
Set app-dev alias as 'impress' in the 'lasuite-net' network
Add indexer configuration in common settings

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-11-03 15:18:42 +01:00
Fabre Florian 9b0af6db73 🔧(backend) force a valid key for token storage in development mode
Generate a fernet key for the OIDC_STORE_REFRESH_TOKEN_KEY in development
settings if not set.

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-11-03 15:18:42 +01:00
Fabre Florian 287b0ee441 (backend) Index deleted documents
Add SEARCH_INDEXER_COUNTDOWN as configurable setting.
Make the search backend creation simplier (only 'get_document_indexer' now).
Allow indexation of deleted documents.

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-11-03 15:18:41 +01:00
Fabre Florian edc19c70a4 (backend) Index partially empty documents
Only documents without title and content are ignored by indexer.
2025-11-03 15:18:41 +01:00
Fabre Florian ba967253f5 (backend) add fallback search & default ordering
Filter deleted documents from visited ones.
Set default ordering to the Find API search call (-updated_at)
BaseDocumentIndexer.search now returns a list of document ids instead of models.
Do not call the indexer in signals when SEARCH_INDEXER_CLASS is not defined
or properly configured.

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-11-03 15:18:41 +01:00
Fabre Florian 6e13187ae6 (backend) refactor indexation signals and fix circular import issues
Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-11-03 15:18:40 +01:00
Fabre Florian 3185662a09 (backend) improve search indexer service configuration
New SEARCH_INDEXER_CLASS setting to define the indexer service class.
Raise ImpoperlyConfigured errors instead of RuntimeError in index service.

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-11-03 15:18:40 +01:00
Fabre Florian 18a2ed81b9 (backend) add document search view
New API view that calls the indexed documents search view
(resource server) of app "Find".

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-11-03 15:18:40 +01:00
Fabre Florian 94bb3e4be1 (backend) add unit test for the 'index' command
Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-11-03 15:18:39 +01:00
Fabre Florian 78f0025249 🔧(compose) Add some ignore for docker-compose local overrides
Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-11-03 15:18:39 +01:00
Samuel Paccoud - DINUM 90dffa3730 (backend) add async triggers to enable document indexation with find
On document content or permission changes, start a celery job that will call the
indexation API of the app "Find".

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-11-03 15:18:39 +01:00
Samuel Paccoud - DINUM 2d4beac82f (backend) add document search indexer
Add indexer that loops across documents in the database, formats them
as json objects and indexes them in the remote "Find" mico-service.
2025-11-03 15:18:38 +01:00
Samuel Paccoud - DINUM 3898c57c6a (backend) add dummy content to demo documents
We need to content in our demo documents so that we can test
indexing.
2025-11-03 15:18:38 +01:00
Samuel Paccoud - DINUM a5fd83dfda 🔧(compose) configure external network for communication with search
Search in Docs relies on an external project like "La Suite Find".
We need to declare a common external network in order to connect to
the search app and index our documents.
2025-11-03 15:18:37 +01:00
270 changed files with 10549 additions and 19884 deletions
+5
View File
@@ -0,0 +1,5 @@
secret:
ignored_matches:
- name:
match: "na1hhus-OLhq9mb9SO3R-8E4dONuMnqpZSY_SX8xcFk="
version: 2
+3 -3
View File
@@ -21,10 +21,10 @@ jobs:
shell: bash
run: |
set -e
HELMFILE=src/helm/helmfile.yaml.gotmpl
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")
for env in $environments; do
echo "################### $env lint ###################"
helmfile -e $env lint -f $HELMFILE || exit 1
helmfile -e $env -f $HELMFILE lint || exit 1
echo -e "\n"
done
done
+5 -5
View File
@@ -19,7 +19,7 @@ jobs:
if: github.event_name == 'pull_request' # Makes sense only for pull requests
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: show
@@ -46,7 +46,7 @@ jobs:
github.event_name == 'pull_request'
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v3
with:
fetch-depth: 50
- name: Check that the CHANGELOG has been modified in the current branch
@@ -56,7 +56,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v2
- name: Check CHANGELOG max line length
run: |
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
@@ -70,7 +70,7 @@ jobs:
if: github.event_name == 'pull_request'
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v2
- name: Install codespell
run: pip install --user codespell
- name: Check for typos
@@ -92,7 +92,7 @@ jobs:
working-directory: src/backend
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v2
- name: Install Python
uses: actions/setup-python@v5
with:
+4
View File
@@ -43,6 +43,10 @@ venv.bak/
env.d/development/*.local
env.d/terraform
# Docker
compose.override.yml
docker/auth/*.local
# npm
node_modules
+8 -69
View File
@@ -8,84 +8,22 @@ and this project adheres to
### Added
- ✨ Add comments feature to the editor #1330
- ✨(backend) Comments on text editor #1330
### Changed
- ⚡️(sw) stop to cache external resources likes videos #1655
- 💥(frontend) upgrade to ui-kit v2
- ⚡️(frontend) improve perf on upload and table of contents #1662
### Fixed
- ♿(frontend) improve accessibility:
- ♿(frontend) improve share modal button accessibility #1626
- ♿(frontend) improve screen reader support in DocShare modal #1628
- 🐛(frontend) fix toolbar not activated when reader #1640
- 🐛(frontend) preserve left panel width on window resize #1588
- 🐛(frontend) prevent duplicate as first character in title #1595
## [3.10.0] - 2025-11-18
### Added
- ✨(export) enable ODT export for documents #1524
- ✨(frontend) improve mobile UX by showing subdocs count #1540
- ✅(e2e) add test to compare generated PDF against reference template #1648
### Changed
- ♻️(frontend) preserve @ character when esc is pressed after typing it #1512
- ♻️(frontend) make summary button fixed to remain visible during scroll #1581
- ♻️(frontend) pdf embed use full width #1526
### Fixed
- ♿(frontend) improve accessibility:
- ♿(frontend) improve ARIA in doc grid and editor for a11y #1519
- ♿(frontend) improve accessibility and styling of summary table #1528
- ♿(frontend) add focus trap and enter key support to remove doc modal #1531
- 🐛(frontend) fix alignment of side menu #1597
- 🐛(frontend) fix fallback translations with Trans #1620
- 🐛(export) fix image overflow by limiting width to 600px during export #1525
- 🐛(export) fix table cell alignment issue in exported documents #1582
- 🐛(export) preserve image aspect ratio in PDF export #1622
- 🐛(export) Export fails when paste with style #1552
### Security
- mitigate role escalation in the ask_for_access viewset #1580
### Removed
- 🔥(backend) remove api managing templates
## [3.9.0] - 2025-11-10
### Added
- ✨(frontend) create skeleton component for DocEditor #1491
- ✨(frontend) add an EmojiPicker in the document tree and title #1381
- ✨(frontend) ajustable left panel #1456
### Changed
- ♻️(frontend) adapt custom blocks to new implementation #1375
- ♻️(backend) increase user short_name field length #1510
- 🚸(frontend) separate viewers from editors #1509
- ♻️(backend) increase user short_name field length
### Fixed
- 🐛(frontend) fix duplicate document entries in grid #1479
- 🐛(backend) fix trashbin list #1520
- 🐛(frontend) show full nested doc names with ajustable bar #1456
- 🐛(backend) fix trashbin list
- ♿(frontend) improve accessibility:
- ♿(frontend) remove empty alt on logo due to Axe a11y error #1516
- 🐛(backend) fix s3 version_id validation #1543
- 🐛(frontend) retry check media status after page reload #1555
- 🐛(frontend) fix Interlinking memory leak #1560
- 🐛(frontend) button new doc UI fix #1557
- 🐛(frontend) interlinking UI fix #1557
- 🐛(backend) fix s3 version_id validation
## [3.8.2] - 2025-10-17
@@ -136,6 +74,9 @@ and this project adheres to
- ♿ update labels and shared document icon accessibility #1442
- 🍱(frontend) Fonts GDPR compliants #1453
- ♻️(service-worker) improve SW registration and update handling #1473
- ✨(backend) add async indexation of documents on save (or access save) #1276
- ✨(backend) add debounce mechanism to limit indexation jobs #1276
- ✨(api) add API route to search for indexed documents in Find #1276
### Fixed
@@ -898,9 +839,7 @@ and this project adheres to
- ✨(frontend) Coming Soon page (#67)
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/suitenumerique/docs/compare/v3.10.0...main
[v3.10.0]: https://github.com/suitenumerique/docs/releases/v3.10.0
[v3.9.0]: https://github.com/suitenumerique/docs/releases/v3.9.0
[unreleased]: https://github.com/suitenumerique/docs/compare/v3.8.2...main
[v3.8.2]: https://github.com/suitenumerique/docs/releases/v3.8.2
[v3.8.1]: https://github.com/suitenumerique/docs/releases/v3.8.1
[v3.8.0]: https://github.com/suitenumerique/docs/releases/v3.8.0
+4
View File
@@ -247,6 +247,10 @@ demo: ## flush db then create a demo for load testing purpose
@$(MANAGE) create_demo
.PHONY: demo
index: ## index all documents to remote search
@$(MANAGE) index
.PHONY: index
# Nota bene: Black should come after isort just in case they don't agree...
lint: ## lint back-end python sources
lint: \
Executable
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# shellcheck source=bin/_config.sh
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
_dc_run app-dev python -c 'from cryptography.fernet import Fernet;import sys; sys.stdout.write("\n" + Fernet.generate_key().decode() + "\n");'
+18
View File
@@ -72,6 +72,11 @@ services:
- env.d/development/postgresql.local
ports:
- "8071:8000"
networks:
default: {}
lasuite:
aliases:
- impress
volumes:
- ./src/backend:/app
- ./data/static:/data/static
@@ -92,6 +97,9 @@ services:
command: ["celery", "-A", "impress.celery_app", "worker", "-l", "DEBUG"]
environment:
- DJANGO_CONFIGURATION=Development
networks:
- default
- lasuite
env_file:
- env.d/development/common
- env.d/development/common.local
@@ -107,6 +115,11 @@ services:
image: nginx:1.25
ports:
- "8083:8083"
networks:
default: {}
lasuite:
aliases:
- nginx
volumes:
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
depends_on:
@@ -217,3 +230,8 @@ services:
kc_postgresql:
condition: service_healthy
restart: true
networks:
lasuite:
name: lasuite-network
driver: bridge
+1
View File
@@ -12,6 +12,7 @@ flowchart TD
Back --> DB("Database (PostgreSQL)")
Back <--> Celery --> DB
Back ----> S3("Minio (S3)")
Back -- REST API --> Find
```
### Architecture decision records
+6
View File
@@ -93,6 +93,12 @@ These are the environment variables you can set for the `impress-backend` contai
| OIDC_USERINFO_SHORTNAME_FIELD | OIDC token claims to create shortname | first_name |
| POSTHOG_KEY | Posthog key for analytics | |
| REDIS_URL | Cache url | redis://redis:6379/1 |
| SEARCH_INDEXER_CLASS | Class of the backend for document indexation & search | |
| SEARCH_INDEXER_BATCH_SIZE | Size of each batch for indexation of all documents | 100000 |
| SEARCH_INDEXER_COUNTDOWN | Minimum debounce delay of indexation jobs (in seconds) | 1 |
| SEARCH_INDEXER_URL | Find application endpoint for indexation | |
| SEARCH_INDEXER_SECRET | Token for indexation queries | |
| SEARCH_INDEXER_QUERY_URL | Find application endpoint for search | |
| SENTRY_DSN | Sentry host | |
| SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 |
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | | false |
+217
View File
@@ -0,0 +1,217 @@
# Setup full-text search with Find
This configuration will enable the fulltext search feature for Docs :
- Each save on **core.Document** or **core.DocumentAccess** will trigger the indexer
- The `api/v1.0/documents/search/` will work as a proxy with the Find API for fulltext search.
## Create an index service for Docs
In Find application Django admin configure a **Service** with these settings
- **Name**: `docs`<br>_request.auth.name of the Docs application._
- **Client id**: `impress`<br>_Name of the token audience or client_id of the Docs application._
## Configure settings
Add those Django settings to enable the feature.
```shell
SEARCH_INDEXER_CLASS="core.services.search_indexers.FindDocumentIndexer"
SEARCH_INDEXER_COUNTDOWN=10 # Debounce delay in seconds for the index calls.
# Indexation endpoint.
SEARCH_INDEXER_SECRET=my-token-from-the-find-impress-service
# The token from service "docs" of Find application.
SEARCH_INDEXER_URL="http://app-find:8000/api/v1.0/documents/index/"
# Search endpoint. Uses the OIDC token for authentication
SEARCH_INDEXER_QUERY_URL="http://app-find:8000/api/v1.0/documents/search/"
```
We also need to enable the **OIDC Token** refresh or the authentication will fail quickly.
```shell
# Store OIDC tokens in the session
OIDC_STORE_ACCESS_TOKEN = True # Store the access token in the session
OIDC_STORE_REFRESH_TOKEN = True # Store the encrypted refresh token in the session
OIDC_STORE_REFRESH_TOKEN_KEY = "your-32-byte-encryption-key==" # Must be a valid Fernet key (32 url-safe base64-encoded bytes)
```
# Installation with docker compose
First see [compose installation](installation/compose.md) documentation.
## 1. Postgresql
Find uses PostgreSQL as its database.
If you are using the example provided, you need to generate a secure key for `DB_PASSWORD` and set it in `env.d/find_postgresql`.
```shell
# Postgresql db container configuration
POSTGRES_DB=find
POSTGRES_USER=dinum
POSTGRES_PASSWORD=pass
# App database configuration
DB_HOST=postgresql-find
DB_NAME=find
DB_USER=dinum
DB_PASSWORD=<password>
DB_PORT=5432
```
And the service can be configured in `compose.yml`.
```yaml
postgresql-find:
image: postgres:15
env_file:
- env.d/development/find_postgresql
- env.d/development/find_postgresql.local
ports:
- "15433:5432"
```
## 2. Opensearch
Find uses Opensearch as its indexation database.
If you are using the example provided, you need to generate a secure key for `OPENSEARCH_INITIAL_ADMIN_PASSWORD` and `OPENSEARCH_PASSWORD`
to set them in `env.d/opensearch`.
```shell
OPENSEARCH_INITIAL_ADMIN_PASSWORD=find
OPENSEARCH_PASSWORD=find
OPENSEARCH_USE_SSL=true # false in Development mode
# Dashboard
OPENSEARCH_HOSTS=["http://opensearch:9200"]
DISABLE_SECURITY_DASHBOARDS_PLUGIN=false # true in Development mode
```
And the service & dashboard can be configured in `compose.yml`.
```yaml
opensearch:
user: ${DOCKER_USER:-1000}
image: opensearchproject/opensearch:latest
env_file:
- env.d/development/opensearch
- env.d/development/opensearch.local
environment:
- discovery.type=single-node
- plugins.security.disabled=true
- plugins.security.ssl.http.enabled=false
ulimits:
memlock:
soft: -1
hard: -1
volumes:
- ./data/opensearch:/usr/share/opensearch/data
ports:
- "9200:9200"
- "9600:9600"
opensearch-dashboards:
image: opensearchproject/opensearch-dashboards:latest
ports:
- "5601:5601"
env_file:
- env.d/development/opensearch
- env.d/development/opensearch.local
depends_on:
- opensearch
```
## 3. Find (dev mode)
If you are using the example provided, you need to generate a secure key for `OIDC_RS_CLIENT_SECRET` and set it in `env.d/find`.
```shell
# Django
DJANGO_ALLOWED_HOSTS=*
DJANGO_SECRET_KEY=ThisIsAnExampleKeyForDevPurposeOnly
DJANGO_SETTINGS_MODULE=find.settings
DJANGO_SUPERUSER_PASSWORD=admin
# Python
PYTHONPATH=/app
# find settings
# Backend url
FIND_BASE_URL="http://localhost:9072"
# OIDC
OIDC_OP_URL=http://localhost:8083/realms/impress
OIDC_OP_INTROSPECTION_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/token/introspect
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/certs
OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8083/realms/impress/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/userinfo
OIDC_RP_CLIENT_ID=impress
OIDC_RP_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
OIDC_RP_SIGN_ALGO=RS256
OIDC_RP_SCOPES="openid email"
OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
# OIDC Resource server
OIDC_RS_SCOPES="openid"
OIDC_RS_CLIENT_ID=impress
OIDC_RS_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
OIDC_RS_SIGN_ALGO=RS256
OIDC_RS_BACKEND_CLASS="core.authentication.FinderResourceServerBackend"
```
And Find can be configured in `compose.yml`.
```yaml
find-dev:
user: ${DOCKER_USER:-1000}
image: find:backend-development
environment:
- PYLINTHOME=/app/.pylint.d
- DJANGO_CONFIGURATION=Development
env_file:
- env.d/development/find
- env.d/development/find.local
- env.d/development/find_postgresql
- env.d/development/find_postgresql.local
- env.d/development/opensearch
- env.d/development/opensearch.local
ports:
- "9071:8000"
volumes:
- ../find/src/backend:/app
- ../find/data/static:/data/static
depends_on:
- find_postgresql
- opensearch
- redis
```
## Ports (dev defaults)
| Port | Service |
| --------- | --------------------- |
| 3000 | Next.js |
| 8071 | Django (main) |
| 9071 | Django (Find) |
| 4444 | Y-Provider |
| 8080 | Keycloak |
| 8083 | Nginx proxy |
| 9000/9001 | MinIO |
| 15432 | PostgreSQL (main) |
| 15433 | PostgreSQL (Find) |
| 5433 | PostgreSQL (Keycloak) |
| 1081 | MailCatcher |
| 9200 | Opensearch |
| 9600 | Opensearch admin |
| 5601 | Opensearch dashboard |
+4 -4
View File
@@ -32,9 +32,9 @@ Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom
----
# **Your Docs icon** 📝
# **Your logo** 📝
You can add your own Docs icon in the header from the theme customization file.
You can add your own logo in the header from the theme customization file.
### Settings 🔧
@@ -44,9 +44,9 @@ THEME_CUSTOMIZATION_FILE_PATH=<path>
### Example of JSON
You can activate it with the `header.icon` configuration: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
You can activate it with the `header.logo` configuration: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
This configuration is optional. If not set, the default icon will be used.
This configuration is optional. If not set, the default logo will be used.
----
+16 -1
View File
@@ -36,6 +36,7 @@ OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/c
OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8083/realms/impress/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/userinfo
OIDC_OP_INTROSPECTION_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/token/introspect
OIDC_RP_CLIENT_ID=impress
OIDC_RP_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
@@ -49,6 +50,14 @@ LOGOUT_REDIRECT_URL=http://localhost:3000
OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
# Store OIDC tokens in the session
OIDC_STORE_ACCESS_TOKEN = True
OIDC_STORE_REFRESH_TOKEN = True # Store the encrypted refresh token in the session.
# Must be a valid Fernet key (32 url-safe base64-encoded bytes)
# To create one, use the bin/fernetkey command.
OIDC_STORE_REFRESH_TOKEN_KEY = "na1hhus-OLhq9mb9SO3R-8E4dONuMnqpZSY_SX8xcFk="
# AI
AI_FEATURE_ENABLED=true
AI_BASE_URL=https://openaiendpoint.com
@@ -68,4 +77,10 @@ Y_PROVIDER_API_BASE_URL=http://y-provider-development:4444/api/
Y_PROVIDER_API_KEY=yprovider-api-key
# Theme customization
THEME_CUSTOMIZATION_CACHE_TIMEOUT=15
THEME_CUSTOMIZATION_CACHE_TIMEOUT=15
# Indexer (disabled)
# SEARCH_INDEXER_CLASS="core.services.search_indexers.SearchIndexer"
SEARCH_INDEXER_SECRET=find-api-key-for-docs-with-exactly-50-chars-length # Key generated by create_demo in Find app.
SEARCH_INDEXER_URL="http://find:8000/api/v1.0/documents/index/"
SEARCH_INDEXER_QUERY_URL="http://find:8000/api/v1.0/documents/search/"
-16
View File
@@ -171,19 +171,3 @@ class ResourceAccessPermission(IsAuthenticated):
action = view.action
return abilities.get(action, False)
class CommentPermission(permissions.BasePermission):
"""Permission class for comments."""
def has_permission(self, request, view):
"""Check permission for a given object."""
if view.action in ["create", "list"]:
document_abilities = view.get_document_or_404().get_abilities(request.user)
return document_abilities["comment"]
return True
def has_object_permission(self, request, view, obj):
"""Check permission for a given object."""
return obj.get_abilities(request.user).get(view.action, False)
+21 -135
View File
@@ -1,5 +1,4 @@
"""Client serializers for the impress core app."""
# pylint: disable=too-many-lines
import binascii
import mimetypes
@@ -25,13 +24,22 @@ 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."""
full_name = serializers.SerializerMethodField(read_only=True)
short_name = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.User
fields = ["id", "email", "full_name", "short_name", "language"]
read_only_fields = ["id", "email", "full_name", "short_name"]
fields = ["full_name", "short_name"]
read_only_fields = ["full_name", "short_name"]
def get_full_name(self, instance):
"""Return the full name of the user."""
@@ -50,15 +58,6 @@ class UserSerializer(serializers.ModelSerializer):
return instance.short_name
class UserLightSerializer(UserSerializer):
"""Serialize users with limited fields."""
class Meta:
model = models.User
fields = ["full_name", "short_name"]
read_only_fields = ["full_name", "short_name"]
class TemplateAccessSerializer(serializers.ModelSerializer):
"""Serialize template accesses."""
@@ -787,9 +786,7 @@ class DocumentAskForAccessCreateSerializer(serializers.Serializer):
"""Serializer for creating a document ask for access."""
role = serializers.ChoiceField(
choices=[
role for role in choices.RoleChoices if role != models.RoleChoices.OWNER
],
choices=models.RoleChoices.choices,
required=False,
default=models.RoleChoices.READER,
)
@@ -813,11 +810,11 @@ class DocumentAskForAccessSerializer(serializers.ModelSerializer):
]
read_only_fields = ["id", "document", "user", "role", "created_at", "abilities"]
def get_abilities(self, instance) -> dict:
def get_abilities(self, invitation) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
return instance.get_abilities(request.user)
return invitation.get_abilities(request.user)
return {}
@@ -894,122 +891,11 @@ class MoveDocumentSerializer(serializers.Serializer):
)
class ReactionSerializer(serializers.ModelSerializer):
"""Serialize reactions."""
class SearchDocumentSerializer(serializers.Serializer):
"""Serializer for fulltext search requests through Find application"""
users = UserLightSerializer(many=True, read_only=True)
class Meta:
model = models.Reaction
fields = [
"id",
"emoji",
"created_at",
"users",
]
read_only_fields = ["id", "created_at", "users"]
class CommentSerializer(serializers.ModelSerializer):
"""Serialize comments (nested under a thread) with reactions and abilities."""
user = UserLightSerializer(read_only=True)
abilities = serializers.SerializerMethodField()
reactions = ReactionSerializer(many=True, read_only=True)
class Meta:
model = models.Comment
fields = [
"id",
"user",
"body",
"created_at",
"updated_at",
"reactions",
"abilities",
]
read_only_fields = [
"id",
"user",
"created_at",
"updated_at",
"reactions",
"abilities",
]
def validate(self, attrs):
"""Validate comment data."""
request = self.context.get("request")
user = getattr(request, "user", None)
attrs["thread_id"] = self.context["thread_id"]
attrs["user_id"] = user.id if user else None
return attrs
def get_abilities(self, obj):
"""Return comment's abilities."""
request = self.context.get("request")
if request:
return obj.get_abilities(request.user)
return {}
class ThreadSerializer(serializers.ModelSerializer):
"""Serialize threads in a backward compatible shape for current frontend.
We expose a flatten representation where ``content`` maps to the first
comment's body. Creating a thread requires a ``content`` field which is
stored as the first comment.
"""
creator = UserLightSerializer(read_only=True)
abilities = serializers.SerializerMethodField(read_only=True)
body = serializers.JSONField(write_only=True, required=True)
comments = serializers.SerializerMethodField(read_only=True)
comments = CommentSerializer(many=True, read_only=True)
class Meta:
model = models.Thread
fields = [
"id",
"body",
"created_at",
"updated_at",
"creator",
"abilities",
"comments",
"resolved",
"resolved_at",
"resolved_by",
"metadata",
]
read_only_fields = [
"id",
"created_at",
"updated_at",
"creator",
"abilities",
"comments",
"resolved",
"resolved_at",
"resolved_by",
"metadata",
]
def validate(self, attrs):
"""Validate thread data."""
request = self.context.get("request")
user = getattr(request, "user", None)
attrs["document_id"] = self.context["resource_id"]
attrs["creator_id"] = user.id if user else None
return attrs
def get_abilities(self, thread):
"""Return thread's abilities."""
request = self.context.get("request")
if request:
return thread.get_abilities(request.user)
return {}
q = serializers.CharField(required=True, allow_blank=False, trim_whitespace=True)
page_size = serializers.IntegerField(
required=False, min_value=1, max_value=50, default=20
)
page = serializers.IntegerField(required=False, min_value=1, default=1)
+215 -142
View File
@@ -14,6 +14,7 @@ from django.contrib.postgres.search import TrigramSimilarity
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.core.paginator import InvalidPage, Paginator
from django.core.validators import URLValidator
from django.db import connection, transaction
from django.db import models as db
@@ -21,7 +22,7 @@ from django.db.models.expressions import RawSQL
from django.db.models.functions import Left, Length
from django.http import Http404, StreamingHttpResponse
from django.urls import reverse
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.utils.functional import cached_property
from django.utils.text import capfirst, slugify
from django.utils.translation import gettext_lazy as _
@@ -32,9 +33,11 @@ from botocore.exceptions import ClientError
from csp.constants import NONE
from csp.decorators import csp_update
from lasuite.malware_detection import malware_detection
from lasuite.oidc_login.decorators import refresh_oidc_access_token
from rest_framework import filters, status, viewsets
from rest_framework import response as drf_response
from rest_framework.permissions import AllowAny
from rest_framework.utils.urls import replace_query_param as drf_replace_query_param
from core import authentication, choices, enums, models
from core.services.ai_services import AIService
@@ -48,6 +51,10 @@ from core.services.converter_services import (
from core.services.converter_services import (
YdocConverter,
)
from core.services.search_indexers import (
get_document_indexer,
get_visited_document_ids_of,
)
from core.tasks.mail import send_ask_for_access_mail
from core.utils import extract_attachments, filter_descendants
@@ -374,6 +381,7 @@ class DocumentViewSet(
list_serializer_class = serializers.ListDocumentSerializer
trashbin_serializer_class = serializers.ListDocumentSerializer
tree_serializer_class = serializers.ListDocumentSerializer
search_serializer_class = serializers.ListDocumentSerializer
def get_queryset(self):
"""Get queryset performing all annotation and filtering on the document tree structure."""
@@ -1065,6 +1073,114 @@ class DocumentViewSet(
{"id": str(duplicated_document.id)}, status=status.HTTP_201_CREATED
)
def _search_simple(self, request, text):
"""
Returns a queryset filtered by the content of the document title
"""
# As the 'list' view we get a prefiltered queryset (deleted docs are excluded)
queryset = self.get_queryset()
filterset = DocumentFilter({"title": text}, queryset=queryset)
if not filterset.is_valid():
raise drf.exceptions.ValidationError(filterset.errors)
queryset = filterset.filter_queryset(queryset)
return self.get_response_for_queryset(
queryset.order_by("-updated_at"),
context={
"request": request,
},
)
def _search_fulltext(self, indexer, request, params):
"""
Returns a queryset from the results the fulltext search of Find
"""
access_token = request.session.get("oidc_access_token")
user = request.user
text = params.validated_data["q"]
page_size = params.validated_data.get("page_size", 20)
page_number = params.validated_data.get("page", 1)
queryset = models.Document.objects.all()
# Retrieve the documents ids from Find.
results = indexer.search(
text=text,
token=access_token,
visited=get_visited_document_ids_of(queryset, user),
page=1,
page_size=100,
)
docs_by_uuid = {str(d.pk): d for d in queryset.filter(pk__in=results)}
ordered_docs = [docs_by_uuid[id] for id in results]
paginator = Paginator(
ordered_docs, per_page=page_size, allow_empty_first_page=True
)
try:
page = paginator.page(page_number)
except InvalidPage as e:
raise drf.exceptions.NotFound(_("Invalid page.")) from e
serializer = self.get_serializer(
page.object_list,
many=True,
context={
"request": request,
},
)
next_url, prev_url = None, None
if page.has_next():
next_url = request.build_absolute_uri()
next_url = drf_replace_query_param(
next_url, "page", page.next_page_number()
)
if page.has_previous():
prev_url = request.build_absolute_uri()
prev_url = drf_replace_query_param(
prev_url, "page", page.previous_page_number()
)
output = {
"count": paginator.count,
"next": next_url,
"previous": prev_url,
"results": serializer.data,
}
return drf.response.Response(output)
@drf.decorators.action(detail=False, methods=["get"], url_path="search")
@method_decorator(refresh_oidc_access_token)
def search(self, request, *args, **kwargs):
"""
Returns a DRF response containing the filtered, annotated and ordered document list.
Applies filtering based on request parameter 'q' from `SearchDocumentSerializer`.
Depending of the configuration it can be:
- A fulltext search through the opensearch indexation app "find" if the backend is
enabled (see SEARCH_INDEXER_CLASS)
- A filtering by the model field 'title'.
The ordering is always by the most recent first.
"""
params = serializers.SearchDocumentSerializer(data=request.query_params)
params.is_valid(raise_exception=True)
indexer = get_document_indexer()
if indexer:
return self._search_fulltext(indexer, request, params=params)
# The indexer is not configured, we fallback on a simple icontains filter by the
# model field 'title'.
return self._search_simple(request, text=params.validated_data["q"])
@drf.decorators.action(detail=True, methods=["get"], url_path="versions")
def versions_list(self, request, *args, **kwargs):
"""
@@ -1832,7 +1948,10 @@ class DocumentAccessViewSet(
class TemplateViewSet(
drf.mixins.CreateModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""Template ViewSet"""
@@ -1888,6 +2007,100 @@ class TemplateViewSet(
serializer = self.get_serializer(queryset, many=True)
return drf.response.Response(serializer.data)
@transaction.atomic
def perform_create(self, serializer):
"""Set the current user as owner of the newly created object."""
obj = serializer.save()
models.TemplateAccess.objects.create(
template=obj,
user=self.request.user,
role=models.RoleChoices.OWNER,
)
class TemplateAccessViewSet(
ResourceAccessViewsetMixin,
drf.mixins.CreateModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""
API ViewSet for all interactions with template accesses.
GET /api/v1.0/templates/<template_id>/accesses/:<template_access_id>
Return list of all template accesses related to the logged-in user or one
template access if an id is provided.
POST /api/v1.0/templates/<template_id>/accesses/ with expected data:
- user: str
- role: str [administrator|editor|reader]
Return newly created template access
PUT /api/v1.0/templates/<template_id>/accesses/<template_access_id>/ with expected data:
- role: str [owner|admin|editor|reader]
Return updated template access
PATCH /api/v1.0/templates/<template_id>/accesses/<template_access_id>/ with expected data:
- role: str [owner|admin|editor|reader]
Return partially updated template access
DELETE /api/v1.0/templates/<template_id>/accesses/<template_access_id>/
Delete targeted template access
"""
lookup_field = "pk"
permission_classes = [permissions.ResourceAccessPermission]
throttle_scope = "template_access"
queryset = models.TemplateAccess.objects.select_related("user").all()
resource_field_name = "template"
serializer_class = serializers.TemplateAccessSerializer
@cached_property
def template(self):
"""Get related template from resource ID in url."""
try:
return models.Template.objects.get(pk=self.kwargs["resource_id"])
except models.Template.DoesNotExist as excpt:
raise drf.exceptions.NotFound() from excpt
def list(self, request, *args, **kwargs):
"""Restrict templates returned by the list endpoint"""
user = self.request.user
teams = user.teams
queryset = self.filter_queryset(self.get_queryset())
# Limit to resource access instances related to a resource THAT also has
# a resource access instance for the logged-in user (we don't want to list
# only the resource access instances pointing to the logged-in user)
queryset = queryset.filter(
db.Q(template__accesses__user=user)
| db.Q(template__accesses__team__in=teams),
).distinct()
serializer = self.get_serializer(queryset, many=True)
return drf.response.Response(serializer.data)
def perform_create(self, serializer):
"""
Actually create the new template access:
- Ensures the `template_id` is explicitly set from the URL.
- If the assigned role is `OWNER`, checks that the requesting user is an owner
of the document. This is the only permission check deferred until this step;
all other access checks are handled earlier in the permission lifecycle.
"""
role = serializer.validated_data.get("role")
if (
role == choices.RoleChoices.OWNER
and self.template.get_role(self.request.user) != choices.RoleChoices.OWNER
):
raise drf.exceptions.PermissionDenied(
"Only owners of a template can assign other users as owners."
)
serializer.save(template_id=self.kwargs["resource_id"])
class InvitationViewset(
drf.mixins.CreateModelMixin,
@@ -2066,18 +2279,7 @@ class DocumentAskForAccessViewSet(
serializer = serializers.RoleSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
target_role = serializer.validated_data.get(
"role", document_ask_for_access.role
)
abilities = document_ask_for_access.get_abilities(request.user)
if target_role not in abilities["set_role_to"]:
return drf.response.Response(
{"detail": "You cannot accept a role higher than your own."},
status=drf.status.HTTP_400_BAD_REQUEST,
)
document_ask_for_access.accept(role=target_role)
document_ask_for_access.accept(role=serializer.validated_data.get("role"))
return drf.response.Response(status=drf.status.HTTP_204_NO_CONTENT)
@@ -2151,132 +2353,3 @@ class ConfigView(drf.views.APIView):
)
return theme_customization
class CommentViewSetMixin:
"""Comment ViewSet Mixin."""
_document = None
def get_document_or_404(self):
"""Get the document related to the viewset or raise a 404 error."""
if self._document is None:
try:
self._document = models.Document.objects.get(
pk=self.kwargs["resource_id"],
)
except models.Document.DoesNotExist as e:
raise drf.exceptions.NotFound("Document not found.") from e
return self._document
class ThreadViewSet(
ResourceAccessViewsetMixin,
CommentViewSetMixin,
drf.mixins.CreateModelMixin,
drf.mixins.ListModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
"""Thread API: list/create threads and nested comment operations."""
permission_classes = [permissions.CommentPermission]
pagination_class = Pagination
serializer_class = serializers.ThreadSerializer
queryset = models.Thread.objects.select_related("creator", "document").filter(
resolved=False
)
resource_field_name = "document"
def perform_create(self, serializer):
"""Create the first comment of the thread."""
body = serializer.validated_data["body"]
del serializer.validated_data["body"]
thread = serializer.save()
models.Comment.objects.create(
thread=thread,
user=self.request.user if self.request.user.is_authenticated else None,
body=body,
)
@drf.decorators.action(detail=True, methods=["post"], url_path="resolve")
def resolve(self, request, *args, **kwargs):
"""Resolve a thread."""
thread = self.get_object()
if not thread.resolved:
thread.resolved = True
thread.resolved_at = timezone.now()
thread.resolved_by = request.user
thread.save(update_fields=["resolved", "resolved_at", "resolved_by"])
return drf.response.Response(status=status.HTTP_204_NO_CONTENT)
class CommentViewSet(
CommentViewSetMixin,
viewsets.ModelViewSet,
):
"""Comment API: list/create comments and nested reaction operations."""
permission_classes = [permissions.CommentPermission]
pagination_class = Pagination
serializer_class = serializers.CommentSerializer
queryset = models.Comment.objects.select_related("user").all()
def get_queryset(self):
"""Override to filter on related resource."""
return (
super()
.get_queryset()
.filter(
thread=self.kwargs["thread_id"],
thread__document=self.kwargs["resource_id"],
)
)
def get_serializer_context(self):
"""Extra context provided to the serializer class."""
context = super().get_serializer_context()
context["document_id"] = self.kwargs["resource_id"]
context["thread_id"] = self.kwargs["thread_id"]
return context
@drf.decorators.action(
detail=True,
methods=["post", "delete"],
)
def reactions(self, request, *args, **kwargs):
"""POST: add reaction; DELETE: remove reaction.
Emoji is expected in request.data['emoji'] for both operations.
"""
comment = self.get_object()
serializer = serializers.ReactionSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
if request.method == "POST":
reaction, created = models.Reaction.objects.get_or_create(
comment=comment,
emoji=serializer.validated_data["emoji"],
)
if not created and reaction.users.filter(id=request.user.id).exists():
return drf.response.Response(
{"user_already_reacted": True}, status=status.HTTP_400_BAD_REQUEST
)
reaction.users.add(request.user)
return drf.response.Response(status=status.HTTP_201_CREATED)
# DELETE
try:
reaction = models.Reaction.objects.get(
comment=comment,
emoji=serializer.validated_data["emoji"],
users__in=[request.user],
)
except models.Reaction.DoesNotExist as e:
raise drf.exceptions.NotFound("Reaction not found.") from e
reaction.users.remove(request.user)
if not reaction.users.exists():
reaction.delete()
return drf.response.Response(status=status.HTTP_204_NO_CONTENT)
+15 -7
View File
@@ -1,11 +1,19 @@
"""Impress Core application"""
# from django.apps import AppConfig
# from django.utils.translation import gettext_lazy as _
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
# class CoreConfig(AppConfig):
# """Configuration class for the impress core app."""
class CoreConfig(AppConfig):
"""Configuration class for the impress core app."""
# name = "core"
# app_label = "core"
# verbose_name = _("impress core application")
name = "core"
app_label = "core"
verbose_name = _("Impress core application")
def ready(self):
"""
Import signals when the app is ready.
"""
# pylint: disable=import-outside-toplevel, unused-import
from . import signals # noqa: PLC0415
-2
View File
@@ -33,7 +33,6 @@ class LinkRoleChoices(PriorityTextChoices):
"""Defines the possible roles a link can offer on a document."""
READER = "reader", _("Reader") # Can read
COMMENTER = "commenter", _("Commenter") # Can read and comment
EDITOR = "editor", _("Editor") # Can read and edit
@@ -41,7 +40,6 @@ class RoleChoices(PriorityTextChoices):
"""Defines the possible roles a user can have in a resource."""
READER = "reader", _("Reader") # Can read
COMMENTER = "commenter", _("Commenter") # Can read and comment
EDITOR = "editor", _("Editor") # Can read and edit
ADMIN = "administrator", _("Administrator") # Can read, edit, delete and share
OWNER = "owner", _("Owner")
-46
View File
@@ -256,49 +256,3 @@ class InvitationFactory(factory.django.DjangoModelFactory):
document = factory.SubFactory(DocumentFactory)
role = factory.fuzzy.FuzzyChoice([role[0] for role in models.RoleChoices.choices])
issuer = factory.SubFactory(UserFactory)
class ThreadFactory(factory.django.DjangoModelFactory):
"""A factory to create threads for a document"""
class Meta:
model = models.Thread
document = factory.SubFactory(DocumentFactory)
creator = factory.SubFactory(UserFactory)
class CommentFactory(factory.django.DjangoModelFactory):
"""A factory to create comments for a thread"""
class Meta:
model = models.Comment
thread = factory.SubFactory(ThreadFactory)
user = factory.SubFactory(UserFactory)
body = factory.Faker("text")
class ReactionFactory(factory.django.DjangoModelFactory):
"""A factory to create reactions for a comment"""
class Meta:
model = models.Reaction
comment = factory.SubFactory(CommentFactory)
emoji = "test"
@factory.post_generation
def users(self, create, extracted, **kwargs):
"""Add users to reaction from a given list of users or create one if not provided."""
if not create:
return
if not extracted:
# the factory is being created, but no users were provided
user = UserFactory()
self.users.add(user)
return
# Add the iterable of groups using bulk addition
self.users.add(*extracted)
@@ -0,0 +1,40 @@
"""
Handle search setup that needs to be done at bootstrap time.
"""
import logging
import time
from django.core.management.base import BaseCommand, CommandError
from core.services.search_indexers import get_document_indexer
logger = logging.getLogger("docs.search.bootstrap_search")
class Command(BaseCommand):
"""Index all documents to remote search service"""
help = __doc__
def handle(self, *args, **options):
"""Launch and log search index generation."""
indexer = get_document_indexer()
if not indexer:
raise CommandError("The indexer is not enabled or properly configured.")
logger.info("Starting to regenerate Find index...")
start = time.perf_counter()
try:
count = indexer.index()
except Exception as err:
raise CommandError("Unable to regenerate index") from err
duration = time.perf_counter() - start
logger.info(
"Search index regenerated from %d document(s) in %.2f seconds.",
count,
duration,
)
@@ -1,275 +0,0 @@
# Generated by Django 5.2.6 on 2025-09-16 08:59
import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0025_alter_user_short_name"),
]
operations = [
migrations.AlterField(
model_name="document",
name="link_role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commenter", "Commenter"),
("editor", "Editor"),
],
default="reader",
max_length=20,
),
),
migrations.AlterField(
model_name="documentaccess",
name="role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commenter", "Commenter"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
migrations.AlterField(
model_name="documentaskforaccess",
name="role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commenter", "Commenter"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
migrations.AlterField(
model_name="invitation",
name="role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commenter", "Commenter"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
migrations.AlterField(
model_name="templateaccess",
name="role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commenter", "Commenter"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
migrations.CreateModel(
name="Thread",
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",
),
),
("resolved", models.BooleanField(default=False)),
("resolved_at", models.DateTimeField(blank=True, null=True)),
("metadata", models.JSONField(blank=True, default=dict)),
(
"creator",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="threads",
to=settings.AUTH_USER_MODEL,
),
),
(
"document",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="threads",
to="core.document",
),
),
(
"resolved_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="resolved_threads",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Thread",
"verbose_name_plural": "Threads",
"db_table": "impress_thread",
"ordering": ("-created_at",),
},
),
migrations.CreateModel(
name="Comment",
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",
),
),
("body", models.JSONField()),
("metadata", models.JSONField(blank=True, default=dict)),
(
"user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="thread_comment",
to=settings.AUTH_USER_MODEL,
),
),
(
"thread",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="comments",
to="core.thread",
),
),
],
options={
"verbose_name": "Comment",
"verbose_name_plural": "Comments",
"db_table": "impress_comment",
"ordering": ("created_at",),
},
),
migrations.CreateModel(
name="Reaction",
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",
),
),
("emoji", models.CharField(max_length=32)),
(
"comment",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="reactions",
to="core.comment",
),
),
(
"users",
models.ManyToManyField(
related_name="reactions", to=settings.AUTH_USER_MODEL
),
),
],
options={
"verbose_name": "Reaction",
"verbose_name_plural": "Reactions",
"db_table": "impress_comment_reaction",
"constraints": [
models.UniqueConstraint(
fields=("comment", "emoji"),
name="unique_comment_emoji",
violation_error_message="This emoji has already been reacted to this comment.",
)
],
},
),
]
+42 -185
View File
@@ -432,32 +432,35 @@ class Document(MP_Node, BaseModel):
def save(self, *args, **kwargs):
"""Write content to object storage only if _content has changed."""
super().save(*args, **kwargs)
if self._content:
file_key = self.file_key
bytes_content = self._content.encode("utf-8")
self.save_content(self._content)
# Attempt to directly check if the object exists using the storage client.
try:
response = default_storage.connection.meta.client.head_object(
Bucket=default_storage.bucket_name, Key=file_key
)
except ClientError as excpt:
# If the error is a 404, the object doesn't exist, so we should create it.
if excpt.response["Error"]["Code"] == "404":
has_changed = True
else:
raise
def save_content(self, content):
"""Save content to object storage."""
file_key = self.file_key
bytes_content = content.encode("utf-8")
# Attempt to directly check if the object exists using the storage client.
try:
response = default_storage.connection.meta.client.head_object(
Bucket=default_storage.bucket_name, Key=file_key
)
except ClientError as excpt:
# If the error is a 404, the object doesn't exist, so we should create it.
if excpt.response["Error"]["Code"] == "404":
has_changed = True
else:
# Compare the existing ETag with the MD5 hash of the new content.
has_changed = (
response["ETag"].strip('"')
!= hashlib.md5(bytes_content).hexdigest() # noqa: S324
)
raise
else:
# Compare the existing ETag with the MD5 hash of the new content.
has_changed = (
response["ETag"].strip('"') != hashlib.md5(bytes_content).hexdigest() # noqa: S324
)
if has_changed:
content_file = ContentFile(bytes_content)
default_storage.save(file_key, content_file)
if has_changed:
content_file = ContentFile(bytes_content)
default_storage.save(file_key, content_file)
def is_leaf(self):
"""
@@ -756,7 +759,6 @@ class Document(MP_Node, BaseModel):
can_update = (
is_owner_or_admin or role == RoleChoices.EDITOR
) and not is_deleted
can_comment = (can_update or role == RoleChoices.COMMENTER) and not is_deleted
can_create_children = can_update and user.is_authenticated
can_destroy = (
is_owner
@@ -787,7 +789,6 @@ class Document(MP_Node, BaseModel):
"children_list": can_get,
"children_create": can_create_children,
"collaboration_auth": can_get,
"comment": can_comment,
"content": can_get,
"cors_proxy": can_get,
"descendants": can_get,
@@ -1148,12 +1149,7 @@ class DocumentAccess(BaseAccess):
set_role_to = []
if is_owner_or_admin:
set_role_to.extend(
[
RoleChoices.READER,
RoleChoices.COMMENTER,
RoleChoices.EDITOR,
RoleChoices.ADMIN,
]
[RoleChoices.READER, RoleChoices.EDITOR, RoleChoices.ADMIN]
)
if role == RoleChoices.OWNER:
set_role_to.append(RoleChoices.OWNER)
@@ -1212,14 +1208,23 @@ class DocumentAskForAccess(BaseModel):
def get_abilities(self, user):
"""Compute and return abilities for a given user."""
user_role = self.document.get_role(user)
is_admin_or_owner = user_role in PRIVILEGED_ROLES
roles = []
set_role_to = [
role
for role in RoleChoices.values
if RoleChoices.get_priority(role) <= RoleChoices.get_priority(user_role)
]
if user.is_authenticated:
teams = user.teams
try:
roles = self.user_roles or []
except AttributeError:
try:
roles = self.document.accesses.filter(
models.Q(user=user) | models.Q(team__in=teams),
).values_list("role", flat=True)
except (self._meta.model.DoesNotExist, IndexError):
roles = []
is_admin_or_owner = bool(
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
return {
"destroy": is_admin_or_owner,
@@ -1227,7 +1232,6 @@ class DocumentAskForAccess(BaseModel):
"partial_update": is_admin_or_owner,
"retrieve": is_admin_or_owner,
"accept": is_admin_or_owner,
"set_role_to": set_role_to,
}
def accept(self, role=None):
@@ -1277,153 +1281,6 @@ class DocumentAskForAccess(BaseModel):
self.document.send_email(subject, [email], context, language)
class Thread(BaseModel):
"""Discussion thread attached to a document.
A thread groups one or many comments. For backward compatibility with the
existing frontend (useComments hook) we still expose a flattened serializer
that returns a "content" field representing the first comment's body.
"""
document = models.ForeignKey(
Document,
on_delete=models.CASCADE,
related_name="threads",
)
creator = models.ForeignKey(
User,
on_delete=models.SET_NULL,
related_name="threads",
null=True,
blank=True,
)
resolved = models.BooleanField(default=False)
resolved_at = models.DateTimeField(null=True, blank=True)
resolved_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
related_name="resolved_threads",
null=True,
blank=True,
)
metadata = models.JSONField(default=dict, blank=True)
class Meta:
db_table = "impress_thread"
ordering = ("-created_at",)
verbose_name = _("Thread")
verbose_name_plural = _("Threads")
def __str__(self):
author = self.creator or _("Anonymous")
return f"Thread by {author!s} on {self.document!s}"
def get_abilities(self, user):
"""Compute and return abilities for a given user (mirrors comment logic)."""
role = self.document.get_role(user)
doc_abilities = self.document.get_abilities(user)
read_access = doc_abilities.get("comment", False)
write_access = self.creator == user or role in [
RoleChoices.OWNER,
RoleChoices.ADMIN,
]
return {
"destroy": write_access,
"update": write_access,
"partial_update": write_access,
"resolve": write_access,
"retrieve": read_access,
}
@property
def first_comment(self):
"""Return the first createdcomment of the thread."""
return self.comments.order_by("created_at").first()
class Comment(BaseModel):
"""A comment belonging to a thread."""
thread = models.ForeignKey(
Thread,
on_delete=models.CASCADE,
related_name="comments",
)
user = models.ForeignKey(
User,
on_delete=models.SET_NULL,
related_name="thread_comment",
null=True,
blank=True,
)
body = models.JSONField()
metadata = models.JSONField(default=dict, blank=True)
class Meta:
db_table = "impress_comment"
ordering = ("created_at",)
verbose_name = _("Comment")
verbose_name_plural = _("Comments")
def __str__(self):
"""Return the string representation of the comment."""
author = self.user or _("Anonymous")
return f"Comment by {author!s} on thread {self.thread_id}"
def get_abilities(self, user):
"""Return the abilities of the comment."""
role = self.thread.document.get_role(user)
doc_abilities = self.thread.document.get_abilities(user)
read_access = doc_abilities.get("comment", False)
can_react = read_access and user.is_authenticated
write_access = self.user == user or role in [
RoleChoices.OWNER,
RoleChoices.ADMIN,
]
return {
"destroy": write_access,
"update": write_access,
"partial_update": write_access,
"reactions": can_react,
"retrieve": read_access,
}
class Reaction(BaseModel):
"""Aggregated reactions for a given emoji on a comment.
We store one row per (comment, emoji) and maintain the list of user IDs who
reacted with that emoji. This matches the frontend interface where a
reaction exposes: emoji, createdAt (first reaction date) and userIds.
"""
comment = models.ForeignKey(
Comment,
on_delete=models.CASCADE,
related_name="reactions",
)
emoji = models.CharField(max_length=32)
users = models.ManyToManyField(User, related_name="reactions")
class Meta:
db_table = "impress_comment_reaction"
constraints = [
models.UniqueConstraint(
fields=["comment", "emoji"],
name="unique_comment_emoji",
violation_error_message=_(
"This emoji has already been reacted to this comment."
),
),
]
verbose_name = _("Reaction")
verbose_name_plural = _("Reactions")
def __str__(self):
"""Return the string representation of the reaction."""
return f"Reaction {self.emoji} on comment {self.comment.id}"
class Template(BaseModel):
"""HTML and CSS code used for formatting the print around the MarkDown body."""
@@ -0,0 +1,295 @@
"""Document search index management utilities and indexers"""
import logging
from abc import ABC, abstractmethod
from collections import defaultdict
from functools import cache
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ImproperlyConfigured
from django.db.models import Subquery
from django.utils.module_loading import import_string
import requests
from core import models, utils
logger = logging.getLogger(__name__)
@cache
def get_document_indexer():
"""Returns an instance of indexer service if enabled and properly configured."""
classpath = settings.SEARCH_INDEXER_CLASS
# For this usecase an empty indexer class is not an issue but a feature.
if not classpath:
logger.info("Document indexer is not configured (see SEARCH_INDEXER_CLASS)")
return None
try:
indexer_class = import_string(settings.SEARCH_INDEXER_CLASS)
return indexer_class()
except ImportError as err:
logger.error("SEARCH_INDEXER_CLASS setting is not valid : %s", err)
except ImproperlyConfigured as err:
logger.error("Document indexer is not properly configured : %s", err)
return None
def get_batch_accesses_by_users_and_teams(paths):
"""
Get accesses related to a list of document paths,
grouped by users and teams, including all ancestor paths.
"""
ancestor_map = utils.get_ancestor_to_descendants_map(
paths, steplen=models.Document.steplen
)
ancestor_paths = list(ancestor_map.keys())
access_qs = models.DocumentAccess.objects.filter(
document__path__in=ancestor_paths
).values("document__path", "user__sub", "team")
access_by_document_path = defaultdict(lambda: {"users": set(), "teams": set()})
for access in access_qs:
ancestor_path = access["document__path"]
user_sub = access["user__sub"]
team = access["team"]
for descendant_path in ancestor_map.get(ancestor_path, []):
if user_sub:
access_by_document_path[descendant_path]["users"].add(str(user_sub))
if team:
access_by_document_path[descendant_path]["teams"].add(team)
return dict(access_by_document_path)
def get_visited_document_ids_of(queryset, user):
"""
Returns the ids of the documents that have a linktrace to the user and NOT owned.
It will be use to limit the opensearch responses to the public documents already
"visited" by the user.
"""
if isinstance(user, AnonymousUser):
return []
qs = models.LinkTrace.objects.filter(user=user)
docs = (
queryset.exclude(accesses__user=user)
.filter(
deleted_at__isnull=True,
ancestors_deleted_at__isnull=True,
)
.filter(pk__in=Subquery(qs.values("document_id")))
.order_by("pk")
.distinct("pk")
)
return [str(id) for id in docs.values_list("pk", flat=True)]
class BaseDocumentIndexer(ABC):
"""
Base class for document indexers.
Handles batching and access resolution. Subclasses must implement both
`serialize_document()` and `push()` to define backend-specific behavior.
"""
def __init__(self, batch_size=None):
"""
Initialize the indexer.
Args:
batch_size (int, optional): Number of documents per batch.
Defaults to settings.SEARCH_INDEXER_BATCH_SIZE.
"""
self.batch_size = batch_size or settings.SEARCH_INDEXER_BATCH_SIZE
self.indexer_url = settings.SEARCH_INDEXER_URL
self.indexer_secret = settings.SEARCH_INDEXER_SECRET
self.search_url = settings.SEARCH_INDEXER_QUERY_URL
if not self.indexer_url:
raise ImproperlyConfigured(
"SEARCH_INDEXER_URL must be set in Django settings."
)
if not self.indexer_secret:
raise ImproperlyConfigured(
"SEARCH_INDEXER_SECRET must be set in Django settings."
)
if not self.search_url:
raise ImproperlyConfigured(
"SEARCH_INDEXER_QUERY_URL must be set in Django settings."
)
def index(self):
"""
Fetch documents in batches, serialize them, and push to the search backend.
"""
last_id = 0
count = 0
while True:
documents_batch = list(
models.Document.objects.filter(
id__gt=last_id,
).order_by("id")[: self.batch_size]
)
if not documents_batch:
break
doc_paths = [doc.path for doc in documents_batch]
last_id = documents_batch[-1].id
accesses_by_document_path = get_batch_accesses_by_users_and_teams(doc_paths)
serialized_batch = [
self.serialize_document(document, accesses_by_document_path)
for document in documents_batch
if document.content or document.title
]
self.push(serialized_batch)
count += len(serialized_batch)
return count
@abstractmethod
def serialize_document(self, document, accesses):
"""
Convert a Document instance to a JSON-serializable format for indexing.
Must be implemented by subclasses.
"""
@abstractmethod
def push(self, data):
"""
Push a batch of serialized documents to the backend.
Must be implemented by subclasses.
"""
# pylint: disable-next=too-many-arguments,too-many-positional-arguments
def search(self, text, token, visited=(), page=1, page_size=50):
"""
Search for documents in Find app.
Ensure the same default ordering as "Docs" list : -updated_at
Returns ids of the documents
Args:
text (str): Text search content.
token (str): OIDC Authentication token.
visited (list, optional):
List of ids of active public documents with LinkTrace
Defaults to settings.SEARCH_INDEXER_BATCH_SIZE.
page (int, optional):
The page number to retrieve.
Defaults to 1 if not specified.
page_size (int, optional):
The number of results to return per page.
Defaults to 50 if not specified.
"""
response = self.search_query(
data={
"q": text,
"visited": visited,
"services": ["docs"],
"page_number": page,
"page_size": page_size,
"order_by": "updated_at",
"order_direction": "desc",
},
token=token,
)
return [d["_id"] for d in response]
@abstractmethod
def search_query(self, data, token) -> dict:
"""
Retrieve documents from the Find app API.
Must be implemented by subclasses.
"""
class SearchIndexer(BaseDocumentIndexer):
"""
Document indexer that pushes documents to La Suite Find app.
"""
def serialize_document(self, document, accesses):
"""
Convert a Document to the JSON format expected by La Suite Find.
Args:
document (Document): The document instance.
accesses (dict): Mapping of document ID to user/team access.
Returns:
dict: A JSON-serializable dictionary.
"""
doc_path = document.path
doc_content = document.content
text_content = utils.base64_yjs_to_text(doc_content) if doc_content else ""
return {
"id": str(document.id),
"title": document.title or "",
"content": text_content,
"depth": document.depth,
"path": document.path,
"numchild": document.numchild,
"created_at": document.created_at.isoformat(),
"updated_at": document.updated_at.isoformat(),
"users": list(accesses.get(doc_path, {}).get("users", set())),
"groups": list(accesses.get(doc_path, {}).get("teams", set())),
"reach": document.computed_link_reach,
"size": len(text_content.encode("utf-8")),
"is_active": not bool(document.ancestors_deleted_at),
}
def search_query(self, data, token) -> requests.Response:
"""
Retrieve documents from the Find app API.
Args:
data (dict): search data
token (str): OICD token
Returns:
dict: A JSON-serializable dictionary.
"""
response = requests.post(
self.search_url,
json=data,
headers={"Authorization": f"Bearer {token}"},
timeout=10,
)
response.raise_for_status()
return response.json()
def push(self, data):
"""
Push a batch of documents to the Find backend.
Args:
data (list): List of document dictionaries.
"""
response = requests.post(
self.indexer_url,
json=data,
headers={"Authorization": f"Bearer {self.indexer_secret}"},
timeout=10,
)
response.raise_for_status()
+31
View File
@@ -0,0 +1,31 @@
"""
Declare and configure the signals for the impress core application
"""
from functools import partial
from django.db import transaction
from django.db.models import signals
from django.dispatch import receiver
from . import models
from .tasks.search import trigger_document_indexer
@receiver(signals.post_save, sender=models.Document)
def document_post_save(sender, instance, **kwargs): # pylint: disable=unused-argument
"""
Asynchronous call to the document indexer at the end of the transaction.
Note : Within the transaction we can have an empty content and a serialization
error.
"""
transaction.on_commit(partial(trigger_document_indexer, instance))
@receiver(signals.post_save, sender=models.DocumentAccess)
def document_access_post_save(sender, instance, created, **kwargs): # pylint: disable=unused-argument
"""
Asynchronous call to the document indexer at the end of the transaction.
"""
if not created:
transaction.on_commit(partial(trigger_document_indexer, instance.document))
+84
View File
@@ -0,0 +1,84 @@
"""Trigger document indexation using celery task."""
from logging import getLogger
from django.conf import settings
from django.core.cache import cache
from django_redis.cache import RedisCache
from core import models
from core.services.search_indexers import (
get_batch_accesses_by_users_and_teams,
get_document_indexer,
)
from impress.celery_app import app
logger = getLogger(__file__)
def indexer_throttle_acquire(document_id, timeout=0, atomic=True):
"""
Enable the task throttle flag for a delay.
Uses redis locks if available to ensure atomic changes
"""
key = f"doc-indexer-throttle-{document_id}"
if isinstance(cache, RedisCache) and atomic:
with cache.locks(key):
return indexer_throttle_acquire(document_id, timeout, atomic=False)
# Use add() here :
# - set the flag and returns true if not exist
# - do nothing and return false if exist
return cache.add(key, 1, timeout=timeout)
@app.task
def document_indexer_task(document_id):
"""Celery Task : Sends indexation query for a document."""
indexer = get_document_indexer()
if indexer is None:
return
try:
doc = models.Document.objects.get(pk=document_id)
except models.Document.DoesNotExist:
# Skip the task if the document does not exist.
return
accesses = get_batch_accesses_by_users_and_teams((doc.path,))
data = indexer.serialize_document(document=doc, accesses=accesses)
logger.info("Start document %s indexation", document_id)
indexer.push(data)
def trigger_document_indexer(document):
"""
Trigger indexation task with debounce a delay set by the SEARCH_INDEXER_COUNTDOWN setting.
Args:
document (Document): The document instance.
"""
countdown = settings.SEARCH_INDEXER_COUNTDOWN
# DO NOT create a task if indexation if disabled
if not settings.SEARCH_INDEXER_CLASS:
return
# Each time this method is called during a countdown, we increment the
# counter and each task decrease it, so the index be run only once.
if indexer_throttle_acquire(document.pk, timeout=countdown):
logger.info(
"Add task for document %s indexation in %.2f seconds",
document.pk,
countdown,
)
document_indexer_task.apply_async(args=[document.pk])
else:
logger.info("Skip task for document %s indexation", document.pk)
@@ -0,0 +1,65 @@
"""
Unit test for `index` command.
"""
from operator import itemgetter
from unittest import mock
from django.core.management import CommandError, call_command
from django.db import transaction
import pytest
from core import factories
from core.services.search_indexers import SearchIndexer
@pytest.mark.django_db
@pytest.mark.usefixtures("indexer_settings")
def test_index():
"""Test the command `index` that run the Find app indexer for all the available documents."""
user = factories.UserFactory()
indexer = SearchIndexer()
with transaction.atomic():
doc = factories.DocumentFactory()
empty_doc = factories.DocumentFactory(title=None, content="")
no_title_doc = factories.DocumentFactory(title=None)
factories.UserDocumentAccessFactory(document=doc, user=user)
factories.UserDocumentAccessFactory(document=empty_doc, user=user)
factories.UserDocumentAccessFactory(document=no_title_doc, user=user)
accesses = {
str(doc.path): {"users": [user.sub]},
str(empty_doc.path): {"users": [user.sub]},
str(no_title_doc.path): {"users": [user.sub]},
}
with mock.patch.object(SearchIndexer, "push") as mock_push:
call_command("index")
push_call_args = [call.args[0] for call in mock_push.call_args_list]
# called once but with a batch of docs
mock_push.assert_called_once()
assert sorted(push_call_args[0], key=itemgetter("id")) == sorted(
[
indexer.serialize_document(doc, accesses),
indexer.serialize_document(no_title_doc, accesses),
],
key=itemgetter("id"),
)
@pytest.mark.django_db
@pytest.mark.usefixtures("indexer_settings")
def test_index_improperly_configured(indexer_settings):
"""The command should raise an exception if the indexer is not configured"""
indexer_settings.SEARCH_INDEXER_CLASS = None
with pytest.raises(CommandError) as err:
call_command("index")
assert str(err.value) == "The indexer is not enabled or properly configured."
+26
View File
@@ -24,3 +24,29 @@ def mock_user_teams():
"core.models.User.teams", new_callable=mock.PropertyMock
) as mock_teams:
yield mock_teams
@pytest.fixture(name="indexer_settings")
def indexer_settings_fixture(settings):
"""
Setup valid settings for the document indexer. Clear the indexer cache.
"""
# pylint: disable-next=import-outside-toplevel
from core.services.search_indexers import ( # noqa: PLC0415
get_document_indexer,
)
get_document_indexer.cache_clear()
settings.SEARCH_INDEXER_CLASS = "core.services.search_indexers.SearchIndexer"
settings.SEARCH_INDEXER_SECRET = "ThisIsAKeyForTest"
settings.SEARCH_INDEXER_URL = "http://localhost:8081/api/v1.0/documents/index/"
settings.SEARCH_INDEXER_QUERY_URL = (
"http://localhost:8081/api/v1.0/documents/search/"
)
yield settings
# clear cache to prevent issues with other tests
get_document_indexer.cache_clear()
@@ -293,7 +293,6 @@ def test_api_document_accesses_retrieve_set_role_to_child():
}
assert result_dict[str(document_access_other_user.id)] == [
"reader",
"commenter",
"editor",
"administrator",
"owner",
@@ -302,7 +301,7 @@ def test_api_document_accesses_retrieve_set_role_to_child():
# Add an access for the other user on the parent
parent_access_other_user = factories.UserDocumentAccessFactory(
document=parent, user=other_user, role="commenter"
document=parent, user=other_user, role="editor"
)
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
@@ -315,7 +314,6 @@ def test_api_document_accesses_retrieve_set_role_to_child():
result["id"]: result["abilities"]["set_role_to"] for result in content
}
assert result_dict[str(document_access_other_user.id)] == [
"commenter",
"editor",
"administrator",
"owner",
@@ -323,7 +321,6 @@ def test_api_document_accesses_retrieve_set_role_to_child():
assert result_dict[str(parent_access.id)] == []
assert result_dict[str(parent_access_other_user.id)] == [
"reader",
"commenter",
"editor",
"administrator",
"owner",
@@ -336,28 +333,28 @@ def test_api_document_accesses_retrieve_set_role_to_child():
[
["administrator", "reader", "reader", "reader"],
[
["reader", "commenter", "editor", "administrator"],
["reader", "editor", "administrator"],
[],
[],
["reader", "commenter", "editor", "administrator"],
["reader", "editor", "administrator"],
],
],
[
["owner", "reader", "reader", "reader"],
[
["reader", "commenter", "editor", "administrator", "owner"],
["reader", "editor", "administrator", "owner"],
[],
[],
["reader", "commenter", "editor", "administrator", "owner"],
["reader", "editor", "administrator", "owner"],
],
],
[
["owner", "reader", "reader", "owner"],
[
["reader", "commenter", "editor", "administrator", "owner"],
["reader", "editor", "administrator", "owner"],
[],
[],
["reader", "commenter", "editor", "administrator", "owner"],
["reader", "editor", "administrator", "owner"],
],
],
],
@@ -418,44 +415,44 @@ def test_api_document_accesses_list_authenticated_related_same_user(roles, resul
[
["administrator", "reader", "reader", "reader"],
[
["reader", "commenter", "editor", "administrator"],
["reader", "editor", "administrator"],
[],
[],
["reader", "commenter", "editor", "administrator"],
["reader", "editor", "administrator"],
],
],
[
["owner", "reader", "reader", "reader"],
[
["reader", "commenter", "editor", "administrator", "owner"],
["reader", "editor", "administrator", "owner"],
[],
[],
["reader", "commenter", "editor", "administrator", "owner"],
["reader", "editor", "administrator", "owner"],
],
],
[
["owner", "reader", "reader", "owner"],
[
["reader", "commenter", "editor", "administrator", "owner"],
["reader", "editor", "administrator", "owner"],
[],
[],
["reader", "commenter", "editor", "administrator", "owner"],
["reader", "editor", "administrator", "owner"],
],
],
[
["reader", "reader", "reader", "owner"],
[
["reader", "commenter", "editor", "administrator", "owner"],
["reader", "editor", "administrator", "owner"],
[],
[],
["reader", "commenter", "editor", "administrator", "owner"],
["reader", "editor", "administrator", "owner"],
],
],
[
["reader", "administrator", "reader", "editor"],
[
["reader", "commenter", "editor", "administrator"],
["reader", "commenter", "editor", "administrator"],
["reader", "editor", "administrator"],
["reader", "editor", "administrator"],
[],
[],
],
@@ -463,7 +460,7 @@ def test_api_document_accesses_list_authenticated_related_same_user(roles, resul
[
["editor", "editor", "administrator", "editor"],
[
["reader", "commenter", "editor", "administrator"],
["reader", "editor", "administrator"],
[],
["editor", "administrator"],
[],
@@ -115,10 +115,7 @@ def test_api_documents_ask_for_access_create_authenticated_non_root_document():
assert response.status_code == 404
@pytest.mark.parametrize(
"role", [role for role in RoleChoices if role != RoleChoices.OWNER]
)
def test_api_documents_ask_for_access_create_authenticated_specific_role(role):
def test_api_documents_ask_for_access_create_authenticated_specific_role():
"""
Authenticated users should be able to create a document ask for access with a specific role.
"""
@@ -130,35 +127,17 @@ def test_api_documents_ask_for_access_create_authenticated_specific_role(role):
response = client.post(
f"/api/v1.0/documents/{document.id}/ask-for-access/",
data={"role": role},
data={"role": RoleChoices.EDITOR},
)
assert response.status_code == 201
assert DocumentAskForAccess.objects.filter(
document=document,
user=user,
role=role,
role=RoleChoices.EDITOR,
).exists()
def test_api_documents_ask_for_access_create_authenticated_owner_role():
"""
Authenticated users should not be able to create a document ask for access with the owner role.
"""
document = DocumentFactory()
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/ask-for-access/",
data={"role": RoleChoices.OWNER},
)
assert response.status_code == 400
assert response.json() == {"role": ['"owner" is not a valid choice.']}
def test_api_documents_ask_for_access_create_authenticated_already_has_access():
"""Authenticated users with existing access can ask for access with a different role."""
user = UserFactory()
@@ -287,7 +266,6 @@ def test_api_documents_ask_for_access_list_authenticated_own_request():
"update": False,
"partial_update": False,
"retrieve": False,
"set_role_to": [],
},
}
],
@@ -357,16 +335,6 @@ def test_api_documents_ask_for_access_list_owner_or_admin(role):
response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/")
assert response.status_code == 200
expected_set_role_to = [
RoleChoices.READER,
RoleChoices.COMMENTER,
RoleChoices.EDITOR,
RoleChoices.ADMIN,
]
if role == RoleChoices.OWNER:
expected_set_role_to.append(RoleChoices.OWNER)
assert response.json() == {
"count": 3,
"next": None,
@@ -386,7 +354,6 @@ def test_api_documents_ask_for_access_list_owner_or_admin(role):
"update": True,
"partial_update": True,
"retrieve": True,
"set_role_to": expected_set_role_to,
},
}
for document_ask_for_access in document_ask_for_accesses
@@ -479,14 +446,6 @@ def test_api_documents_ask_for_access_retrieve_owner_or_admin(role):
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
)
assert response.status_code == 200
expected_set_role_to = [
RoleChoices.READER,
RoleChoices.COMMENTER,
RoleChoices.EDITOR,
RoleChoices.ADMIN,
]
if role == RoleChoices.OWNER:
expected_set_role_to.append(RoleChoices.OWNER)
assert response.json() == {
"id": str(document_ask_for_access.id),
"document": str(document.id),
@@ -501,7 +460,6 @@ def test_api_documents_ask_for_access_retrieve_owner_or_admin(role):
"update": True,
"partial_update": True,
"retrieve": True,
"set_role_to": expected_set_role_to,
},
}
@@ -791,53 +749,6 @@ def test_api_documents_ask_for_access_accept_authenticated_owner_or_admin_update
assert document_access.role == RoleChoices.ADMIN
def test_api_documents_ask_for_access_accept_admin_cannot_accept_owner_role():
"""
Admin users should not be able to accept document ask for access with the owner role.
"""
user = UserFactory()
document = DocumentFactory(users=[(user, RoleChoices.ADMIN)])
document_ask_for_access = DocumentAskForAccessFactory(
document=document, role=RoleChoices.READER
)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/accept/",
data={"role": RoleChoices.OWNER},
)
assert response.status_code == 400
assert response.json() == {
"detail": "You cannot accept a role higher than your own."
}
def test_api_documents_ask_for_access_accept_owner_can_accept_owner_role():
"""
Owner users should be able to accept document ask for access with the owner role.
"""
user = UserFactory()
document = DocumentFactory(users=[(user, RoleChoices.OWNER)])
document_ask_for_access = DocumentAskForAccessFactory(
document=document, role=RoleChoices.READER
)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/accept/",
data={"role": RoleChoices.OWNER},
)
assert response.status_code == 204
assert not DocumentAskForAccess.objects.filter(
id=document_ask_for_access.id
).exists()
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
def test_api_documents_ask_for_access_accept_authenticated_non_root_document(role):
"""
@@ -1,878 +0,0 @@
"""Test API for comments on documents."""
import random
from django.contrib.auth.models import AnonymousUser
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
# List comments
def test_list_comments_anonymous_user_public_document():
"""Anonymous users should be allowed to list comments on a public document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.COMMENTER
)
thread = factories.ThreadFactory(document=document)
comment1, comment2 = factories.CommentFactory.create_batch(2, thread=thread)
# other comments not linked to the document
factories.CommentFactory.create_batch(2)
response = APIClient().get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/"
)
assert response.status_code == 200
assert response.json() == {
"count": 2,
"next": None,
"previous": None,
"results": [
{
"id": str(comment1.id),
"body": comment1.body,
"created_at": comment1.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": comment1.updated_at.isoformat().replace("+00:00", "Z"),
"user": {
"full_name": comment1.user.full_name,
"short_name": comment1.user.short_name,
},
"abilities": comment1.get_abilities(AnonymousUser()),
"reactions": [],
},
{
"id": str(comment2.id),
"body": comment2.body,
"created_at": comment2.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": comment2.updated_at.isoformat().replace("+00:00", "Z"),
"user": {
"full_name": comment2.user.full_name,
"short_name": comment2.user.short_name,
},
"abilities": comment2.get_abilities(AnonymousUser()),
"reactions": [],
},
],
}
@pytest.mark.parametrize("link_reach", ["restricted", "authenticated"])
def test_list_comments_anonymous_user_non_public_document(link_reach):
"""Anonymous users should not be allowed to list comments on a non-public document."""
document = factories.DocumentFactory(
link_reach=link_reach, link_role=models.LinkRoleChoices.COMMENTER
)
thread = factories.ThreadFactory(document=document)
factories.CommentFactory(thread=thread)
# other comments not linked to the document
factories.CommentFactory.create_batch(2)
response = APIClient().get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/"
)
assert response.status_code == 401
def test_list_comments_authenticated_user_accessible_document():
"""Authenticated users should be allowed to list comments on an accessible document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)]
)
thread = factories.ThreadFactory(document=document)
comment1 = factories.CommentFactory(thread=thread)
comment2 = factories.CommentFactory(thread=thread, user=user)
# other comments not linked to the document
factories.CommentFactory.create_batch(2)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/"
)
assert response.status_code == 200
assert response.json() == {
"count": 2,
"next": None,
"previous": None,
"results": [
{
"id": str(comment1.id),
"body": comment1.body,
"created_at": comment1.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": comment1.updated_at.isoformat().replace("+00:00", "Z"),
"user": {
"full_name": comment1.user.full_name,
"short_name": comment1.user.short_name,
},
"abilities": comment1.get_abilities(user),
"reactions": [],
},
{
"id": str(comment2.id),
"body": comment2.body,
"created_at": comment2.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": comment2.updated_at.isoformat().replace("+00:00", "Z"),
"user": {
"full_name": comment2.user.full_name,
"short_name": comment2.user.short_name,
},
"abilities": comment2.get_abilities(user),
"reactions": [],
},
],
}
def test_list_comments_authenticated_user_non_accessible_document():
"""Authenticated users should not be allowed to list comments on a non-accessible document."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach="restricted")
thread = factories.ThreadFactory(document=document)
factories.CommentFactory(thread=thread)
# other comments not linked to the document
factories.CommentFactory.create_batch(2)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/"
)
assert response.status_code == 403
def test_list_comments_authenticated_user_not_enough_access():
"""
Authenticated users should not be allowed to list comments on a document they don't have
comment access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)]
)
thread = factories.ThreadFactory(document=document)
factories.CommentFactory(thread=thread)
# other comments not linked to the document
factories.CommentFactory.create_batch(2)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/"
)
assert response.status_code == 403
# Create comment
def test_create_comment_anonymous_user_public_document():
"""
Anonymous users should be allowed to create comments on a public document
with commenter link_role.
"""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.COMMENTER
)
thread = factories.ThreadFactory(document=document)
client = APIClient()
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/",
{"body": "test"},
)
assert response.status_code == 201
assert response.json() == {
"id": str(response.json()["id"]),
"body": "test",
"created_at": response.json()["created_at"],
"updated_at": response.json()["updated_at"],
"user": None,
"abilities": {
"destroy": False,
"update": False,
"partial_update": False,
"reactions": False,
"retrieve": True,
},
"reactions": [],
}
def test_create_comment_anonymous_user_non_accessible_document():
"""Anonymous users should not be allowed to create comments on a non-accessible document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.READER
)
thread = factories.ThreadFactory(document=document)
client = APIClient()
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/",
{"body": "test"},
)
assert response.status_code == 401
def test_create_comment_authenticated_user_accessible_document():
"""Authenticated users should be allowed to create comments on an accessible document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)]
)
thread = factories.ThreadFactory(document=document)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/",
{"body": "test"},
)
assert response.status_code == 201
assert response.json() == {
"id": str(response.json()["id"]),
"body": "test",
"created_at": response.json()["created_at"],
"updated_at": response.json()["updated_at"],
"user": {
"full_name": user.full_name,
"short_name": user.short_name,
},
"abilities": {
"destroy": True,
"update": True,
"partial_update": True,
"reactions": True,
"retrieve": True,
},
"reactions": [],
}
def test_create_comment_authenticated_user_not_enough_access():
"""
Authenticated users should not be allowed to create comments on a document they don't have
comment access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)]
)
thread = factories.ThreadFactory(document=document)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/",
{"body": "test"},
)
assert response.status_code == 403
# Retrieve comment
def test_retrieve_comment_anonymous_user_public_document():
"""Anonymous users should be allowed to retrieve comments on a public document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.COMMENTER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
response = client.get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 200
assert response.json() == {
"id": str(comment.id),
"body": comment.body,
"created_at": comment.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": comment.updated_at.isoformat().replace("+00:00", "Z"),
"user": {
"full_name": comment.user.full_name,
"short_name": comment.user.short_name,
},
"reactions": [],
"abilities": comment.get_abilities(AnonymousUser()),
}
def test_retrieve_comment_anonymous_user_non_accessible_document():
"""Anonymous users should not be allowed to retrieve comments on a non-accessible document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.READER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
response = client.get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 401
def test_retrieve_comment_authenticated_user_accessible_document():
"""Authenticated users should be allowed to retrieve comments on an accessible document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)]
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 200
def test_retrieve_comment_authenticated_user_not_enough_access():
"""
Authenticated users should not be allowed to retrieve comments on a document they don't have
comment access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)]
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 403
# Update comment
def test_update_comment_anonymous_user_public_document():
"""Anonymous users should not be allowed to update comments on a public document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.COMMENTER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, body="test")
client = APIClient()
response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
{"body": "other content"},
)
assert response.status_code == 401
def test_update_comment_anonymous_user_non_accessible_document():
"""Anonymous users should not be allowed to update comments on a non-accessible document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.READER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, body="test")
client = APIClient()
response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
{"body": "other content"},
)
assert response.status_code == 401
def test_update_comment_authenticated_user_accessible_document():
"""Authenticated users should not be able to update comments not their own."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted",
users=[
(
user,
random.choice(
[models.LinkRoleChoices.COMMENTER, models.LinkRoleChoices.EDITOR]
),
)
],
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, body="test")
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
{"body": "other content"},
)
assert response.status_code == 403
def test_update_comment_authenticated_user_own_comment():
"""Authenticated users should be able to update comments not their own."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted",
users=[
(
user,
random.choice(
[models.LinkRoleChoices.COMMENTER, models.LinkRoleChoices.EDITOR]
),
)
],
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, body="test", user=user)
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
{"body": "other content"},
)
assert response.status_code == 200
comment.refresh_from_db()
assert comment.body == "other content"
def test_update_comment_authenticated_user_not_enough_access():
"""
Authenticated users should not be allowed to update comments on a document they don't
have comment access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)]
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, body="test")
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
{"body": "other content"},
)
assert response.status_code == 403
def test_update_comment_authenticated_no_access():
"""
Authenticated users should not be allowed to update comments on a document they don't
have access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach="restricted")
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, body="test")
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
{"body": "other content"},
)
assert response.status_code == 403
@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER])
def test_update_comment_authenticated_admin_or_owner_can_update_any_comment(role):
"""
Authenticated users should be able to update comments on a document they don't have access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, role)])
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, body="test")
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
{"body": "other content"},
)
assert response.status_code == 200
comment.refresh_from_db()
assert comment.body == "other content"
@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER])
def test_update_comment_authenticated_admin_or_owner_can_update_own_comment(role):
"""
Authenticated users should be able to update comments on a document they don't have access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, role)])
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, body="test", user=user)
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
{"body": "other content"},
)
assert response.status_code == 200
comment.refresh_from_db()
assert comment.body == "other content"
# Delete comment
def test_delete_comment_anonymous_user_public_document():
"""Anonymous users should not be allowed to delete comments on a public document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.COMMENTER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 401
def test_delete_comment_anonymous_user_non_accessible_document():
"""Anonymous users should not be allowed to delete comments on a non-accessible document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.READER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 401
def test_delete_comment_authenticated_user_accessible_document_own_comment():
"""Authenticated users should be able to delete comments on an accessible document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)]
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, user=user)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 204
def test_delete_comment_authenticated_user_accessible_document_not_own_comment():
"""Authenticated users should not be able to delete comments on an accessible document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)]
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 403
@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER])
def test_delete_comment_authenticated_user_admin_or_owner_can_delete_any_comment(role):
"""Authenticated users should be able to delete comments on a document they have access to."""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, role)])
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 204
@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER])
def test_delete_comment_authenticated_user_admin_or_owner_can_delete_own_comment(role):
"""Authenticated users should be able to delete comments on a document they have access to."""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, role)])
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, user=user)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 204
def test_delete_comment_authenticated_user_not_enough_access():
"""
Authenticated users should not be able to delete comments on a document they don't
have access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)]
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 403
# Create reaction
@pytest.mark.parametrize("link_role", models.LinkRoleChoices.values)
def test_create_reaction_anonymous_user_public_document(link_role):
"""No matter the link_role, an anonymous user can not react to a comment."""
document = factories.DocumentFactory(link_reach="public", link_role=link_role)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 401
def test_create_reaction_authenticated_user_public_document():
"""
Authenticated users should not be able to reaction to a comment on a public document with
link_role reader.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.READER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 403
def test_create_reaction_authenticated_user_accessible_public_document():
"""
Authenticated users should be able to react to a comment on a public document.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.COMMENTER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 201
assert models.Reaction.objects.filter(
comment=comment, emoji="test", users__in=[user]
).exists()
def test_create_reaction_authenticated_user_connected_document_link_role_reader():
"""
Authenticated users should not be able to react to a comment on a connected document
with link_role reader.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="authenticated", link_role=models.LinkRoleChoices.READER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 403
@pytest.mark.parametrize(
"link_role",
[
role
for role in models.LinkRoleChoices.values
if role != models.LinkRoleChoices.READER
],
)
def test_create_reaction_authenticated_user_connected_document(link_role):
"""
Authenticated users should be able to react to a comment on a connected document.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="authenticated", link_role=link_role
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 201
assert models.Reaction.objects.filter(
comment=comment, emoji="test", users__in=[user]
).exists()
def test_create_reaction_authenticated_user_restricted_accessible_document():
"""
Authenticated users should not be able to react to a comment on a restricted accessible document
they don't have access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach="restricted")
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 403
def test_create_reaction_authenticated_user_restricted_accessible_document_role_reader():
"""
Authenticated users should not be able to react to a comment on a restricted accessible
document with role reader.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", link_role=models.LinkRoleChoices.READER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 403
@pytest.mark.parametrize(
"role",
[role for role in models.RoleChoices.values if role != models.RoleChoices.READER],
)
def test_create_reaction_authenticated_user_restricted_accessible_document_role_commenter(
role,
):
"""
Authenticated users should be able to react to a comment on a restricted accessible document
with role commenter.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach="restricted", users=[(user, role)])
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 201
assert models.Reaction.objects.filter(
comment=comment, emoji="test", users__in=[user]
).exists()
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 400
assert response.json() == {"user_already_reacted": True}
# Delete reaction
def test_delete_reaction_not_owned_by_the_current_user():
"""
Users should not be able to delete reactions not owned by the current user.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.RoleChoices.ADMIN)]
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
reaction = factories.ReactionFactory(comment=comment)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": reaction.emoji},
)
assert response.status_code == 404
def test_delete_reaction_owned_by_the_current_user():
"""
Users should not be able to delete reactions not owned by the current user.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.RoleChoices.ADMIN)]
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
reaction = factories.ReactionFactory(comment=comment)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": reaction.emoji},
)
assert response.status_code == 404
reaction.refresh_from_db()
assert reaction.users.exists()
@@ -36,7 +36,6 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"comment": document.link_role in ["commenter", "editor"],
"cors_proxy": True,
"content": True,
"descendants": True,
@@ -47,8 +46,8 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": None,
},
"mask": False,
@@ -114,7 +113,6 @@ def test_api_documents_retrieve_anonymous_public_parent():
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"comment": grand_parent.link_role in ["commenter", "editor"],
"descendants": True,
"cors_proxy": True,
"content": True,
@@ -222,7 +220,6 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"children_create": document.link_role == "editor",
"children_list": True,
"collaboration_auth": True,
"comment": document.link_role in ["commenter", "editor"],
"descendants": True,
"cors_proxy": True,
"content": True,
@@ -232,8 +229,8 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": None,
},
"mask": True,
@@ -307,7 +304,6 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
"children_create": grand_parent.link_role == "editor",
"children_list": True,
"collaboration_auth": True,
"comment": grand_parent.link_role in ["commenter", "editor"],
"descendants": True,
"cors_proxy": True,
"content": True,
@@ -498,14 +494,13 @@ def test_api_documents_retrieve_authenticated_related_parent():
"abilities": {
"accesses_manage": access.role in ["administrator", "owner"],
"accesses_view": True,
"ai_transform": access.role not in ["reader", "commenter"],
"ai_translate": access.role not in ["reader", "commenter"],
"attachment_upload": access.role not in ["reader", "commenter"],
"can_edit": access.role not in ["reader", "commenter"],
"children_create": access.role not in ["reader", "commenter"],
"ai_transform": access.role != "reader",
"ai_translate": access.role != "reader",
"attachment_upload": access.role != "reader",
"can_edit": access.role != "reader",
"children_create": access.role != "reader",
"children_list": True,
"collaboration_auth": True,
"comment": access.role != "reader",
"descendants": True,
"cors_proxy": True,
"content": True,
@@ -521,11 +516,11 @@ def test_api_documents_retrieve_authenticated_related_parent():
"media_auth": True,
"media_check": True,
"move": access.role in ["administrator", "owner"],
"partial_update": access.role not in ["reader", "commenter"],
"partial_update": access.role != "reader",
"restore": access.role == "owner",
"retrieve": True,
"tree": True,
"update": access.role not in ["reader", "commenter"],
"update": access.role != "reader",
"versions_destroy": access.role in ["administrator", "owner"],
"versions_list": True,
"versions_retrieve": True,
@@ -0,0 +1,426 @@
"""
Tests for Documents API endpoint in impress's core app: list
"""
import random
from json import loads as json_loads
from django.test import RequestFactory
import pytest
import responses
from faker import Faker
from rest_framework.test import APIClient
from core import factories, models
from core.services.search_indexers import get_document_indexer
fake = Faker()
pytestmark = pytest.mark.django_db
def build_search_url(**kwargs):
"""Build absolute uri for search endpoint with ORDERED query arguments"""
return (
RequestFactory()
.get("/api/v1.0/documents/search/", dict(sorted(kwargs.items())))
.build_absolute_uri()
)
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
@responses.activate
def test_api_documents_search_anonymous(reach, role, indexer_settings):
"""
Anonymous users should not be allowed to search documents whatever the
link reach and link role
"""
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
factories.DocumentFactory(link_reach=reach, link_role=role)
# Find response
responses.add(
responses.POST,
"http://find/api/v1.0/search",
json=[],
status=200,
)
response = APIClient().get("/api/v1.0/documents/search/", data={"q": "alpha"})
assert response.status_code == 200
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
def test_api_documents_search_endpoint_is_none(indexer_settings):
"""
Missing SEARCH_INDEXER_QUERY_URL, so the indexer is not properly configured.
Should fallback on title filter
"""
indexer_settings.SEARCH_INDEXER_QUERY_URL = None
assert get_document_indexer() is None
user = factories.UserFactory()
document = factories.DocumentFactory(title="alpha")
access = factories.UserDocumentAccessFactory(document=document, user=user)
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/documents/search/", data={"q": "alpha"})
assert response.status_code == 200
content = response.json()
results = content.pop("results")
assert content == {
"count": 1,
"next": None,
"previous": None,
}
assert len(results) == 1
assert results[0] == {
"id": str(document.id),
"abilities": document.get_abilities(user),
"ancestors_link_reach": None,
"ancestors_link_role": None,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 1,
"excerpt": document.excerpt,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 1,
"numchild": 0,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"deleted_at": None,
"user_role": access.role,
}
@responses.activate
def test_api_documents_search_invalid_params(indexer_settings):
"""Validate the format of documents as returned by the search view."""
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/documents/search/")
assert response.status_code == 400
assert response.json() == {"q": ["This field is required."]}
response = client.get("/api/v1.0/documents/search/", data={"q": " "})
assert response.status_code == 400
assert response.json() == {"q": ["This field may not be blank."]}
response = client.get(
"/api/v1.0/documents/search/", data={"q": "any", "page": "NaN"}
)
assert response.status_code == 400
assert response.json() == {"page": ["A valid integer is required."]}
@responses.activate
def test_api_documents_search_format(indexer_settings):
"""Validate the format of documents as returned by the search view."""
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
assert get_document_indexer() is not None
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
user_a, user_b, user_c = factories.UserFactory.create_batch(3)
document = factories.DocumentFactory(
title="alpha",
users=(user_a, user_c),
link_traces=(user, user_b),
)
access = factories.UserDocumentAccessFactory(document=document, user=user)
# Find response
responses.add(
responses.POST,
"http://find/api/v1.0/search",
json=[
{"_id": str(document.pk)},
],
status=200,
)
response = client.get("/api/v1.0/documents/search/", data={"q": "alpha"})
assert response.status_code == 200
content = response.json()
results = content.pop("results")
assert content == {
"count": 1,
"next": None,
"previous": None,
}
assert len(results) == 1
assert results[0] == {
"id": str(document.id),
"abilities": document.get_abilities(user),
"ancestors_link_reach": None,
"ancestors_link_role": None,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 1,
"excerpt": document.excerpt,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses_ancestors": 3,
"nb_accesses_direct": 3,
"numchild": 0,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"deleted_at": None,
"user_role": access.role,
}
@responses.activate
@pytest.mark.parametrize(
"pagination, status, expected",
(
(
{"page": 1, "page_size": 10},
200,
{
"count": 10,
"previous": None,
"next": None,
"range": (0, None),
},
),
(
{},
200,
{
"count": 10,
"previous": None,
"next": None,
"range": (0, None),
"api_page_size": 21, # default page_size is 20
},
),
(
{"page": 2, "page_size": 10},
404,
{},
),
(
{"page": 1, "page_size": 5},
200,
{
"count": 10,
"previous": None,
"next": {"page": 2, "page_size": 5},
"range": (0, 5),
},
),
(
{"page": 2, "page_size": 5},
200,
{
"count": 10,
"previous": {"page": 1, "page_size": 5},
"next": None,
"range": (5, None),
},
),
({"page": 3, "page_size": 5}, 404, {}),
),
)
def test_api_documents_search_pagination(
indexer_settings, pagination, status, expected
):
"""Documents should be ordered by descending "score" by default"""
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
assert get_document_indexer() is not None
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
docs = factories.DocumentFactory.create_batch(10, title="alpha", users=[user])
docs_by_uuid = {str(doc.pk): doc for doc in docs}
api_results = [{"_id": id} for id in docs_by_uuid.keys()]
# reorder randomly to simulate score ordering
random.shuffle(api_results)
# Find response
# pylint: disable-next=assignment-from-none
api_search = responses.add(
responses.POST,
"http://find/api/v1.0/search",
json=api_results,
status=200,
)
response = client.get(
"/api/v1.0/documents/search/",
data={
"q": "alpha",
**pagination,
},
)
assert response.status_code == status
if response.status_code < 300:
previous_url = (
build_search_url(q="alpha", **expected["previous"])
if expected["previous"]
else None
)
next_url = (
build_search_url(q="alpha", **expected["next"])
if expected["next"]
else None
)
start, end = expected["range"]
content = response.json()
assert content["count"] == expected["count"]
assert content["previous"] == previous_url
assert content["next"] == next_url
results = content.pop("results")
# The find api results ordering by score is kept
assert [r["id"] for r in results] == [r["_id"] for r in api_results[start:end]]
# Check the query parameters.
assert api_search.call_count == 1
assert api_search.calls[0].response.status_code == 200
assert json_loads(api_search.calls[0].request.body) == {
"q": "alpha",
"visited": [],
"services": ["docs"],
"page_number": 1,
"page_size": 100,
"order_by": "updated_at",
"order_direction": "desc",
}
@responses.activate
@pytest.mark.parametrize(
"pagination, status, expected",
(
(
{"page": 1, "page_size": 10},
200,
{"count": 10, "previous": None, "next": None, "range": (0, None)},
),
(
{},
200,
{"count": 10, "previous": None, "next": None, "range": (0, None)},
),
(
{"page": 2, "page_size": 10},
404,
{},
),
(
{"page": 1, "page_size": 5},
200,
{
"count": 10,
"previous": None,
"next": {"page": 2, "page_size": 5},
"range": (0, 5),
},
),
(
{"page": 2, "page_size": 5},
200,
{
"count": 10,
"previous": {"page_size": 5},
"next": None,
"range": (5, None),
},
),
({"page": 3, "page_size": 5}, 404, {}),
),
)
def test_api_documents_search_pagination_endpoint_is_none(
indexer_settings, pagination, status, expected
):
"""Documents should be ordered by descending "-updated_at" by default"""
indexer_settings.SEARCH_INDEXER_QUERY_URL = None
assert get_document_indexer() is None
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(10, title="alpha", users=[user])
response = client.get(
"/api/v1.0/documents/search/",
data={
"q": "alpha",
**pagination,
},
)
assert response.status_code == status
if response.status_code < 300:
previous_url = (
build_search_url(q="alpha", **expected["previous"])
if expected["previous"]
else None
)
next_url = (
build_search_url(q="alpha", **expected["next"])
if expected["next"]
else None
)
queryset = models.Document.objects.order_by("-updated_at")
start, end = expected["range"]
expected_results = [str(d.pk) for d in queryset[start:end]]
content = response.json()
assert content["count"] == expected["count"]
assert content["previous"] == previous_url
assert content["next"] == next_url
results = content.pop("results")
assert [r["id"] for r in results] == expected_results
File diff suppressed because it is too large Load Diff
@@ -81,7 +81,6 @@ def test_api_documents_trashbin_format():
"collaboration_auth": False,
"descendants": False,
"cors_proxy": False,
"comment": False,
"content": False,
"destroy": False,
"duplicate": False,
@@ -89,8 +88,8 @@ def test_api_documents_trashbin_format():
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": None,
},
"mask": False,
@@ -0,0 +1,799 @@
"""
Test template accesses API endpoints for users in impress's core app.
"""
import random
from unittest import mock
from uuid import uuid4
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.api import serializers
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
def test_api_template_accesses_list_anonymous():
"""Anonymous users should not be allowed to list template accesses."""
template = factories.TemplateFactory()
factories.UserTemplateAccessFactory.create_batch(2, template=template)
response = APIClient().get(f"/api/v1.0/templates/{template.id!s}/accesses/")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_template_accesses_list_authenticated_unrelated():
"""
Authenticated users should not be allowed to list template accesses for a template
to which they are not related.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
factories.UserTemplateAccessFactory.create_batch(3, template=template)
# Accesses for other templates to which the user is related should not be listed either
other_access = factories.UserTemplateAccessFactory(user=user)
factories.UserTemplateAccessFactory(template=other_access.template)
response = client.get(
f"/api/v1.0/templates/{template.id!s}/accesses/",
)
assert response.status_code == 200
assert response.json() == []
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_list_authenticated_related(via, mock_user_teams):
"""
Authenticated users should be able to list template accesses for a template
to which they are directly related, whatever their role in the template.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
user_access = None
if via == USER:
user_access = models.TemplateAccess.objects.create(
template=template,
user=user,
role=random.choice(models.RoleChoices.values),
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
user_access = models.TemplateAccess.objects.create(
template=template,
team="lasuite",
role=random.choice(models.RoleChoices.values),
)
access1 = factories.TeamTemplateAccessFactory(template=template)
access2 = factories.UserTemplateAccessFactory(template=template)
# Accesses for other templates to which the user is related should not be listed either
other_access = factories.UserTemplateAccessFactory(user=user)
factories.UserTemplateAccessFactory(template=other_access.template)
response = client.get(
f"/api/v1.0/templates/{template.id!s}/accesses/",
)
assert response.status_code == 200
content = response.json()
assert len(content) == 3
assert sorted(content, key=lambda x: x["id"]) == sorted(
[
{
"id": str(user_access.id),
"user": str(user.id) if via == "user" else None,
"team": "lasuite" if via == "team" else "",
"role": user_access.role,
"abilities": user_access.get_abilities(user),
},
{
"id": str(access1.id),
"user": None,
"team": access1.team,
"role": access1.role,
"abilities": access1.get_abilities(user),
},
{
"id": str(access2.id),
"user": str(access2.user.id),
"team": "",
"role": access2.role,
"abilities": access2.get_abilities(user),
},
],
key=lambda x: x["id"],
)
def test_api_template_accesses_retrieve_anonymous():
"""
Anonymous users should not be allowed to retrieve a template access.
"""
access = factories.UserTemplateAccessFactory()
response = APIClient().get(
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_template_accesses_retrieve_authenticated_unrelated():
"""
Authenticated users should not be allowed to retrieve a template access for
a template to which they are not related.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
access = factories.UserTemplateAccessFactory(template=template)
response = client.get(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
# Accesses related to another template should be excluded even if the user is related to it
for access in [
factories.UserTemplateAccessFactory(),
factories.UserTemplateAccessFactory(user=user),
]:
response = client.get(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 404
assert response.json() == {
"detail": "No TemplateAccess matches the given query."
}
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_retrieve_authenticated_related(via, mock_user_teams):
"""
A user who is related to a template should be allowed to retrieve the
associated template user accesses.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(template=template, team="lasuite")
access = factories.UserTemplateAccessFactory(template=template)
response = client.get(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 200
assert response.json() == {
"id": str(access.id),
"user": str(access.user.id),
"team": "",
"role": access.role,
"abilities": access.get_abilities(user),
}
def test_api_template_accesses_update_anonymous():
"""Anonymous users should not be allowed to update a template access."""
access = factories.UserTemplateAccessFactory()
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.values),
}
api_client = APIClient()
for field, value in new_values.items():
response = api_client.put(
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
)
assert response.status_code == 401
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_template_accesses_update_authenticated_unrelated():
"""
Authenticated users should not be allowed to update a template access for a template to which
they are not related.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
access = factories.UserTemplateAccessFactory()
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.values),
}
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
assert updated_values == old_values
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_authenticated_editor_or_reader(
via, role, mock_user_teams
):
"""Editors or readers of a template should not be allowed to update its accesses."""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
)
access = factories.UserTemplateAccessFactory(template=template)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.values),
}
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
assert updated_values == old_values
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_administrator_except_owner(via, mock_user_teams):
"""
A user who is a direct administrator in a template should be allowed to update a user
access for this template, as long as they don't try to set the role to owner.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
access = factories.UserTemplateAccessFactory(
template=template,
role=random.choice(["administrator", "editor", "reader"]),
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(["administrator", "editor", "reader"]),
}
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
if (
new_data["role"] == old_values["role"]
): # we are not really updating the role
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
if field == "role":
assert updated_values == {**old_values, "role": new_values["role"]}
else:
assert updated_values == old_values
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_administrator_from_owner(via, mock_user_teams):
"""
A user who is an administrator in a template, should not be allowed to update
the user access of an "owner" for this template.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
other_user = factories.UserFactory()
access = factories.UserTemplateAccessFactory(
template=template, user=other_user, role="owner"
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.values),
}
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
data={**old_values, field: value},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
assert updated_values == old_values
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_administrator_to_owner(via, mock_user_teams):
"""
A user who is an administrator in a template, should not be allowed to update
the user access of another user to grant template ownership.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
other_user = factories.UserFactory()
access = factories.UserTemplateAccessFactory(
template=template,
user=other_user,
role=random.choice(["administrator", "editor", "reader"]),
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": "owner",
}
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
# We are not allowed or not really updating the role
if field == "role" or new_data["role"] == old_values["role"]:
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
assert updated_values == old_values
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_owner(via, mock_user_teams):
"""
A user who is an owner in a template should be allowed to update
a user access for this template whatever the role.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
factories.UserFactory()
access = factories.UserTemplateAccessFactory(
template=template,
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.values),
}
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
if (
new_data["role"] == old_values["role"]
): # we are not really updating the role
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
if field == "role":
assert updated_values == {**old_values, "role": new_values["role"]}
else:
assert updated_values == old_values
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_owner_self(via, mock_user_teams):
"""
A user who is owner of a template should be allowed to update
their own user access provided there are other owners in the template.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
access = factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
else:
access = factories.UserTemplateAccessFactory(
template=template, user=user, role="owner"
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_role = random.choice(["administrator", "editor", "reader"])
response = client.put(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
data={**old_values, "role": new_role},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
assert access.role == "owner"
# Add another owner and it should now work
factories.UserTemplateAccessFactory(template=template, role="owner")
response = client.put(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
data={**old_values, "role": new_role},
format="json",
)
assert response.status_code == 200
access.refresh_from_db()
assert access.role == new_role
# Delete
def test_api_template_accesses_delete_anonymous():
"""Anonymous users should not be allowed to destroy a template access."""
access = factories.UserTemplateAccessFactory()
response = APIClient().delete(
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 401
assert models.TemplateAccess.objects.count() == 1
def test_api_template_accesses_delete_authenticated():
"""
Authenticated users should not be allowed to delete a template access for a
template to which they are not related.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
access = factories.UserTemplateAccessFactory()
response = client.delete(
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TemplateAccess.objects.count() == 2
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_editor_or_reader(via, role, mock_user_teams):
"""
Authenticated users should not be allowed to delete a template access for a
template in which they are a simple editor or reader.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
)
access = factories.UserTemplateAccessFactory(template=template)
assert models.TemplateAccess.objects.count() == 3
assert models.TemplateAccess.objects.filter(user=access.user).exists()
response = client.delete(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TemplateAccess.objects.count() == 3
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_administrators_except_owners(
via, mock_user_teams
):
"""
Users who are administrators in a template should be allowed to delete an access
from the template provided it is not ownership.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
access = factories.UserTemplateAccessFactory(
template=template, role=random.choice(["reader", "editor", "administrator"])
)
assert models.TemplateAccess.objects.count() == 2
assert models.TemplateAccess.objects.filter(user=access.user).exists()
response = client.delete(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert models.TemplateAccess.objects.count() == 1
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_administrator_on_owners(via, mock_user_teams):
"""
Users who are administrators in a template should not be allowed to delete an ownership
access from the template.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
access = factories.UserTemplateAccessFactory(template=template, role="owner")
assert models.TemplateAccess.objects.count() == 3
assert models.TemplateAccess.objects.filter(user=access.user).exists()
response = client.delete(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TemplateAccess.objects.count() == 3
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_owners(via, mock_user_teams):
"""
Users should be able to delete the template access of another user
for a template of which they are owner.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
access = factories.UserTemplateAccessFactory(template=template)
assert models.TemplateAccess.objects.count() == 2
assert models.TemplateAccess.objects.filter(user=access.user).exists()
response = client.delete(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert models.TemplateAccess.objects.count() == 1
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_owners_last_owner(via, mock_user_teams):
"""
It should not be possible to delete the last owner access from a template
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
access = None
if via == USER:
access = factories.UserTemplateAccessFactory(
template=template, user=user, role="owner"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
access = factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
assert models.TemplateAccess.objects.count() == 2
response = client.delete(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TemplateAccess.objects.count() == 2
def test_api_template_accesses_throttling(settings):
"""Test api template accesses throttling."""
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["template_access"]
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["template_access"] = "2/minute"
template = factories.TemplateFactory()
user = factories.UserFactory()
factories.UserTemplateAccessFactory(
template=template, user=user, role="administrator"
)
client = APIClient()
client.force_login(user)
for _i in range(2):
response = client.get(f"/api/v1.0/templates/{template.id!s}/accesses/")
assert response.status_code == 200
with mock.patch("core.api.throttling.capture_message") as mock_capture_message:
response = client.get(f"/api/v1.0/templates/{template.id!s}/accesses/")
assert response.status_code == 429
mock_capture_message.assert_called_once_with(
"Rate limit exceeded for scope template_access", "warning"
)
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["template_access"] = current_rate
@@ -0,0 +1,206 @@
"""
Test template accesses create API endpoint for users in impress's core app.
"""
import random
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
def test_api_template_accesses_create_anonymous():
"""Anonymous users should not be allowed to create template accesses."""
template = factories.TemplateFactory()
other_user = factories.UserFactory()
response = APIClient().post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"template": str(template.id),
"role": random.choice(models.RoleChoices.values),
},
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert models.TemplateAccess.objects.exists() is False
def test_api_template_accesses_create_authenticated_unrelated():
"""
Authenticated users should not be allowed to create template accesses for a template to
which they are not related.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
template = factories.TemplateFactory()
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
},
format="json",
)
assert response.status_code == 403
assert not models.TemplateAccess.objects.filter(user=other_user).exists()
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_create_authenticated_editor_or_reader(
via, role, mock_user_teams
):
"""Editors or readers of a template should not be allowed to create template accesses."""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
)
other_user = factories.UserFactory()
for new_role in [role[0] for role in models.RoleChoices.choices]:
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"role": new_role,
},
format="json",
)
assert response.status_code == 403
assert not models.TemplateAccess.objects.filter(user=other_user).exists()
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_create_authenticated_administrator(via, mock_user_teams):
"""
Administrators of a template should be able to create template accesses
except for the "owner" role.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
other_user = factories.UserFactory()
# It should not be allowed to create an owner access
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"role": "owner",
},
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "Only owners of a template can assign other users as owners."
}
# It should be allowed to create a lower access
role = random.choice(
[role[0] for role in models.RoleChoices.choices if role[0] != "owner"]
)
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.TemplateAccess.objects.filter(user=other_user).count() == 1
new_template_access = models.TemplateAccess.objects.filter(user=other_user).get()
assert response.json() == {
"abilities": new_template_access.get_abilities(user),
"id": str(new_template_access.id),
"team": "",
"role": role,
"user": str(other_user.id),
}
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_create_authenticated_owner(via, mock_user_teams):
"""
Owners of a template should be able to create template accesses whatever the role.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
other_user = factories.UserFactory()
role = random.choice([role[0] for role in models.RoleChoices.choices])
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.TemplateAccess.objects.filter(user=other_user).count() == 1
new_template_access = models.TemplateAccess.objects.filter(user=other_user).get()
assert response.json() == {
"id": str(new_template_access.id),
"user": str(other_user.id),
"team": "",
"role": role,
"abilities": new_template_access.get_abilities(user),
}
@@ -42,5 +42,7 @@ def test_api_templates_create_authenticated():
format="json",
)
assert response.status_code == 405
assert not Template.objects.exists()
assert response.status_code == 201
template = Template.objects.get()
assert template.title == "my template"
assert template.accesses.filter(role="owner", user=user).exists()
@@ -8,6 +8,7 @@ import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
@@ -24,7 +25,7 @@ def test_api_templates_delete_anonymous():
assert models.Template.objects.count() == 1
def test_api_templates_delete_not_implemented():
def test_api_templates_delete_authenticated_unrelated():
"""
Authenticated users should not be allowed to delete a template to which they are not
related.
@@ -35,11 +36,72 @@ def test_api_templates_delete_not_implemented():
client.force_login(user)
is_public = random.choice([True, False])
template = factories.TemplateFactory(is_public=is_public, users=[(user, "owner")])
template = factories.TemplateFactory(is_public=is_public)
response = client.delete(
f"/api/v1.0/templates/{template.id!s}/",
)
assert response.status_code == 405
assert response.status_code == 403 if is_public else 404
assert models.Template.objects.count() == 1
@pytest.mark.parametrize("role", ["reader", "editor", "administrator"])
@pytest.mark.parametrize("via", VIA)
def test_api_templates_delete_authenticated_member_or_administrator(
via, role, mock_user_teams
):
"""
Authenticated users should not be allowed to delete a template for which they are
only a member or administrator.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
)
response = client.delete(
f"/api/v1.0/templates/{template.id}/",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert models.Template.objects.count() == 1
@pytest.mark.parametrize("via", VIA)
def test_api_templates_delete_authenticated_owner(via, mock_user_teams):
"""
Authenticated users should be able to delete a template they own.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
response = client.delete(
f"/api/v1.0/templates/{template.id}/",
)
assert response.status_code == 204
assert models.Template.objects.exists() is False
@@ -2,11 +2,14 @@
Tests for Templates API endpoint in impress's core app: update
"""
import random
import pytest
from rest_framework.test import APIClient
from core import factories
from core.api import serializers
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
@@ -14,6 +17,7 @@ pytestmark = pytest.mark.django_db
def test_api_templates_update_anonymous():
"""Anonymous users should not be allowed to update a template."""
template = factories.TemplateFactory()
old_template_values = serializers.TemplateSerializer(instance=template).data
new_template_values = serializers.TemplateSerializer(
instance=factories.TemplateFactory()
@@ -24,18 +28,145 @@ def test_api_templates_update_anonymous():
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
template.refresh_from_db()
template_values = serializers.TemplateSerializer(instance=template).data
assert template_values == old_template_values
def test_api_templates_update_not_implemented():
def test_api_templates_update_authenticated_unrelated():
"""
Authenticated users should not be allowed to update a template.
Authenticated users should not be allowed to update a template to which they are not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory(users=[(user, "owner")])
template = factories.TemplateFactory(is_public=False)
old_template_values = serializers.TemplateSerializer(instance=template).data
new_template_values = serializers.TemplateSerializer(
instance=factories.TemplateFactory()
).data
response = client.put(
f"/api/v1.0/templates/{template.id!s}/",
new_template_values,
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
template.refresh_from_db()
template_values = serializers.TemplateSerializer(instance=template).data
assert template_values == old_template_values
@pytest.mark.parametrize("via", VIA)
def test_api_templates_update_authenticated_readers(via, mock_user_teams):
"""
Users who are readers of a template should not be allowed to update it.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="reader")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="reader"
)
old_template_values = serializers.TemplateSerializer(instance=template).data
new_template_values = serializers.TemplateSerializer(
instance=factories.TemplateFactory()
).data
response = client.put(
f"/api/v1.0/templates/{template.id!s}/",
new_template_values,
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
template.refresh_from_db()
template_values = serializers.TemplateSerializer(instance=template).data
assert template_values == old_template_values
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
@pytest.mark.parametrize("via", VIA)
def test_api_templates_update_authenticated_editor_or_administrator_or_owner(
via, role, mock_user_teams
):
"""Administrator or owner of a template should be allowed to update it."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
)
old_template_values = serializers.TemplateSerializer(instance=template).data
new_template_values = serializers.TemplateSerializer(
instance=factories.TemplateFactory()
).data
response = client.put(
f"/api/v1.0/templates/{template.id!s}/",
new_template_values,
format="json",
)
assert response.status_code == 200
template.refresh_from_db()
template_values = serializers.TemplateSerializer(instance=template).data
for key, value in template_values.items():
if key in ["id", "accesses"]:
assert value == old_template_values[key]
else:
assert value == new_template_values[key]
@pytest.mark.parametrize("via", VIA)
def test_api_templates_update_authenticated_owners(via, mock_user_teams):
"""Administrators of a template should be allowed to update it."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
old_template_values = serializers.TemplateSerializer(instance=template).data
new_template_values = serializers.TemplateSerializer(
instance=factories.TemplateFactory()
@@ -45,10 +176,55 @@ def test_api_templates_update_not_implemented():
f"/api/v1.0/templates/{template.id!s}/", new_template_values, format="json"
)
assert response.status_code == 405
assert response.status_code == 200
template.refresh_from_db()
template_values = serializers.TemplateSerializer(instance=template).data
for key, value in template_values.items():
if key in ["id", "accesses"]:
assert value == old_template_values[key]
else:
assert value == new_template_values[key]
response = client.patch(
f"/api/v1.0/templates/{template.id!s}/", new_template_values, format="json"
@pytest.mark.parametrize("via", VIA)
def test_api_templates_update_administrator_or_owner_of_another(via, mock_user_teams):
"""
Being administrator or owner of a template should not grant authorization to update
another template.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(
template=template, user=user, role=random.choice(["administrator", "owner"])
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template,
team="lasuite",
role=random.choice(["administrator", "owner"]),
)
is_public = random.choice([True, False])
template = factories.TemplateFactory(title="Old title", is_public=is_public)
old_template_values = serializers.TemplateSerializer(instance=template).data
new_template_values = serializers.TemplateSerializer(
instance=factories.TemplateFactory()
).data
response = client.put(
f"/api/v1.0/templates/{template.id!s}/",
new_template_values,
format="json",
)
assert response.status_code == 405
assert response.status_code == 403 if is_public else 404
template.refresh_from_db()
template_values = serializers.TemplateSerializer(instance=template).data
assert template_values == old_template_values
-29
View File
@@ -278,35 +278,6 @@ def test_api_users_retrieve_me_authenticated():
}
def test_api_users_retrieve_me_authenticated_empty_name():
"""
Authenticated users should be able to retrieve their own user via the "/users/me" path.
when no name is provided, the full name and short name should be the email without the domain.
"""
user = factories.UserFactory(
email="test_foo@test.com",
full_name=None,
short_name=None,
)
client = APIClient()
client.force_login(user)
factories.UserFactory.create_batch(2)
response = client.get(
"/api/v1.0/users/me/",
)
assert response.status_code == 200
assert response.json() == {
"id": str(user.id),
"email": "test_foo@test.com",
"full_name": "test_foo",
"language": user.language,
"short_name": "test_foo",
}
def test_api_users_retrieve_anonymous():
"""Anonymous users should not be allowed to retrieve a user."""
client = APIClient()
@@ -1,283 +0,0 @@
"""Test the comment model."""
import random
from django.contrib.auth.models import AnonymousUser
import pytest
from core import factories
from core.models import LinkReachChoices, LinkRoleChoices, RoleChoices
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize(
"role,can_comment",
[
(LinkRoleChoices.READER, False),
(LinkRoleChoices.COMMENTER, True),
(LinkRoleChoices.EDITOR, True),
],
)
def test_comment_get_abilities_anonymous_user_public_document(role, can_comment):
"""Anonymous users cannot comment on a document."""
document = factories.DocumentFactory(
link_role=role, link_reach=LinkReachChoices.PUBLIC
)
comment = factories.CommentFactory(thread__document=document)
user = AnonymousUser()
assert comment.get_abilities(user) == {
"destroy": False,
"update": False,
"partial_update": False,
"reactions": False,
"retrieve": can_comment,
}
@pytest.mark.parametrize(
"link_reach", [LinkReachChoices.RESTRICTED, LinkReachChoices.AUTHENTICATED]
)
def test_comment_get_abilities_anonymous_user_restricted_document(link_reach):
"""Anonymous users cannot comment on a restricted document."""
document = factories.DocumentFactory(link_reach=link_reach)
comment = factories.CommentFactory(thread__document=document)
user = AnonymousUser()
assert comment.get_abilities(user) == {
"destroy": False,
"update": False,
"partial_update": False,
"reactions": False,
"retrieve": False,
}
@pytest.mark.parametrize(
"link_role,link_reach,can_comment",
[
(LinkRoleChoices.READER, LinkReachChoices.PUBLIC, False),
(LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC, True),
(LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC, True),
(LinkRoleChoices.READER, LinkReachChoices.RESTRICTED, False),
(LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED, False),
(LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED, False),
(LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED, False),
(LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED, True),
(LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED, True),
],
)
def test_comment_get_abilities_user_reader(link_role, link_reach, can_comment):
"""Readers cannot comment on a document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_role=link_role, link_reach=link_reach, users=[(user, RoleChoices.READER)]
)
comment = factories.CommentFactory(thread__document=document)
assert comment.get_abilities(user) == {
"destroy": False,
"update": False,
"partial_update": False,
"reactions": can_comment,
"retrieve": can_comment,
}
@pytest.mark.parametrize(
"link_role,link_reach,can_comment",
[
(LinkRoleChoices.READER, LinkReachChoices.PUBLIC, False),
(LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC, True),
(LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC, True),
(LinkRoleChoices.READER, LinkReachChoices.RESTRICTED, False),
(LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED, False),
(LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED, False),
(LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED, False),
(LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED, True),
(LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED, True),
],
)
def test_comment_get_abilities_user_reader_own_comment(
link_role, link_reach, can_comment
):
"""User with reader role on a document has all accesses to its own comment."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_role=link_role, link_reach=link_reach, users=[(user, RoleChoices.READER)]
)
comment = factories.CommentFactory(
thread__document=document, user=user if can_comment else None
)
assert comment.get_abilities(user) == {
"destroy": can_comment,
"update": can_comment,
"partial_update": can_comment,
"reactions": can_comment,
"retrieve": can_comment,
}
@pytest.mark.parametrize(
"link_role,link_reach",
[
(LinkRoleChoices.READER, LinkReachChoices.PUBLIC),
(LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC),
(LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC),
(LinkRoleChoices.READER, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED),
],
)
def test_comment_get_abilities_user_commenter(link_role, link_reach):
"""Commenters can comment on a document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_role=link_role,
link_reach=link_reach,
users=[(user, RoleChoices.COMMENTER)],
)
comment = factories.CommentFactory(thread__document=document)
assert comment.get_abilities(user) == {
"destroy": False,
"update": False,
"partial_update": False,
"reactions": True,
"retrieve": True,
}
@pytest.mark.parametrize(
"link_role,link_reach",
[
(LinkRoleChoices.READER, LinkReachChoices.PUBLIC),
(LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC),
(LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC),
(LinkRoleChoices.READER, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED),
],
)
def test_comment_get_abilities_user_commenter_own_comment(link_role, link_reach):
"""Commenters have all accesses to its own comment."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_role=link_role,
link_reach=link_reach,
users=[(user, RoleChoices.COMMENTER)],
)
comment = factories.CommentFactory(thread__document=document, user=user)
assert comment.get_abilities(user) == {
"destroy": True,
"update": True,
"partial_update": True,
"reactions": True,
"retrieve": True,
}
@pytest.mark.parametrize(
"link_role,link_reach",
[
(LinkRoleChoices.READER, LinkReachChoices.PUBLIC),
(LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC),
(LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC),
(LinkRoleChoices.READER, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED),
],
)
def test_comment_get_abilities_user_editor(link_role, link_reach):
"""Editors can comment on a document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_role=link_role, link_reach=link_reach, users=[(user, RoleChoices.EDITOR)]
)
comment = factories.CommentFactory(thread__document=document)
assert comment.get_abilities(user) == {
"destroy": False,
"update": False,
"partial_update": False,
"reactions": True,
"retrieve": True,
}
@pytest.mark.parametrize(
"link_role,link_reach",
[
(LinkRoleChoices.READER, LinkReachChoices.PUBLIC),
(LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC),
(LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC),
(LinkRoleChoices.READER, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED),
],
)
def test_comment_get_abilities_user_editor_own_comment(link_role, link_reach):
"""Editors have all accesses to its own comment."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_role=link_role, link_reach=link_reach, users=[(user, RoleChoices.EDITOR)]
)
comment = factories.CommentFactory(thread__document=document, user=user)
assert comment.get_abilities(user) == {
"destroy": True,
"update": True,
"partial_update": True,
"reactions": True,
"retrieve": True,
}
def test_comment_get_abilities_user_admin():
"""Admins have all accesses to a comment."""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, RoleChoices.ADMIN)])
comment = factories.CommentFactory(
thread__document=document, user=random.choice([user, None])
)
assert comment.get_abilities(user) == {
"destroy": True,
"update": True,
"partial_update": True,
"reactions": True,
"retrieve": True,
}
def test_comment_get_abilities_user_owner():
"""Owners have all accesses to a comment."""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, RoleChoices.OWNER)])
comment = factories.CommentFactory(
thread__document=document, user=random.choice([user, None])
)
assert comment.get_abilities(user) == {
"destroy": True,
"update": True,
"partial_update": True,
"reactions": True,
"retrieve": True,
}
@@ -123,7 +123,7 @@ def test_models_document_access_get_abilities_for_owner_of_self_allowed():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "commenter", "editor", "administrator", "owner"],
"set_role_to": ["reader", "editor", "administrator", "owner"],
}
@@ -166,7 +166,7 @@ def test_models_document_access_get_abilities_for_owner_of_self_last_on_child(
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "commenter", "editor", "administrator", "owner"],
"set_role_to": ["reader", "editor", "administrator", "owner"],
}
@@ -183,7 +183,7 @@ def test_models_document_access_get_abilities_for_owner_of_owner():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "commenter", "editor", "administrator", "owner"],
"set_role_to": ["reader", "editor", "administrator", "owner"],
}
@@ -200,7 +200,7 @@ def test_models_document_access_get_abilities_for_owner_of_administrator():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "commenter", "editor", "administrator", "owner"],
"set_role_to": ["reader", "editor", "administrator", "owner"],
}
@@ -217,7 +217,7 @@ def test_models_document_access_get_abilities_for_owner_of_editor():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "commenter", "editor", "administrator", "owner"],
"set_role_to": ["reader", "editor", "administrator", "owner"],
}
@@ -234,7 +234,7 @@ def test_models_document_access_get_abilities_for_owner_of_reader():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "commenter", "editor", "administrator", "owner"],
"set_role_to": ["reader", "editor", "administrator", "owner"],
}
@@ -271,7 +271,7 @@ def test_models_document_access_get_abilities_for_administrator_of_administrator
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "commenter", "editor", "administrator"],
"set_role_to": ["reader", "editor", "administrator"],
}
@@ -288,7 +288,7 @@ def test_models_document_access_get_abilities_for_administrator_of_editor():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "commenter", "editor", "administrator"],
"set_role_to": ["reader", "editor", "administrator"],
}
@@ -305,7 +305,7 @@ def test_models_document_access_get_abilities_for_administrator_of_reader():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "commenter", "editor", "administrator"],
"set_role_to": ["reader", "editor", "administrator"],
}
+359 -200
View File
@@ -6,6 +6,7 @@ Unit tests for the Document model
import random
import smtplib
from logging import Logger
from operator import itemgetter
from unittest import mock
from django.contrib.auth.models import AnonymousUser
@@ -13,12 +14,15 @@ from django.core import mail
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.db import transaction
from django.test.utils import override_settings
from django.utils import timezone
import pytest
from core import factories, models
from core.services.search_indexers import SearchIndexer
from core.tasks.search import document_indexer_task
pytestmark = pytest.mark.django_db
@@ -134,13 +138,10 @@ def test_models_documents_soft_delete(depth):
[
(True, "restricted", "reader"),
(True, "restricted", "editor"),
(True, "restricted", "commenter"),
(False, "restricted", "reader"),
(False, "restricted", "editor"),
(False, "restricted", "commenter"),
(False, "authenticated", "reader"),
(False, "authenticated", "editor"),
(False, "authenticated", "commenter"),
],
)
def test_models_documents_get_abilities_forbidden(
@@ -168,7 +169,6 @@ def test_models_documents_get_abilities_forbidden(
"destroy": False,
"duplicate": False,
"favorite": False,
"comment": False,
"invite_owner": False,
"mask": False,
"media_auth": False,
@@ -176,8 +176,8 @@ def test_models_documents_get_abilities_forbidden(
"move": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": None,
},
"partial_update": False,
@@ -227,7 +227,6 @@ def test_models_documents_get_abilities_reader(
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"comment": False,
"descendants": True,
"cors_proxy": True,
"content": True,
@@ -237,78 +236,8 @@ def test_models_documents_get_abilities_reader(
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"restricted": None,
},
"mask": is_authenticated,
"media_auth": True,
"media_check": True,
"move": False,
"partial_update": False,
"restore": False,
"retrieve": True,
"tree": True,
"update": False,
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
}
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 not in ["link_select_options", "ancestors_links_definition"]
)
@override_settings(
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
)
@pytest.mark.parametrize(
"is_authenticated,reach",
[
(True, "public"),
(False, "public"),
(True, "authenticated"),
],
)
def test_models_documents_get_abilities_commenter(
is_authenticated, reach, django_assert_num_queries
):
"""
Check abilities returned for a document giving commenter role to link holders
i.e anonymous users or authenticated users who have no specific role on the document.
"""
document = factories.DocumentFactory(link_reach=reach, link_role="commenter")
user = factories.UserFactory() if is_authenticated else AnonymousUser()
expected_abilities = {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"can_edit": False,
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"comment": True,
"content": True,
"descendants": True,
"cors_proxy": True,
"destroy": False,
"duplicate": is_authenticated,
"favorite": is_authenticated,
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": None,
},
"mask": is_authenticated,
@@ -364,7 +293,6 @@ def test_models_documents_get_abilities_editor(
"children_create": is_authenticated,
"children_list": True,
"collaboration_auth": True,
"comment": True,
"descendants": True,
"cors_proxy": True,
"content": True,
@@ -374,8 +302,8 @@ def test_models_documents_get_abilities_editor(
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": None,
},
"mask": is_authenticated,
@@ -420,7 +348,6 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
"children_create": True,
"children_list": True,
"collaboration_auth": True,
"comment": True,
"descendants": True,
"cors_proxy": True,
"content": True,
@@ -430,8 +357,8 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
"invite_owner": True,
"link_configuration": True,
"link_select_options": {
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": None,
},
"mask": True,
@@ -462,7 +389,6 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
"children_create": False,
"children_list": False,
"collaboration_auth": False,
"comment": False,
"descendants": False,
"cors_proxy": False,
"content": False,
@@ -472,8 +398,8 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": None,
},
"mask": False,
@@ -508,7 +434,6 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
"children_create": True,
"children_list": True,
"collaboration_auth": True,
"comment": True,
"descendants": True,
"cors_proxy": True,
"content": True,
@@ -518,8 +443,8 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
"invite_owner": False,
"link_configuration": True,
"link_select_options": {
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": None,
},
"mask": True,
@@ -564,7 +489,6 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
"children_create": True,
"children_list": True,
"collaboration_auth": True,
"comment": True,
"descendants": True,
"cors_proxy": True,
"content": True,
@@ -574,8 +498,8 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": None,
},
"mask": True,
@@ -627,8 +551,6 @@ def test_models_documents_get_abilities_reader_user(
"children_create": access_from_link,
"children_list": True,
"collaboration_auth": True,
"comment": document.link_reach != "restricted"
and document.link_role in ["commenter", "editor"],
"descendants": True,
"cors_proxy": True,
"content": True,
@@ -638,73 +560,8 @@ def test_models_documents_get_abilities_reader_user(
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"restricted": None,
},
"mask": True,
"media_auth": True,
"media_check": True,
"move": False,
"partial_update": access_from_link,
"restore": False,
"retrieve": True,
"tree": True,
"update": access_from_link,
"versions_destroy": False,
"versions_list": True,
"versions_retrieve": True,
}
with override_settings(AI_ALLOW_REACH_FROM=ai_access_setting):
with django_assert_num_queries(1):
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 not in ["link_select_options", "ancestors_links_definition"]
)
@pytest.mark.parametrize("ai_access_setting", ["public", "authenticated", "restricted"])
def test_models_documents_get_abilities_commenter_user(
ai_access_setting, django_assert_num_queries
):
"""Check abilities returned for the commenter of a document."""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, "commenter")])
access_from_link = (
document.link_reach != "restricted" and document.link_role == "editor"
)
expected_abilities = {
"accesses_manage": False,
"accesses_view": True,
# If you get your editor rights from the link role and not your access role
# You should not access AI if it's restricted to users with specific access
"ai_transform": access_from_link and ai_access_setting != "restricted",
"ai_translate": access_from_link and ai_access_setting != "restricted",
"attachment_upload": access_from_link,
"can_edit": access_from_link,
"children_create": access_from_link,
"children_list": True,
"collaboration_auth": True,
"comment": True,
"content": True,
"descendants": True,
"cors_proxy": True,
"destroy": False,
"duplicate": True,
"favorite": True,
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": None,
},
"mask": True,
@@ -754,7 +611,6 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"comment": False,
"descendants": True,
"cors_proxy": True,
"content": True,
@@ -764,8 +620,8 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": None,
},
"mask": True,
@@ -1468,14 +1324,7 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
"public",
"reader",
{
"public": ["reader", "commenter", "editor"],
},
),
(
"public",
"commenter",
{
"public": ["commenter", "editor"],
"public": ["reader", "editor"],
},
),
("public", "editor", {"public": ["editor"]}),
@@ -1483,16 +1332,8 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
"authenticated",
"reader",
{
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
},
),
(
"authenticated",
"commenter",
{
"authenticated": ["commenter", "editor"],
"public": ["commenter", "editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
},
),
(
@@ -1505,17 +1346,8 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
"reader",
{
"restricted": None,
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
},
),
(
"restricted",
"commenter",
{
"restricted": None,
"authenticated": ["commenter", "editor"],
"public": ["commenter", "editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
},
),
(
@@ -1532,15 +1364,15 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
"public",
None,
{
"public": ["reader", "commenter", "editor"],
"public": ["reader", "editor"],
},
),
(
None,
"reader",
{
"public": ["reader", "commenter", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "editor"],
"restricted": None,
},
),
@@ -1548,8 +1380,8 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
None,
None,
{
"public": ["reader", "commenter", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "editor"],
"restricted": None,
},
),
@@ -1617,3 +1449,330 @@ def test_models_documents_compute_ancestors_links_paths_mapping_structure(
{"link_reach": sibling.link_reach, "link_role": sibling.link_role},
],
}
@mock.patch.object(SearchIndexer, "push")
@pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer(mock_push, indexer_settings):
"""Test indexation task on document creation"""
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
with transaction.atomic():
doc1, doc2, doc3 = factories.DocumentFactory.create_batch(3)
accesses = {}
data = [call.args[0] for call in mock_push.call_args_list]
indexer = SearchIndexer()
assert sorted(data, key=itemgetter("id")) == sorted(
[
indexer.serialize_document(doc1, accesses),
indexer.serialize_document(doc2, accesses),
indexer.serialize_document(doc3, accesses),
],
key=itemgetter("id"),
)
# The throttle counters should be reset
assert cache.get(f"doc-indexer-throttle-{doc1.pk}") is None
assert cache.get(f"doc-indexer-throttle-{doc2.pk}") is None
assert cache.get(f"doc-indexer-throttle-{doc3.pk}") is None
@mock.patch.object(SearchIndexer, "push")
@pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer_not_configured(mock_push, indexer_settings):
"""Task should not start an indexation when disabled"""
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
indexer_settings.SEARCH_INDEXER_CLASS = None
user = factories.UserFactory()
with transaction.atomic():
doc = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=doc, user=user)
assert mock_push.assert_not_called
@mock.patch.object(SearchIndexer, "push")
@pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer_wrongly_configured(
mock_push, indexer_settings
):
"""Task should not start an indexation when disabled"""
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
indexer_settings.SEARCH_INDEXER_URL = None
user = factories.UserFactory()
with transaction.atomic():
doc = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=doc, user=user)
assert mock_push.assert_not_called
@mock.patch.object(SearchIndexer, "push")
@pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer_with_accesses(mock_push, indexer_settings):
"""Test indexation task on document creation"""
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
user = factories.UserFactory()
with transaction.atomic():
doc1, doc2, doc3 = factories.DocumentFactory.create_batch(3)
factories.UserDocumentAccessFactory(document=doc1, user=user)
factories.UserDocumentAccessFactory(document=doc2, user=user)
factories.UserDocumentAccessFactory(document=doc3, user=user)
accesses = {
str(doc1.path): {"users": [user.sub]},
str(doc2.path): {"users": [user.sub]},
str(doc3.path): {"users": [user.sub]},
}
data = [call.args[0] for call in mock_push.call_args_list]
indexer = SearchIndexer()
assert sorted(data, key=itemgetter("id")) == sorted(
[
indexer.serialize_document(doc1, accesses),
indexer.serialize_document(doc2, accesses),
indexer.serialize_document(doc3, accesses),
],
key=itemgetter("id"),
)
# The throttle counters should be reset
assert cache.get(f"doc-indexer-throttle-{doc1.pk}") is None
assert cache.get(f"doc-indexer-throttle-{doc2.pk}") is None
assert cache.get(f"doc-indexer-throttle-{doc3.pk}") is None
@mock.patch.object(SearchIndexer, "push")
@pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer_deleted(mock_push, indexer_settings):
"""Indexation task on deleted or ancestor_deleted documents"""
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
user = factories.UserFactory()
with transaction.atomic():
doc = factories.DocumentFactory(
link_reach=models.LinkReachChoices.AUTHENTICATED
)
doc_deleted = factories.DocumentFactory(
link_reach=models.LinkReachChoices.AUTHENTICATED
)
doc_ancestor_deleted = factories.DocumentFactory(
parent=doc_deleted,
link_reach=models.LinkReachChoices.AUTHENTICATED,
)
doc_deleted.soft_delete()
doc_ancestor_deleted.ancestors_deleted_at = doc_deleted.deleted_at
factories.UserDocumentAccessFactory(document=doc, user=user)
factories.UserDocumentAccessFactory(document=doc_deleted, user=user)
factories.UserDocumentAccessFactory(document=doc_ancestor_deleted, user=user)
doc_deleted.refresh_from_db()
doc_ancestor_deleted.refresh_from_db()
assert doc_deleted.deleted_at is not None
assert doc_deleted.ancestors_deleted_at is not None
assert doc_ancestor_deleted.deleted_at is None
assert doc_ancestor_deleted.ancestors_deleted_at is not None
accesses = {
str(doc.path): {"users": [user.sub]},
str(doc_deleted.path): {"users": [user.sub]},
str(doc_ancestor_deleted.path): {"users": [user.sub]},
}
data = [call.args[0] for call in mock_push.call_args_list]
indexer = SearchIndexer()
# Even deleted document are re-indexed : only update their status in the future ?
assert sorted(data, key=itemgetter("id")) == sorted(
[
indexer.serialize_document(doc, accesses),
indexer.serialize_document(doc_deleted, accesses),
indexer.serialize_document(doc_ancestor_deleted, accesses),
indexer.serialize_document(doc_deleted, accesses), # soft_delete()
],
key=itemgetter("id"),
)
# The throttle counters should be reset
assert cache.get(f"doc-indexer-throttle-{doc.pk}") is None
assert cache.get(f"doc-indexer-throttle-{doc_deleted.pk}") is None
assert cache.get(f"doc-indexer-throttle-{doc_ancestor_deleted.pk}") is None
@pytest.mark.django_db(transaction=True)
def test_models_documents_indexer_hard_deleted(indexer_settings):
"""Indexation task on hard deleted document"""
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
user = factories.UserFactory()
with transaction.atomic():
doc = factories.DocumentFactory(
link_reach=models.LinkReachChoices.AUTHENTICATED
)
factories.UserDocumentAccessFactory(document=doc, user=user)
doc_id = doc.pk
doc.delete()
# Call task on deleted document.
document_indexer_task.apply(args=[doc_id])
with mock.patch.object(SearchIndexer, "push") as mock_push:
# Hard delete document are not re-indexed.
assert mock_push.assert_not_called
@mock.patch.object(SearchIndexer, "push")
@pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer_restored(mock_push, indexer_settings):
"""Restart indexation task on restored documents"""
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
user = factories.UserFactory()
with transaction.atomic():
doc = factories.DocumentFactory(
link_reach=models.LinkReachChoices.AUTHENTICATED
)
doc_deleted = factories.DocumentFactory(
link_reach=models.LinkReachChoices.AUTHENTICATED
)
doc_ancestor_deleted = factories.DocumentFactory(
parent=doc_deleted,
link_reach=models.LinkReachChoices.AUTHENTICATED,
)
doc_deleted.soft_delete()
doc_ancestor_deleted.ancestors_deleted_at = doc_deleted.deleted_at
factories.UserDocumentAccessFactory(document=doc, user=user)
factories.UserDocumentAccessFactory(document=doc_deleted, user=user)
factories.UserDocumentAccessFactory(document=doc_ancestor_deleted, user=user)
doc_deleted.refresh_from_db()
doc_ancestor_deleted.refresh_from_db()
assert doc_deleted.deleted_at is not None
assert doc_deleted.ancestors_deleted_at is not None
assert doc_ancestor_deleted.deleted_at is None
assert doc_ancestor_deleted.ancestors_deleted_at is not None
doc_restored = models.Document.objects.get(pk=doc_deleted.pk)
doc_restored.restore()
doc_ancestor_restored = models.Document.objects.get(pk=doc_ancestor_deleted.pk)
assert doc_restored.deleted_at is None
assert doc_restored.ancestors_deleted_at is None
assert doc_ancestor_restored.deleted_at is None
assert doc_ancestor_restored.ancestors_deleted_at is None
accesses = {
str(doc.path): {"users": [user.sub]},
str(doc_deleted.path): {"users": [user.sub]},
str(doc_ancestor_deleted.path): {"users": [user.sub]},
}
data = [call.args[0] for call in mock_push.call_args_list]
indexer = SearchIndexer()
# All docs are re-indexed
assert sorted(data, key=itemgetter("id")) == sorted(
[
indexer.serialize_document(doc, accesses),
indexer.serialize_document(doc_deleted, accesses),
indexer.serialize_document(doc_deleted, accesses), # soft_delete()
indexer.serialize_document(doc_restored, accesses), # restore()
indexer.serialize_document(doc_ancestor_deleted, accesses),
],
key=itemgetter("id"),
)
@pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer_throttle(indexer_settings):
"""Test indexation task skipping on document update"""
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
indexer = SearchIndexer()
user = factories.UserFactory()
with mock.patch.object(SearchIndexer, "push"):
with transaction.atomic():
doc = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=doc, user=user)
accesses = {
str(doc.path): {"users": [user.sub]},
}
with mock.patch.object(SearchIndexer, "push") as mock_push:
# Simulate 1 running task
cache.set(f"doc-indexer-throttle-{doc.pk}", 1)
# save doc to trigger the indexer, but nothing should be done since
# the flag is up
with transaction.atomic():
doc.save()
assert [call.args[0] for call in mock_push.call_args_list] == []
with mock.patch.object(SearchIndexer, "push") as mock_push:
# No waiting task
cache.delete(f"doc-indexer-throttle-{doc.pk}")
with transaction.atomic():
doc = models.Document.objects.get(pk=doc.pk)
doc.save()
assert [call.args[0] for call in mock_push.call_args_list] == [
indexer.serialize_document(doc, accesses),
]
@pytest.mark.django_db(transaction=True)
def test_models_documents_access_post_save_indexer(indexer_settings):
"""Test indexation task on DocumentAccess update"""
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
indexer = SearchIndexer()
user = factories.UserFactory()
with mock.patch.object(SearchIndexer, "push"):
with transaction.atomic():
doc = factories.DocumentFactory()
doc_access = factories.UserDocumentAccessFactory(document=doc, user=user)
accesses = {
str(doc.path): {"users": [user.sub]},
}
indexer = SearchIndexer()
with mock.patch.object(SearchIndexer, "push") as mock_push:
with transaction.atomic():
doc_access.save()
assert [call.args[0] for call in mock_push.call_args_list] == [
indexer.serialize_document(doc, accesses),
]
@@ -0,0 +1,540 @@
"""Tests for Documents search indexers"""
from functools import partial
from json import dumps as json_dumps
from unittest.mock import patch
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ImproperlyConfigured
from django.utils.module_loading import import_string
import pytest
import responses
from requests import HTTPError
from core import factories, models, utils
from core.services.search_indexers import (
BaseDocumentIndexer,
SearchIndexer,
get_document_indexer,
get_visited_document_ids_of,
)
pytestmark = pytest.mark.django_db
class FakeDocumentIndexer(BaseDocumentIndexer):
"""Fake indexer for test purpose"""
def serialize_document(self, document, accesses):
return {}
def push(self, data):
pass
def search_query(self, data, token):
return {}
def test_services_search_indexer_class_invalid(indexer_settings):
"""
Should raise RuntimeError if SEARCH_INDEXER_CLASS cannot be imported.
"""
indexer_settings.SEARCH_INDEXER_CLASS = "unknown.Unknown"
assert get_document_indexer() is None
def test_services_search_indexer_class(indexer_settings):
"""
Import indexer class defined in setting SEARCH_INDEXER_CLASS.
"""
indexer_settings.SEARCH_INDEXER_CLASS = (
"core.tests.test_services_search_indexers.FakeDocumentIndexer"
)
assert isinstance(
get_document_indexer(),
import_string("core.tests.test_services_search_indexers.FakeDocumentIndexer"),
)
def test_services_search_indexer_is_configured(indexer_settings):
"""
Should return true only when the indexer class and other configuration settings
are valid.
"""
indexer_settings.SEARCH_INDEXER_CLASS = None
# None
get_document_indexer.cache_clear()
assert not get_document_indexer()
# Empty
indexer_settings.SEARCH_INDEXER_CLASS = ""
get_document_indexer.cache_clear()
assert not get_document_indexer()
# Valid class
indexer_settings.SEARCH_INDEXER_CLASS = (
"core.services.search_indexers.SearchIndexer"
)
get_document_indexer.cache_clear()
assert get_document_indexer() is not None
indexer_settings.SEARCH_INDEXER_URL = ""
# Invalid url
get_document_indexer.cache_clear()
assert not get_document_indexer()
def test_services_search_indexer_url_is_none(indexer_settings):
"""
Indexer should raise RuntimeError if SEARCH_INDEXER_URL is None or empty.
"""
indexer_settings.SEARCH_INDEXER_URL = None
with pytest.raises(ImproperlyConfigured) as exc_info:
SearchIndexer()
assert "SEARCH_INDEXER_URL must be set in Django settings." in str(exc_info.value)
def test_services_search_indexer_url_is_empty(indexer_settings):
"""
Indexer should raise RuntimeError if SEARCH_INDEXER_URL is empty string.
"""
indexer_settings.SEARCH_INDEXER_URL = ""
with pytest.raises(ImproperlyConfigured) as exc_info:
SearchIndexer()
assert "SEARCH_INDEXER_URL must be set in Django settings." in str(exc_info.value)
def test_services_search_indexer_secret_is_none(indexer_settings):
"""
Indexer should raise RuntimeError if SEARCH_INDEXER_SECRET is None.
"""
indexer_settings.SEARCH_INDEXER_SECRET = None
with pytest.raises(ImproperlyConfigured) as exc_info:
SearchIndexer()
assert "SEARCH_INDEXER_SECRET must be set in Django settings." in str(
exc_info.value
)
def test_services_search_indexer_secret_is_empty(indexer_settings):
"""
Indexer should raise RuntimeError if SEARCH_INDEXER_SECRET is empty string.
"""
indexer_settings.SEARCH_INDEXER_SECRET = ""
with pytest.raises(ImproperlyConfigured) as exc_info:
SearchIndexer()
assert "SEARCH_INDEXER_SECRET must be set in Django settings." in str(
exc_info.value
)
def test_services_search_endpoint_is_none(indexer_settings):
"""
Indexer should raise RuntimeError if SEARCH_INDEXER_QUERY_URL is None.
"""
indexer_settings.SEARCH_INDEXER_QUERY_URL = None
with pytest.raises(ImproperlyConfigured) as exc_info:
SearchIndexer()
assert "SEARCH_INDEXER_QUERY_URL must be set in Django settings." in str(
exc_info.value
)
def test_services_search_endpoint_is_empty(indexer_settings):
"""
Indexer should raise RuntimeError if SEARCH_INDEXER_QUERY_URL is empty.
"""
indexer_settings.SEARCH_INDEXER_QUERY_URL = ""
with pytest.raises(ImproperlyConfigured) as exc_info:
SearchIndexer()
assert "SEARCH_INDEXER_QUERY_URL must be set in Django settings." in str(
exc_info.value
)
@pytest.mark.usefixtures("indexer_settings")
def test_services_search_indexers_serialize_document_returns_expected_json():
"""
It should serialize documents with correct metadata and access control.
"""
user_a, user_b = factories.UserFactory.create_batch(2)
document = factories.DocumentFactory()
factories.DocumentFactory(parent=document)
factories.UserDocumentAccessFactory(document=document, user=user_a)
factories.UserDocumentAccessFactory(document=document, user=user_b)
factories.TeamDocumentAccessFactory(document=document, team="team1")
factories.TeamDocumentAccessFactory(document=document, team="team2")
accesses = {
document.path: {
"users": {str(user_a.sub), str(user_b.sub)},
"teams": {"team1", "team2"},
}
}
indexer = SearchIndexer()
result = indexer.serialize_document(document, accesses)
assert set(result.pop("users")) == {str(user_a.sub), str(user_b.sub)}
assert set(result.pop("groups")) == {"team1", "team2"}
assert result == {
"id": str(document.id),
"title": document.title,
"depth": 1,
"path": document.path,
"numchild": 1,
"content": utils.base64_yjs_to_text(document.content),
"created_at": document.created_at.isoformat(),
"updated_at": document.updated_at.isoformat(),
"reach": document.link_reach,
"size": 13,
"is_active": True,
}
@pytest.mark.usefixtures("indexer_settings")
def test_services_search_indexers_serialize_document_deleted():
"""Deleted documents are marked as just in the serialized json."""
parent = factories.DocumentFactory()
document = factories.DocumentFactory(parent=parent)
parent.soft_delete()
document.refresh_from_db()
indexer = SearchIndexer()
result = indexer.serialize_document(document, {})
assert result["is_active"] is False
@pytest.mark.usefixtures("indexer_settings")
def test_services_search_indexers_serialize_document_empty():
"""Empty documents returns empty content in the serialized json."""
document = factories.DocumentFactory(content="", title=None)
indexer = SearchIndexer()
result = indexer.serialize_document(document, {})
assert result["content"] == ""
assert result["title"] == ""
@responses.activate
def test_services_search_indexers_index_errors(indexer_settings):
"""
Documents indexing response handling on Find API HTTP errors.
"""
factories.DocumentFactory()
indexer_settings.SEARCH_INDEXER_URL = "http://app-find/api/v1.0/documents/index/"
responses.add(
responses.POST,
"http://app-find/api/v1.0/documents/index/",
status=401,
body=json_dumps({"message": "Authentication failed."}),
)
with pytest.raises(HTTPError):
SearchIndexer().index()
@patch.object(SearchIndexer, "push")
def test_services_search_indexers_batches_pass_only_batch_accesses(
mock_push, indexer_settings
):
"""
Documents indexing should be processed in batches,
and only the access data relevant to each batch should be used.
"""
indexer_settings.SEARCH_INDEXER_BATCH_SIZE = 2
documents = factories.DocumentFactory.create_batch(5)
# Attach a single user access to each document
expected_user_subs = {}
for document in documents:
access = factories.UserDocumentAccessFactory(document=document)
expected_user_subs[str(document.id)] = str(access.user.sub)
assert SearchIndexer().index() == 5
# Should be 3 batches: 2 + 2 + 1
assert mock_push.call_count == 3
seen_doc_ids = set()
for call in mock_push.call_args_list:
batch = call.args[0]
assert isinstance(batch, list)
for doc_json in batch:
doc_id = doc_json["id"]
seen_doc_ids.add(doc_id)
# Only one user expected per document
assert doc_json["users"] == [expected_user_subs[doc_id]]
assert doc_json["groups"] == []
# Make sure all 5 documents were indexed
assert seen_doc_ids == {str(d.id) for d in documents}
@patch.object(SearchIndexer, "push")
@pytest.mark.usefixtures("indexer_settings")
def test_services_search_indexers_ignore_empty_documents(mock_push):
"""
Documents indexing should be processed in batches,
and only the access data relevant to each batch should be used.
"""
document = factories.DocumentFactory()
factories.DocumentFactory(content="", title="")
empty_title = factories.DocumentFactory(title="")
empty_content = factories.DocumentFactory(content="")
assert SearchIndexer().index() == 3
assert mock_push.call_count == 1
# Make sure only not eempty documents are indexed
results = {doc["id"] for doc in mock_push.call_args[0][0]}
assert results == {
str(d.id)
for d in (
document,
empty_content,
empty_title,
)
}
@patch.object(SearchIndexer, "push")
@pytest.mark.usefixtures("indexer_settings")
def test_services_search_indexers_ancestors_link_reach(mock_push):
"""Document accesses and reach should take into account ancestors link reaches."""
great_grand_parent = factories.DocumentFactory(link_reach="restricted")
grand_parent = factories.DocumentFactory(
parent=great_grand_parent, link_reach="authenticated"
)
parent = factories.DocumentFactory(parent=grand_parent, link_reach="public")
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
assert SearchIndexer().index() == 4
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
assert len(results) == 4
assert results[str(great_grand_parent.id)]["reach"] == "restricted"
assert results[str(grand_parent.id)]["reach"] == "authenticated"
assert results[str(parent.id)]["reach"] == "public"
assert results[str(document.id)]["reach"] == "public"
@patch.object(SearchIndexer, "push")
@pytest.mark.usefixtures("indexer_settings")
def test_services_search_indexers_ancestors_users(mock_push):
"""Document accesses and reach should include users from ancestors."""
user_gp, user_p, user_d = factories.UserFactory.create_batch(3)
grand_parent = factories.DocumentFactory(users=[user_gp])
parent = factories.DocumentFactory(parent=grand_parent, users=[user_p])
document = factories.DocumentFactory(parent=parent, users=[user_d])
assert SearchIndexer().index() == 3
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
assert len(results) == 3
assert results[str(grand_parent.id)]["users"] == [str(user_gp.sub)]
assert set(results[str(parent.id)]["users"]) == {str(user_gp.sub), str(user_p.sub)}
assert set(results[str(document.id)]["users"]) == {
str(user_gp.sub),
str(user_p.sub),
str(user_d.sub),
}
@patch.object(SearchIndexer, "push")
@pytest.mark.usefixtures("indexer_settings")
def test_services_search_indexers_ancestors_teams(mock_push):
"""Document accesses and reach should include teams from ancestors."""
grand_parent = factories.DocumentFactory(teams=["team_gp"])
parent = factories.DocumentFactory(parent=grand_parent, teams=["team_p"])
document = factories.DocumentFactory(parent=parent, teams=["team_d"])
assert SearchIndexer().index() == 3
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
assert len(results) == 3
assert results[str(grand_parent.id)]["groups"] == ["team_gp"]
assert set(results[str(parent.id)]["groups"]) == {"team_gp", "team_p"}
assert set(results[str(document.id)]["groups"]) == {"team_gp", "team_p", "team_d"}
@patch("requests.post")
def test_push_uses_correct_url_and_data(mock_post, indexer_settings):
"""
push() should call requests.post with the correct URL from settings
the timeout set to 10 seconds and the data as JSON.
"""
indexer_settings.SEARCH_INDEXER_URL = "http://example.com/index"
indexer = SearchIndexer()
sample_data = [{"id": "123", "title": "Test"}]
mock_response = mock_post.return_value
mock_response.raise_for_status.return_value = None # No error
indexer.push(sample_data)
mock_post.assert_called_once()
args, kwargs = mock_post.call_args
assert args[0] == indexer_settings.SEARCH_INDEXER_URL
assert kwargs.get("json") == sample_data
assert kwargs.get("timeout") == 10
def test_get_visited_document_ids_of():
"""
get_visited_document_ids_of() returns the ids of the documents viewed
by the user BUT without specific access configuration (like public ones)
"""
user = factories.UserFactory()
other = factories.UserFactory()
anonymous = AnonymousUser()
queryset = models.Document.objects.all()
assert not get_visited_document_ids_of(queryset, anonymous)
assert not get_visited_document_ids_of(queryset, user)
doc1, doc2, _ = factories.DocumentFactory.create_batch(3)
create_link = partial(models.LinkTrace.objects.create, user=user, is_masked=False)
create_link(document=doc1)
create_link(document=doc2)
# The third document is not visited
assert sorted(get_visited_document_ids_of(queryset, user)) == sorted(
[str(doc1.pk), str(doc2.pk)]
)
factories.UserDocumentAccessFactory(user=other, document=doc1)
factories.UserDocumentAccessFactory(user=user, document=doc2)
# The second document have an access for the user
assert get_visited_document_ids_of(queryset, user) == [str(doc1.pk)]
@pytest.mark.usefixtures("indexer_settings")
def test_get_visited_document_ids_of_deleted():
"""
get_visited_document_ids_of() returns the ids of the documents viewed
by the user if they are not deleted.
"""
user = factories.UserFactory()
anonymous = AnonymousUser()
queryset = models.Document.objects.all()
assert not get_visited_document_ids_of(queryset, anonymous)
assert not get_visited_document_ids_of(queryset, user)
doc = factories.DocumentFactory()
doc_deleted = factories.DocumentFactory()
doc_ancestor_deleted = factories.DocumentFactory(parent=doc_deleted)
create_link = partial(models.LinkTrace.objects.create, user=user, is_masked=False)
create_link(document=doc)
create_link(document=doc_deleted)
create_link(document=doc_ancestor_deleted)
# The all documents are visited
assert sorted(get_visited_document_ids_of(queryset, user)) == sorted(
[str(doc.pk), str(doc_deleted.pk), str(doc_ancestor_deleted.pk)]
)
doc_deleted.soft_delete()
# Only the first document is not deleted
assert get_visited_document_ids_of(queryset, user) == [str(doc.pk)]
@responses.activate
def test_services_search_indexers_search_errors(indexer_settings):
"""
Documents indexing response handling on Find API HTTP errors.
"""
factories.DocumentFactory()
indexer_settings.SEARCH_INDEXER_QUERY_URL = (
"http://app-find/api/v1.0/documents/search/"
)
responses.add(
responses.POST,
"http://app-find/api/v1.0/documents/search/",
status=401,
body=json_dumps({"message": "Authentication failed."}),
)
with pytest.raises(HTTPError):
SearchIndexer().search("alpha", token="mytoken")
@patch("requests.post")
def test_services_search_indexers_search(mock_post, indexer_settings):
"""
search() should call requests.post to SEARCH_INDEXER_QUERY_URL with the
document ids from linktraces.
"""
user = factories.UserFactory()
indexer = SearchIndexer()
mock_response = mock_post.return_value
mock_response.raise_for_status.return_value = None # No error
doc1, doc2, _ = factories.DocumentFactory.create_batch(3)
create_link = partial(models.LinkTrace.objects.create, user=user, is_masked=False)
create_link(document=doc1)
create_link(document=doc2)
visited = get_visited_document_ids_of(models.Document.objects.all(), user)
indexer.search("alpha", visited=visited, token="mytoken")
args, kwargs = mock_post.call_args
assert args[0] == indexer_settings.SEARCH_INDEXER_QUERY_URL
query_data = kwargs.get("json")
assert query_data["q"] == "alpha"
assert sorted(query_data["visited"]) == sorted([str(doc1.pk), str(doc2.pk)])
assert query_data["services"] == ["docs"]
assert query_data["page_number"] == 1
assert query_data["page_size"] == 50
assert query_data["order_by"] == "updated_at"
assert query_data["order_direction"] == "desc"
assert kwargs.get("headers") == {"Authorization": "Bearer mytoken"}
assert kwargs.get("timeout") == 10
+25
View File
@@ -75,3 +75,28 @@ def test_utils_extract_attachments():
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]
def test_utils_get_ancestor_to_descendants_map_single_path():
"""Test ancestor mapping of a single path."""
paths = ["000100020005"]
result = utils.get_ancestor_to_descendants_map(paths, steplen=4)
assert result == {
"0001": {"000100020005"},
"00010002": {"000100020005"},
"000100020005": {"000100020005"},
}
def test_utils_get_ancestor_to_descendants_map_multiple_paths():
"""Test ancestor mapping of multiple paths with shared prefixes."""
paths = ["000100020005", "00010003"]
result = utils.get_ancestor_to_descendants_map(paths, steplen=4)
assert result == {
"0001": {"000100020005", "00010003"},
"00010002": {"000100020005"},
"000100020005": {"000100020005"},
"00010003": {"00010003"},
}
+10 -12
View File
@@ -26,22 +26,20 @@ document_related_router.register(
viewsets.InvitationViewset,
basename="invitations",
)
document_related_router.register(
"threads",
viewsets.ThreadViewSet,
basename="threads",
)
document_related_router.register(
"ask-for-access",
viewsets.DocumentAskForAccessViewSet,
basename="ask_for_access",
)
thread_related_router = DefaultRouter()
thread_related_router.register(
"comments",
viewsets.CommentViewSet,
basename="comments",
# - Routes nested under a template
template_related_router = DefaultRouter()
template_related_router.register(
"accesses",
viewsets.TemplateAccessViewSet,
basename="template_accesses",
)
@@ -57,8 +55,8 @@ urlpatterns = [
include(document_related_router.urls),
),
re_path(
r"^documents/(?P<resource_id>[0-9a-z-]*)/threads/(?P<thread_id>[0-9a-z-]*)/",
include(thread_related_router.urls),
r"^templates/(?P<resource_id>[0-9a-z-]*)/",
include(template_related_router.urls),
),
]
),
+22
View File
@@ -2,6 +2,7 @@
import base64
import re
from collections import defaultdict
import pycrdt
from bs4 import BeautifulSoup
@@ -9,6 +10,27 @@ from bs4 import BeautifulSoup
from core import enums
def get_ancestor_to_descendants_map(paths, steplen):
"""
Given a list of document paths, return a mapping of ancestor_path -> set of descendant_paths.
Each path is assumed to use materialized path format with fixed-length segments.
Args:
paths (list of str): List of full document paths.
steplen (int): Length of each path segment.
Returns:
dict[str, set[str]]: Mapping from ancestor path to its descendant paths (including itself).
"""
ancestor_map = defaultdict(set)
for path in paths:
for i in range(steplen, len(path) + 1, steplen):
ancestor = path[:i]
ancestor_map[ancestor].add(path)
return ancestor_map
def filter_descendants(paths, root_paths, skip_sorting=False):
"""
Filters paths to keep only those that are descendants of any path in root_paths.
@@ -1,16 +1,19 @@
# ruff: noqa: S311, S106
"""create_demo management command"""
import base64
import logging
import math
import random
import time
from collections import defaultdict
from uuid import uuid4
from django import db
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
import pycrdt
from faker import Faker
from core import models
@@ -27,6 +30,16 @@ def random_true_with_probability(probability):
return random.random() < probability
def get_ydoc_for_text(text):
"""Return a ydoc from plain text for demo purposes."""
ydoc = pycrdt.Doc()
paragraph = pycrdt.XmlElement("p", {}, [pycrdt.XmlText(text)])
fragment = pycrdt.XmlFragment([paragraph])
ydoc["document-store"] = fragment
update = ydoc.get_update()
return base64.b64encode(update).decode("utf-8")
class BulkQueue:
"""A utility class to create Django model instances in bulk by just pushing to a queue."""
@@ -48,7 +61,7 @@ class BulkQueue:
self.queue[objects[0]._meta.model.__name__] = [] # noqa: SLF001
def push(self, obj):
"""Add a model instance to queue to that it gets created in bulk."""
"""Add a model instance to queue so that it gets created in bulk."""
objects = self.queue[obj._meta.model.__name__] # noqa: SLF001
objects.append(obj)
if len(objects) > self.BATCH_SIZE:
@@ -139,17 +152,19 @@ def create_demo(stdout):
# pylint: disable=protected-access
key = models.Document._int2str(i) # noqa: SLF001
padding = models.Document.alphabet[0] * (models.Document.steplen - len(key))
queue.push(
models.Document(
depth=1,
path=f"{padding}{key}",
creator_id=random.choice(users_ids),
title=fake.sentence(nb_words=4),
link_reach=models.LinkReachChoices.AUTHENTICATED
if random_true_with_probability(0.5)
else random.choice(models.LinkReachChoices.values),
)
title = fake.sentence(nb_words=4)
document = models.Document(
id=uuid4(),
depth=1,
path=f"{padding}{key}",
creator_id=random.choice(users_ids),
title=title,
link_reach=models.LinkReachChoices.AUTHENTICATED
if random_true_with_probability(0.5)
else random.choice(models.LinkReachChoices.values),
)
document.save_content(get_ydoc_for_text(f"Content for {title:s}"))
queue.push(document)
queue.flush()
+22
View File
@@ -99,6 +99,28 @@ class Base(Configuration):
}
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
# Search
SEARCH_INDEXER_CLASS = values.Value(
default=None,
environ_name="SEARCH_INDEXER_CLASS",
environ_prefix=None,
)
SEARCH_INDEXER_BATCH_SIZE = values.IntegerValue(
default=100_000, environ_name="SEARCH_INDEXER_BATCH_SIZE", environ_prefix=None
)
SEARCH_INDEXER_URL = values.Value(
default=None, environ_name="SEARCH_INDEXER_URL", environ_prefix=None
)
SEARCH_INDEXER_COUNTDOWN = values.IntegerValue(
default=1, environ_name="SEARCH_INDEXER_COUNTDOWN", environ_prefix=None
)
SEARCH_INDEXER_SECRET = values.Value(
default=None, environ_name="SEARCH_INDEXER_SECRET", environ_prefix=None
)
SEARCH_INDEXER_QUERY_URL = values.Value(
default=None, environ_name="SEARCH_INDEXER_QUERY_URL", environ_prefix=None
)
# Static files (CSS, JavaScript, Images)
STATIC_URL = "/static/"
STATIC_ROOT = os.path.join(DATA_DIR, "static")
+65 -65
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
"PO-Revision-Date: 2025-11-19 10:13\n"
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
"PO-Revision-Date: 2025-10-14 13:09\n"
"Last-Translator: \n"
"Language-Team: Breton\n"
"Language: br_FR\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:36 core/admin.py:36
#: build/lib/core/admin.py:37 core/admin.py:37
msgid "Personal info"
msgstr "Titouroù personel"
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr "Aotreoù"
#: build/lib/core/admin.py:61 core/admin.py:61
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
msgstr "Deiziadoù a-bouez"
#: build/lib/core/admin.py:147 core/admin.py:147
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr "Gwezennadur"
@@ -79,7 +79,7 @@ msgstr "Doare korf"
msgid "Format"
msgstr "Stumm"
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
#, python-brace-format
msgid "copy of {title}"
msgstr "eilenn {title}"
@@ -178,228 +178,228 @@ msgstr ""
msgid "full name"
msgstr "anv klok"
#: build/lib/core/models.py:152 core/models.py:152
#: build/lib/core/models.py:151 core/models.py:151
msgid "short name"
msgstr "anv berr"
#: build/lib/core/models.py:155 core/models.py:155
#: build/lib/core/models.py:153 core/models.py:153
msgid "identity email address"
msgstr "postel identelezh"
#: build/lib/core/models.py:160 core/models.py:160
#: build/lib/core/models.py:158 core/models.py:158
msgid "admin email address"
msgstr "postel ar merour"
#: build/lib/core/models.py:167 core/models.py:167
#: build/lib/core/models.py:165 core/models.py:165
msgid "language"
msgstr "yezh"
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:166 core/models.py:166
msgid "The language in which the user wants to see the interface."
msgstr "Ar yezh a vo implijet evit etrefas an implijer."
#: build/lib/core/models.py:176 core/models.py:176
#: build/lib/core/models.py:174 core/models.py:174
msgid "The timezone in which the user wants to see times."
msgstr "Ar gwerzhid-eur a vo implijet evit etrefas an implijer."
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:177 core/models.py:177
msgid "device"
msgstr "trevnad"
#: build/lib/core/models.py:181 core/models.py:181
#: build/lib/core/models.py:179 core/models.py:179
msgid "Whether the user is a device or a real user."
msgstr "Pe vefe an implijer un aparailh pe un implijer gwirion."
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:182 core/models.py:182
msgid "staff status"
msgstr "statud ar skipailh"
#: build/lib/core/models.py:186 core/models.py:186
#: build/lib/core/models.py:184 core/models.py:184
msgid "Whether the user can log into this admin site."
msgstr "Ma c'hall an implijer kevreañ ouzh al lec'hienn verañ-mañ."
#: build/lib/core/models.py:189 core/models.py:189
#: build/lib/core/models.py:187 core/models.py:187
msgid "active"
msgstr "oberiant"
#: build/lib/core/models.py:192 core/models.py:192
#: build/lib/core/models.py:190 core/models.py:190
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Ma rank bezañ tretet an implijer-mañ evel oberiant. Diziuzit an dra-mañ e-plas dilemel kontoù."
#: build/lib/core/models.py:204 core/models.py:204
#: build/lib/core/models.py:202 core/models.py:202
msgid "user"
msgstr "implijer"
#: build/lib/core/models.py:205 core/models.py:205
#: build/lib/core/models.py:203 core/models.py:203
msgid "users"
msgstr "implijerien"
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
#: core/models.py:361 core/models.py:1276
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
#: core/models.py:359 core/models.py:1282
msgid "title"
msgstr "titl"
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:360 core/models.py:360
msgid "excerpt"
msgstr "bomm"
#: build/lib/core/models.py:411 core/models.py:411
#: build/lib/core/models.py:409 core/models.py:409
msgid "Document"
msgstr "Restr"
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:410 core/models.py:410
msgid "Documents"
msgstr "Restroù"
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
#: core/models.py:820
msgid "Untitled Document"
msgstr "Restr hep titl"
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:855 core/models.py:855
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} en deus rannet ur restr ganeoc'h!"
#: build/lib/core/models.py:861 core/models.py:861
#: build/lib/core/models.py:859 core/models.py:859
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} en deus pedet ac'hanoc'h gant ar rol \"{role}\" war ar restr da-heul:"
#: build/lib/core/models.py:867 core/models.py:867
#: build/lib/core/models.py:865 core/models.py:865
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} en deus rannet ur restr ganeoc'h: {title}"
#: build/lib/core/models.py:967 core/models.py:967
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link trace"
msgstr "Roud liamm ar restr/an implijer"
#: build/lib/core/models.py:968 core/models.py:968
#: build/lib/core/models.py:966 core/models.py:966
msgid "Document/user link traces"
msgstr "Roudoù liamm ar restr/an implijer"
#: build/lib/core/models.py:974 core/models.py:974
#: build/lib/core/models.py:972 core/models.py:972
msgid "A link trace already exists for this document/user."
msgstr "Ur roud liamm a zo dija evit an restr/an implijer."
#: build/lib/core/models.py:997 core/models.py:997
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorite"
msgstr "Restr muiañ-karet"
#: build/lib/core/models.py:998 core/models.py:998
#: build/lib/core/models.py:996 core/models.py:996
msgid "Document favorites"
msgstr "Restroù muiañ-karet"
#: build/lib/core/models.py:1004 core/models.py:1004
#: build/lib/core/models.py:1002 core/models.py:1002
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Ar restr-mañ a zo ur restr muiañ karet gant an implijer-mañ."
#: build/lib/core/models.py:1026 core/models.py:1026
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relation"
msgstr "Liamm restr/implijer"
#: build/lib/core/models.py:1027 core/models.py:1027
#: build/lib/core/models.py:1025 core/models.py:1025
msgid "Document/user relations"
msgstr "Liammoù restr/implijer"
#: build/lib/core/models.py:1033 core/models.py:1033
#: build/lib/core/models.py:1031 core/models.py:1031
msgid "This user is already in this document."
msgstr "An implijer-mañ a zo dija er restr-mañ."
#: build/lib/core/models.py:1039 core/models.py:1039
#: build/lib/core/models.py:1037 core/models.py:1037
msgid "This team is already in this document."
msgstr "Ar skipailh-mañ a zo dija en restr-mañ."
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
#: core/models.py:1045 core/models.py:1362
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
#: core/models.py:1043 core/models.py:1368
msgid "Either user or team must be set, not both."
msgstr "An implijer pe ar skipailh a rank bezañ termenet, ket an daou avat."
#: build/lib/core/models.py:1191 core/models.py:1191
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for access"
msgstr "Goulenn tizhout ar restr"
#: build/lib/core/models.py:1192 core/models.py:1192
#: build/lib/core/models.py:1190 core/models.py:1190
msgid "Document ask for accesses"
msgstr "Goulennoù tizhout ar restr"
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1196 core/models.py:1196
msgid "This user has already asked for access to this document."
msgstr "An implijer en deus goulennet tizhout ar restr-mañ."
#: build/lib/core/models.py:1255 core/models.py:1255
#: build/lib/core/models.py:1261 core/models.py:1261
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} en defe c'hoant da dizhout ar restr-mañ!"
#: build/lib/core/models.py:1259 core/models.py:1259
#: build/lib/core/models.py:1265 core/models.py:1265
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} en defe c'hoant da dizhout ar restr da-heul:"
#: build/lib/core/models.py:1265 core/models.py:1265
#: build/lib/core/models.py:1271 core/models.py:1271
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} en defe c'hoant da dizhout ar restr: {title}"
#: build/lib/core/models.py:1277 core/models.py:1277
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "description"
msgstr "deskrivadur"
#: build/lib/core/models.py:1278 core/models.py:1278
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "code"
msgstr "kod"
#: build/lib/core/models.py:1279 core/models.py:1279
#: build/lib/core/models.py:1285 core/models.py:1285
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1281 core/models.py:1281
#: build/lib/core/models.py:1287 core/models.py:1287
msgid "public"
msgstr "publik"
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "Whether this template is public for anyone to use."
msgstr "M'eo foran ar patrom-mañ hag implijus gant n'eus forzh piv."
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Template"
msgstr "Patrom"
#: build/lib/core/models.py:1290 core/models.py:1290
#: build/lib/core/models.py:1296 core/models.py:1296
msgid "Templates"
msgstr "Patromoù"
#: build/lib/core/models.py:1343 core/models.py:1343
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relation"
msgstr "Liamm patrom/implijer"
#: build/lib/core/models.py:1344 core/models.py:1344
#: build/lib/core/models.py:1350 core/models.py:1350
msgid "Template/user relations"
msgstr "Liammoù patrom/implijer"
#: build/lib/core/models.py:1350 core/models.py:1350
#: build/lib/core/models.py:1356 core/models.py:1356
msgid "This user is already in this template."
msgstr "An implijer-mañ a zo dija er patrom-mañ."
#: build/lib/core/models.py:1356 core/models.py:1356
#: build/lib/core/models.py:1362 core/models.py:1362
msgid "This team is already in this template."
msgstr "Ar skipailh-mañ a zo dija er patrom-mañ."
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "email address"
msgstr "postel"
#: build/lib/core/models.py:1452 core/models.py:1452
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitation"
msgstr "Pedadenn d'ur restr"
#: build/lib/core/models.py:1453 core/models.py:1453
#: build/lib/core/models.py:1459 core/models.py:1459
msgid "Document invitations"
msgstr "Pedadennoù d'ur restr"
#: build/lib/core/models.py:1473 core/models.py:1473
#: build/lib/core/models.py:1479 core/models.py:1479
msgid "This email is already associated to a registered user."
msgstr "Ar postel-mañ a zo liammet ouzh un implijer enskrivet."
+65 -65
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
"PO-Revision-Date: 2025-11-19 10:13\n"
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
"PO-Revision-Date: 2025-10-14 13:09\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de_DE\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:36 core/admin.py:36
#: build/lib/core/admin.py:37 core/admin.py:37
msgid "Personal info"
msgstr "Persönliche Daten"
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr "Berechtigungen"
#: build/lib/core/admin.py:61 core/admin.py:61
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
msgstr "Wichtige Daten"
#: build/lib/core/admin.py:147 core/admin.py:147
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr "Baumstruktur"
@@ -79,7 +79,7 @@ msgstr "Typ"
msgid "Format"
msgstr "Format"
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
#, python-brace-format
msgid "copy of {title}"
msgstr "Kopie von {title}"
@@ -178,228 +178,228 @@ msgstr ""
msgid "full name"
msgstr "Name"
#: build/lib/core/models.py:152 core/models.py:152
#: build/lib/core/models.py:151 core/models.py:151
msgid "short name"
msgstr "Kurzbezeichnung"
#: build/lib/core/models.py:155 core/models.py:155
#: build/lib/core/models.py:153 core/models.py:153
msgid "identity email address"
msgstr "Identitäts-E-Mail-Adresse"
#: build/lib/core/models.py:160 core/models.py:160
#: build/lib/core/models.py:158 core/models.py:158
msgid "admin email address"
msgstr "Admin E-Mail-Adresse"
#: build/lib/core/models.py:167 core/models.py:167
#: build/lib/core/models.py:165 core/models.py:165
msgid "language"
msgstr "Sprache"
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:166 core/models.py:166
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:176 core/models.py:176
#: build/lib/core/models.py:174 core/models.py:174
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:179 core/models.py:179
#: build/lib/core/models.py:177 core/models.py:177
msgid "device"
msgstr "Gerät"
#: build/lib/core/models.py:181 core/models.py:181
#: build/lib/core/models.py:179 core/models.py:179
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:184 core/models.py:184
#: build/lib/core/models.py:182 core/models.py:182
msgid "staff status"
msgstr "Status des Teammitgliedes"
#: build/lib/core/models.py:186 core/models.py:186
#: build/lib/core/models.py:184 core/models.py:184
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:189 core/models.py:189
#: build/lib/core/models.py:187 core/models.py:187
msgid "active"
msgstr "aktiviert"
#: build/lib/core/models.py:192 core/models.py:192
#: build/lib/core/models.py:190 core/models.py:190
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:204 core/models.py:204
#: build/lib/core/models.py:202 core/models.py:202
msgid "user"
msgstr "Benutzer"
#: build/lib/core/models.py:205 core/models.py:205
#: build/lib/core/models.py:203 core/models.py:203
msgid "users"
msgstr "Benutzer"
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
#: core/models.py:361 core/models.py:1276
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
#: core/models.py:359 core/models.py:1282
msgid "title"
msgstr "Titel"
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:360 core/models.py:360
msgid "excerpt"
msgstr "Auszug"
#: build/lib/core/models.py:411 core/models.py:411
#: build/lib/core/models.py:409 core/models.py:409
msgid "Document"
msgstr "Dokument"
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:410 core/models.py:410
msgid "Documents"
msgstr "Dokumente"
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
#: core/models.py:820
msgid "Untitled Document"
msgstr "Unbenanntes Dokument"
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:855 core/models.py:855
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} hat ein Dokument mit Ihnen geteilt!"
#: build/lib/core/models.py:861 core/models.py:861
#: build/lib/core/models.py:859 core/models.py:859
#, 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:867 core/models.py:867
#: build/lib/core/models.py:865 core/models.py:865
#, 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:967 core/models.py:967
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link trace"
msgstr "Dokument/Benutzer Linkverfolgung"
#: build/lib/core/models.py:968 core/models.py:968
#: build/lib/core/models.py:966 core/models.py:966
msgid "Document/user link traces"
msgstr "Dokument/Benutzer Linkverfolgung"
#: build/lib/core/models.py:974 core/models.py:974
#: build/lib/core/models.py:972 core/models.py:972
msgid "A link trace already exists for this document/user."
msgstr "Für dieses Dokument/ diesen Benutzer ist bereits eine Linkverfolgung vorhanden."
#: build/lib/core/models.py:997 core/models.py:997
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorite"
msgstr "Dokumentenfavorit"
#: build/lib/core/models.py:998 core/models.py:998
#: build/lib/core/models.py:996 core/models.py:996
msgid "Document favorites"
msgstr "Dokumentfavoriten"
#: build/lib/core/models.py:1004 core/models.py:1004
#: build/lib/core/models.py:1002 core/models.py:1002
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:1026 core/models.py:1026
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relation"
msgstr "Dokument/Benutzerbeziehung"
#: build/lib/core/models.py:1027 core/models.py:1027
#: build/lib/core/models.py:1025 core/models.py:1025
msgid "Document/user relations"
msgstr "Dokument/Benutzerbeziehungen"
#: build/lib/core/models.py:1033 core/models.py:1033
#: build/lib/core/models.py:1031 core/models.py:1031
msgid "This user is already in this document."
msgstr "Dieser Benutzer befindet sich bereits in diesem Dokument."
#: build/lib/core/models.py:1039 core/models.py:1039
#: build/lib/core/models.py:1037 core/models.py:1037
msgid "This team is already in this document."
msgstr "Dieses Team befindet sich bereits in diesem Dokument."
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
#: core/models.py:1045 core/models.py:1362
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
#: core/models.py:1043 core/models.py:1368
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:1191 core/models.py:1191
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1192 core/models.py:1192
#: build/lib/core/models.py:1190 core/models.py:1190
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1196 core/models.py:1196
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1255 core/models.py:1255
#: build/lib/core/models.py:1261 core/models.py:1261
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1259 core/models.py:1259
#: build/lib/core/models.py:1265 core/models.py:1265
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1265 core/models.py:1265
#: build/lib/core/models.py:1271 core/models.py:1271
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1277 core/models.py:1277
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "description"
msgstr "Beschreibung"
#: build/lib/core/models.py:1278 core/models.py:1278
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "code"
msgstr "Code"
#: build/lib/core/models.py:1279 core/models.py:1279
#: build/lib/core/models.py:1285 core/models.py:1285
msgid "css"
msgstr "CSS"
#: build/lib/core/models.py:1281 core/models.py:1281
#: build/lib/core/models.py:1287 core/models.py:1287
msgid "public"
msgstr "öffentlich"
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "Whether this template is public for anyone to use."
msgstr "Ob diese Vorlage für jedermann öffentlich ist."
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Template"
msgstr "Vorlage"
#: build/lib/core/models.py:1290 core/models.py:1290
#: build/lib/core/models.py:1296 core/models.py:1296
msgid "Templates"
msgstr "Vorlagen"
#: build/lib/core/models.py:1343 core/models.py:1343
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relation"
msgstr "Vorlage/Benutzer-Beziehung"
#: build/lib/core/models.py:1344 core/models.py:1344
#: build/lib/core/models.py:1350 core/models.py:1350
msgid "Template/user relations"
msgstr "Vorlage/Benutzerbeziehungen"
#: build/lib/core/models.py:1350 core/models.py:1350
#: build/lib/core/models.py:1356 core/models.py:1356
msgid "This user is already in this template."
msgstr "Dieser Benutzer ist bereits in dieser Vorlage."
#: build/lib/core/models.py:1356 core/models.py:1356
#: build/lib/core/models.py:1362 core/models.py:1362
msgid "This team is already in this template."
msgstr "Dieses Team ist bereits in diesem Template."
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "email address"
msgstr "E-Mail-Adresse"
#: build/lib/core/models.py:1452 core/models.py:1452
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitation"
msgstr "Einladung zum Dokument"
#: build/lib/core/models.py:1453 core/models.py:1453
#: build/lib/core/models.py:1459 core/models.py:1459
msgid "Document invitations"
msgstr "Dokumenteinladungen"
#: build/lib/core/models.py:1473 core/models.py:1473
#: build/lib/core/models.py:1479 core/models.py:1479
msgid "This email is already associated to a registered user."
msgstr "Diese E-Mail ist bereits einem registrierten Benutzer zugeordnet."
+65 -65
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
"PO-Revision-Date: 2025-11-19 10:13\n"
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
"PO-Revision-Date: 2025-10-14 13:09\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:36 core/admin.py:36
#: build/lib/core/admin.py:37 core/admin.py:37
msgid "Personal info"
msgstr ""
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr ""
#: build/lib/core/admin.py:61 core/admin.py:61
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
msgstr ""
#: build/lib/core/admin.py:147 core/admin.py:147
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr ""
@@ -79,7 +79,7 @@ msgstr ""
msgid "Format"
msgstr ""
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
#, python-brace-format
msgid "copy of {title}"
msgstr ""
@@ -178,228 +178,228 @@ msgstr ""
msgid "full name"
msgstr ""
#: build/lib/core/models.py:152 core/models.py:152
#: build/lib/core/models.py:151 core/models.py:151
msgid "short name"
msgstr ""
#: build/lib/core/models.py:155 core/models.py:155
#: build/lib/core/models.py:153 core/models.py:153
msgid "identity email address"
msgstr ""
#: build/lib/core/models.py:160 core/models.py:160
#: build/lib/core/models.py:158 core/models.py:158
msgid "admin email address"
msgstr ""
#: build/lib/core/models.py:167 core/models.py:167
#: build/lib/core/models.py:165 core/models.py:165
msgid "language"
msgstr ""
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:166 core/models.py:166
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/core/models.py:176 core/models.py:176
#: build/lib/core/models.py:174 core/models.py:174
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:177 core/models.py:177
msgid "device"
msgstr ""
#: build/lib/core/models.py:181 core/models.py:181
#: build/lib/core/models.py:179 core/models.py:179
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:182 core/models.py:182
msgid "staff status"
msgstr ""
#: build/lib/core/models.py:186 core/models.py:186
#: build/lib/core/models.py:184 core/models.py:184
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/core/models.py:189 core/models.py:189
#: build/lib/core/models.py:187 core/models.py:187
msgid "active"
msgstr ""
#: build/lib/core/models.py:192 core/models.py:192
#: build/lib/core/models.py:190 core/models.py:190
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: build/lib/core/models.py:204 core/models.py:204
#: build/lib/core/models.py:202 core/models.py:202
msgid "user"
msgstr ""
#: build/lib/core/models.py:205 core/models.py:205
#: build/lib/core/models.py:203 core/models.py:203
msgid "users"
msgstr ""
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
#: core/models.py:361 core/models.py:1276
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
#: core/models.py:359 core/models.py:1282
msgid "title"
msgstr ""
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:360 core/models.py:360
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:411 core/models.py:411
#: build/lib/core/models.py:409 core/models.py:409
msgid "Document"
msgstr ""
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:410 core/models.py:410
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
#: core/models.py:820
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:855 core/models.py:855
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:861 core/models.py:861
#: build/lib/core/models.py:859 core/models.py:859
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:867 core/models.py:867
#: build/lib/core/models.py:865 core/models.py:865
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:967 core/models.py:967
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:968 core/models.py:968
#: build/lib/core/models.py:966 core/models.py:966
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:974 core/models.py:974
#: build/lib/core/models.py:972 core/models.py:972
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:997 core/models.py:997
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:998 core/models.py:998
#: build/lib/core/models.py:996 core/models.py:996
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1004 core/models.py:1004
#: build/lib/core/models.py:1002 core/models.py:1002
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1026 core/models.py:1026
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1027 core/models.py:1027
#: build/lib/core/models.py:1025 core/models.py:1025
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1033 core/models.py:1033
#: build/lib/core/models.py:1031 core/models.py:1031
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1039 core/models.py:1039
#: build/lib/core/models.py:1037 core/models.py:1037
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
#: core/models.py:1045 core/models.py:1362
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
#: core/models.py:1043 core/models.py:1368
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1191 core/models.py:1191
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1192 core/models.py:1192
#: build/lib/core/models.py:1190 core/models.py:1190
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1196 core/models.py:1196
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1255 core/models.py:1255
#: build/lib/core/models.py:1261 core/models.py:1261
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1259 core/models.py:1259
#: build/lib/core/models.py:1265 core/models.py:1265
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1265 core/models.py:1265
#: build/lib/core/models.py:1271 core/models.py:1271
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1277 core/models.py:1277
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "description"
msgstr ""
#: build/lib/core/models.py:1278 core/models.py:1278
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "code"
msgstr ""
#: build/lib/core/models.py:1279 core/models.py:1279
#: build/lib/core/models.py:1285 core/models.py:1285
msgid "css"
msgstr ""
#: build/lib/core/models.py:1281 core/models.py:1281
#: build/lib/core/models.py:1287 core/models.py:1287
msgid "public"
msgstr ""
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1290 core/models.py:1290
#: build/lib/core/models.py:1296 core/models.py:1296
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1343 core/models.py:1343
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1344 core/models.py:1344
#: build/lib/core/models.py:1350 core/models.py:1350
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1350 core/models.py:1350
#: build/lib/core/models.py:1356 core/models.py:1356
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1356 core/models.py:1356
#: build/lib/core/models.py:1362 core/models.py:1362
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1452 core/models.py:1452
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1453 core/models.py:1453
#: build/lib/core/models.py:1459 core/models.py:1459
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1473 core/models.py:1473
#: build/lib/core/models.py:1479 core/models.py:1479
msgid "This email is already associated to a registered user."
msgstr ""
+65 -65
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
"PO-Revision-Date: 2025-11-19 10:13\n"
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
"PO-Revision-Date: 2025-10-14 13:09\n"
"Last-Translator: \n"
"Language-Team: Spanish\n"
"Language: es_ES\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:36 core/admin.py:36
#: build/lib/core/admin.py:37 core/admin.py:37
msgid "Personal info"
msgstr "Información Personal"
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr "Permisos"
#: build/lib/core/admin.py:61 core/admin.py:61
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
msgstr "Fechas importantes"
#: build/lib/core/admin.py:147 core/admin.py:147
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr "Estructura en árbol"
@@ -79,7 +79,7 @@ msgstr "Tipo de Cuerpo"
msgid "Format"
msgstr "Formato"
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
#, python-brace-format
msgid "copy of {title}"
msgstr "copia de {title}"
@@ -178,228 +178,228 @@ msgstr "Obligatorio. 255 caracteres o menos. Solo caracteres ASCII."
msgid "full name"
msgstr "nombre completo"
#: build/lib/core/models.py:152 core/models.py:152
#: build/lib/core/models.py:151 core/models.py:151
msgid "short name"
msgstr "nombre abreviado"
#: build/lib/core/models.py:155 core/models.py:155
#: build/lib/core/models.py:153 core/models.py:153
msgid "identity email address"
msgstr "correo electrónico de identidad"
#: build/lib/core/models.py:160 core/models.py:160
#: build/lib/core/models.py:158 core/models.py:158
msgid "admin email address"
msgstr "correo electrónico del administrador"
#: build/lib/core/models.py:167 core/models.py:167
#: build/lib/core/models.py:165 core/models.py:165
msgid "language"
msgstr "idioma"
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:166 core/models.py:166
msgid "The language in which the user wants to see the interface."
msgstr "El idioma en el que el usuario desea ver la interfaz."
#: build/lib/core/models.py:176 core/models.py:176
#: build/lib/core/models.py:174 core/models.py:174
msgid "The timezone in which the user wants to see times."
msgstr "La zona horaria en la que el usuario quiere ver los tiempos."
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:177 core/models.py:177
msgid "device"
msgstr "dispositivo"
#: build/lib/core/models.py:181 core/models.py:181
#: build/lib/core/models.py:179 core/models.py:179
msgid "Whether the user is a device or a real user."
msgstr "Si el usuario es un dispositivo o un usuario real."
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:182 core/models.py:182
msgid "staff status"
msgstr "rol en el equipo"
#: build/lib/core/models.py:186 core/models.py:186
#: build/lib/core/models.py:184 core/models.py:184
msgid "Whether the user can log into this admin site."
msgstr "Si el usuario puede iniciar sesión en esta página web de administración."
#: build/lib/core/models.py:189 core/models.py:189
#: build/lib/core/models.py:187 core/models.py:187
msgid "active"
msgstr "activo"
#: build/lib/core/models.py:192 core/models.py:192
#: build/lib/core/models.py:190 core/models.py:190
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Si este usuario debe ser considerado como activo. Deseleccionar en lugar de eliminar cuentas."
#: build/lib/core/models.py:204 core/models.py:204
#: build/lib/core/models.py:202 core/models.py:202
msgid "user"
msgstr "usuario"
#: build/lib/core/models.py:205 core/models.py:205
#: build/lib/core/models.py:203 core/models.py:203
msgid "users"
msgstr "usuarios"
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
#: core/models.py:361 core/models.py:1276
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
#: core/models.py:359 core/models.py:1282
msgid "title"
msgstr "título"
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:360 core/models.py:360
msgid "excerpt"
msgstr "resumen"
#: build/lib/core/models.py:411 core/models.py:411
#: build/lib/core/models.py:409 core/models.py:409
msgid "Document"
msgstr "Documento"
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:410 core/models.py:410
msgid "Documents"
msgstr "Documentos"
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
#: core/models.py:820
msgid "Untitled Document"
msgstr "Documento sin título"
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:855 core/models.py:855
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "¡{name} ha compartido un documento contigo!"
#: build/lib/core/models.py:861 core/models.py:861
#: build/lib/core/models.py:859 core/models.py:859
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "Te ha invitado {name} al siguiente documento con el rol \"{role}\" :"
#: build/lib/core/models.py:867 core/models.py:867
#: build/lib/core/models.py:865 core/models.py:865
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} ha compartido un documento contigo: {title}"
#: build/lib/core/models.py:967 core/models.py:967
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link trace"
msgstr "Traza del enlace de documento/usuario"
#: build/lib/core/models.py:968 core/models.py:968
#: build/lib/core/models.py:966 core/models.py:966
msgid "Document/user link traces"
msgstr "Trazas del enlace de documento/usuario"
#: build/lib/core/models.py:974 core/models.py:974
#: build/lib/core/models.py:972 core/models.py:972
msgid "A link trace already exists for this document/user."
msgstr "Ya existe una traza de enlace para este documento/usuario."
#: build/lib/core/models.py:997 core/models.py:997
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorite"
msgstr "Documento favorito"
#: build/lib/core/models.py:998 core/models.py:998
#: build/lib/core/models.py:996 core/models.py:996
msgid "Document favorites"
msgstr "Documentos favoritos"
#: build/lib/core/models.py:1004 core/models.py:1004
#: build/lib/core/models.py:1002 core/models.py:1002
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Este documento ya ha sido marcado como favorito por el usuario."
#: build/lib/core/models.py:1026 core/models.py:1026
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relation"
msgstr "Relación documento/usuario"
#: build/lib/core/models.py:1027 core/models.py:1027
#: build/lib/core/models.py:1025 core/models.py:1025
msgid "Document/user relations"
msgstr "Relaciones documento/usuario"
#: build/lib/core/models.py:1033 core/models.py:1033
#: build/lib/core/models.py:1031 core/models.py:1031
msgid "This user is already in this document."
msgstr "Este usuario ya forma parte del documento."
#: build/lib/core/models.py:1039 core/models.py:1039
#: build/lib/core/models.py:1037 core/models.py:1037
msgid "This team is already in this document."
msgstr "Este equipo ya forma parte del documento."
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
#: core/models.py:1045 core/models.py:1362
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
#: core/models.py:1043 core/models.py:1368
msgid "Either user or team must be set, not both."
msgstr "Debe establecerse un usuario o un equipo, no ambos."
#: build/lib/core/models.py:1191 core/models.py:1191
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for access"
msgstr "Solicitud de acceso"
#: build/lib/core/models.py:1192 core/models.py:1192
#: build/lib/core/models.py:1190 core/models.py:1190
msgid "Document ask for accesses"
msgstr "Solicitud de accesos"
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1196 core/models.py:1196
msgid "This user has already asked for access to this document."
msgstr "Este usuario ya ha solicitado acceso a este documento."
#: build/lib/core/models.py:1255 core/models.py:1255
#: build/lib/core/models.py:1261 core/models.py:1261
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "¡{name} desea acceder a un documento!"
#: build/lib/core/models.py:1259 core/models.py:1259
#: build/lib/core/models.py:1265 core/models.py:1265
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} desea acceso al siguiente documento:"
#: build/lib/core/models.py:1265 core/models.py:1265
#: build/lib/core/models.py:1271 core/models.py:1271
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} está pidiendo acceso al documento: {title}"
#: build/lib/core/models.py:1277 core/models.py:1277
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "description"
msgstr "descripción"
#: build/lib/core/models.py:1278 core/models.py:1278
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "code"
msgstr "código"
#: build/lib/core/models.py:1279 core/models.py:1279
#: build/lib/core/models.py:1285 core/models.py:1285
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1281 core/models.py:1281
#: build/lib/core/models.py:1287 core/models.py:1287
msgid "public"
msgstr "público"
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "Whether this template is public for anyone to use."
msgstr "Si esta plantilla es pública para que cualquiera la utilice."
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Template"
msgstr "Plantilla"
#: build/lib/core/models.py:1290 core/models.py:1290
#: build/lib/core/models.py:1296 core/models.py:1296
msgid "Templates"
msgstr "Plantillas"
#: build/lib/core/models.py:1343 core/models.py:1343
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relation"
msgstr "Relación plantilla/usuario"
#: build/lib/core/models.py:1344 core/models.py:1344
#: build/lib/core/models.py:1350 core/models.py:1350
msgid "Template/user relations"
msgstr "Relaciones plantilla/usuario"
#: build/lib/core/models.py:1350 core/models.py:1350
#: build/lib/core/models.py:1356 core/models.py:1356
msgid "This user is already in this template."
msgstr "Este usuario ya forma parte de la plantilla."
#: build/lib/core/models.py:1356 core/models.py:1356
#: build/lib/core/models.py:1362 core/models.py:1362
msgid "This team is already in this template."
msgstr "Este equipo ya se encuentra en esta plantilla."
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "email address"
msgstr "dirección de correo electrónico"
#: build/lib/core/models.py:1452 core/models.py:1452
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitation"
msgstr "Invitación al documento"
#: build/lib/core/models.py:1453 core/models.py:1453
#: build/lib/core/models.py:1459 core/models.py:1459
msgid "Document invitations"
msgstr "Invitaciones a documentos"
#: build/lib/core/models.py:1473 core/models.py:1473
#: build/lib/core/models.py:1479 core/models.py:1479
msgid "This email is already associated to a registered user."
msgstr "Este correo electrónico está asociado a un usuario registrado."
+65 -65
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
"PO-Revision-Date: 2025-11-19 10:13\n"
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
"PO-Revision-Date: 2025-10-14 13:09\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:36 core/admin.py:36
#: build/lib/core/admin.py:37 core/admin.py:37
msgid "Personal info"
msgstr "Infos Personnelles"
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr "Permissions"
#: build/lib/core/admin.py:61 core/admin.py:61
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
msgstr "Dates importantes"
#: build/lib/core/admin.py:147 core/admin.py:147
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr "Arborescence"
@@ -79,7 +79,7 @@ msgstr "Type de corps"
msgid "Format"
msgstr "Format"
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
#, python-brace-format
msgid "copy of {title}"
msgstr "copie de {title}"
@@ -178,228 +178,228 @@ msgstr "Obligatoire. 255 caractères ou moins. Caractères ASCII uniquement."
msgid "full name"
msgstr "nom complet"
#: build/lib/core/models.py:152 core/models.py:152
#: build/lib/core/models.py:151 core/models.py:151
msgid "short name"
msgstr "nom court"
#: build/lib/core/models.py:155 core/models.py:155
#: build/lib/core/models.py:153 core/models.py:153
msgid "identity email address"
msgstr "adresse e-mail d'identité"
#: build/lib/core/models.py:160 core/models.py:160
#: build/lib/core/models.py:158 core/models.py:158
msgid "admin email address"
msgstr "adresse e-mail de l'administrateur"
#: build/lib/core/models.py:167 core/models.py:167
#: build/lib/core/models.py:165 core/models.py:165
msgid "language"
msgstr "langue"
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:166 core/models.py:166
msgid "The language in which the user wants to see the interface."
msgstr "La langue dans laquelle l'utilisateur veut voir l'interface."
#: build/lib/core/models.py:176 core/models.py:176
#: build/lib/core/models.py:174 core/models.py:174
msgid "The timezone in which the user wants to see times."
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:177 core/models.py:177
msgid "device"
msgstr "appareil"
#: build/lib/core/models.py:181 core/models.py:181
#: build/lib/core/models.py:179 core/models.py:179
msgid "Whether the user is a device or a real user."
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:182 core/models.py:182
msgid "staff status"
msgstr "statut d'équipe"
#: build/lib/core/models.py:186 core/models.py:186
#: build/lib/core/models.py:184 core/models.py:184
msgid "Whether the user can log into this admin site."
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
#: build/lib/core/models.py:189 core/models.py:189
#: build/lib/core/models.py:187 core/models.py:187
msgid "active"
msgstr "actif"
#: build/lib/core/models.py:192 core/models.py:192
#: build/lib/core/models.py:190 core/models.py:190
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Si cet utilisateur doit être traité comme actif. Désélectionnez ceci au lieu de supprimer des comptes."
#: build/lib/core/models.py:204 core/models.py:204
#: build/lib/core/models.py:202 core/models.py:202
msgid "user"
msgstr "utilisateur"
#: build/lib/core/models.py:205 core/models.py:205
#: build/lib/core/models.py:203 core/models.py:203
msgid "users"
msgstr "utilisateurs"
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
#: core/models.py:361 core/models.py:1276
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
#: core/models.py:359 core/models.py:1282
msgid "title"
msgstr "titre"
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:360 core/models.py:360
msgid "excerpt"
msgstr "extrait"
#: build/lib/core/models.py:411 core/models.py:411
#: build/lib/core/models.py:409 core/models.py:409
msgid "Document"
msgstr "Document"
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:410 core/models.py:410
msgid "Documents"
msgstr "Documents"
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
#: core/models.py:820
msgid "Untitled Document"
msgstr "Document sans titre"
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:855 core/models.py:855
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} a partagé un document avec vous!"
#: build/lib/core/models.py:861 core/models.py:861
#: build/lib/core/models.py:859 core/models.py:859
#, 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:867 core/models.py:867
#: build/lib/core/models.py:865 core/models.py:865
#, 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:967 core/models.py:967
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link trace"
msgstr "Trace du lien document/utilisateur"
#: build/lib/core/models.py:968 core/models.py:968
#: build/lib/core/models.py:966 core/models.py:966
msgid "Document/user link traces"
msgstr "Traces du lien document/utilisateur"
#: build/lib/core/models.py:974 core/models.py:974
#: build/lib/core/models.py:972 core/models.py:972
msgid "A link trace already exists for this document/user."
msgstr "Une trace de lien existe déjà pour ce document/utilisateur."
#: build/lib/core/models.py:997 core/models.py:997
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorite"
msgstr "Document favori"
#: build/lib/core/models.py:998 core/models.py:998
#: build/lib/core/models.py:996 core/models.py:996
msgid "Document favorites"
msgstr "Documents favoris"
#: build/lib/core/models.py:1004 core/models.py:1004
#: build/lib/core/models.py:1002 core/models.py:1002
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Ce document est déjà un favori de cet utilisateur."
#: build/lib/core/models.py:1026 core/models.py:1026
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relation"
msgstr "Relation document/utilisateur"
#: build/lib/core/models.py:1027 core/models.py:1027
#: build/lib/core/models.py:1025 core/models.py:1025
msgid "Document/user relations"
msgstr "Relations document/utilisateur"
#: build/lib/core/models.py:1033 core/models.py:1033
#: build/lib/core/models.py:1031 core/models.py:1031
msgid "This user is already in this document."
msgstr "Cet utilisateur est déjà dans ce document."
#: build/lib/core/models.py:1039 core/models.py:1039
#: build/lib/core/models.py:1037 core/models.py:1037
msgid "This team is already in this document."
msgstr "Cette équipe est déjà dans ce document."
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
#: core/models.py:1045 core/models.py:1362
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
#: core/models.py:1043 core/models.py:1368
msgid "Either user or team must be set, not both."
msgstr "L'utilisateur ou l'équipe doivent être définis, pas les deux."
#: build/lib/core/models.py:1191 core/models.py:1191
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for access"
msgstr "Demande d'accès au document"
#: build/lib/core/models.py:1192 core/models.py:1192
#: build/lib/core/models.py:1190 core/models.py:1190
msgid "Document ask for accesses"
msgstr "Demande d'accès au document"
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1196 core/models.py:1196
msgid "This user has already asked for access to this document."
msgstr "Cet utilisateur a déjà demandé l'accès à ce document."
#: build/lib/core/models.py:1255 core/models.py:1255
#: build/lib/core/models.py:1261 core/models.py:1261
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} souhaiterait accéder au document suivant !"
#: build/lib/core/models.py:1259 core/models.py:1259
#: build/lib/core/models.py:1265 core/models.py:1265
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} souhaiterait accéder au document suivant :"
#: build/lib/core/models.py:1265 core/models.py:1265
#: build/lib/core/models.py:1271 core/models.py:1271
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} demande l'accès au document : {title}"
#: build/lib/core/models.py:1277 core/models.py:1277
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "description"
msgstr "description"
#: build/lib/core/models.py:1278 core/models.py:1278
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "code"
msgstr "code"
#: build/lib/core/models.py:1279 core/models.py:1279
#: build/lib/core/models.py:1285 core/models.py:1285
msgid "css"
msgstr "CSS"
#: build/lib/core/models.py:1281 core/models.py:1281
#: build/lib/core/models.py:1287 core/models.py:1287
msgid "public"
msgstr "public"
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "Whether this template is public for anyone to use."
msgstr "Si ce modèle est public, utilisable par n'importe qui."
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Template"
msgstr "Modèle"
#: build/lib/core/models.py:1290 core/models.py:1290
#: build/lib/core/models.py:1296 core/models.py:1296
msgid "Templates"
msgstr "Modèles"
#: build/lib/core/models.py:1343 core/models.py:1343
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relation"
msgstr "Relation modèle/utilisateur"
#: build/lib/core/models.py:1344 core/models.py:1344
#: build/lib/core/models.py:1350 core/models.py:1350
msgid "Template/user relations"
msgstr "Relations modèle/utilisateur"
#: build/lib/core/models.py:1350 core/models.py:1350
#: build/lib/core/models.py:1356 core/models.py:1356
msgid "This user is already in this template."
msgstr "Cet utilisateur est déjà dans ce modèle."
#: build/lib/core/models.py:1356 core/models.py:1356
#: build/lib/core/models.py:1362 core/models.py:1362
msgid "This team is already in this template."
msgstr "Cette équipe est déjà modèle."
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "email address"
msgstr "adresse e-mail"
#: build/lib/core/models.py:1452 core/models.py:1452
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitation"
msgstr "Invitation à un document"
#: build/lib/core/models.py:1453 core/models.py:1453
#: build/lib/core/models.py:1459 core/models.py:1459
msgid "Document invitations"
msgstr "Invitations à un document"
#: build/lib/core/models.py:1473 core/models.py:1473
#: build/lib/core/models.py:1479 core/models.py:1479
msgid "This email is already associated to a registered user."
msgstr "Cette adresse email est déjà associée à un utilisateur inscrit."
+65 -65
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
"PO-Revision-Date: 2025-11-19 10:13\n"
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
"PO-Revision-Date: 2025-10-14 13:09\n"
"Last-Translator: \n"
"Language-Team: Italian\n"
"Language: it_IT\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:36 core/admin.py:36
#: build/lib/core/admin.py:37 core/admin.py:37
msgid "Personal info"
msgstr "Informazioni personali"
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr "Permessi"
#: build/lib/core/admin.py:61 core/admin.py:61
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
msgstr "Date importanti"
#: build/lib/core/admin.py:147 core/admin.py:147
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr "Struttura ad albero"
@@ -79,7 +79,7 @@ msgstr ""
msgid "Format"
msgstr "Formato"
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
#, python-brace-format
msgid "copy of {title}"
msgstr "copia di {title}"
@@ -178,228 +178,228 @@ msgstr ""
msgid "full name"
msgstr "nome completo"
#: build/lib/core/models.py:152 core/models.py:152
#: build/lib/core/models.py:151 core/models.py:151
msgid "short name"
msgstr "nome"
#: build/lib/core/models.py:155 core/models.py:155
#: build/lib/core/models.py:153 core/models.py:153
msgid "identity email address"
msgstr "indirizzo email di identità"
#: build/lib/core/models.py:160 core/models.py:160
#: build/lib/core/models.py:158 core/models.py:158
msgid "admin email address"
msgstr "Indirizzo email dell'amministratore"
#: build/lib/core/models.py:167 core/models.py:167
#: build/lib/core/models.py:165 core/models.py:165
msgid "language"
msgstr "lingua"
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:166 core/models.py:166
msgid "The language in which the user wants to see the interface."
msgstr "La lingua in cui l'utente vuole vedere l'interfaccia."
#: build/lib/core/models.py:176 core/models.py:176
#: build/lib/core/models.py:174 core/models.py:174
msgid "The timezone in which the user wants to see times."
msgstr "Il fuso orario in cui l'utente vuole vedere gli orari."
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:177 core/models.py:177
msgid "device"
msgstr "dispositivo"
#: build/lib/core/models.py:181 core/models.py:181
#: build/lib/core/models.py:179 core/models.py:179
msgid "Whether the user is a device or a real user."
msgstr "Se l'utente è un dispositivo o un utente reale."
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:182 core/models.py:182
msgid "staff status"
msgstr "stato del personale"
#: build/lib/core/models.py:186 core/models.py:186
#: build/lib/core/models.py:184 core/models.py:184
msgid "Whether the user can log into this admin site."
msgstr "Indica se l'utente può accedere a questo sito amministratore."
#: build/lib/core/models.py:189 core/models.py:189
#: build/lib/core/models.py:187 core/models.py:187
msgid "active"
msgstr "attivo"
#: build/lib/core/models.py:192 core/models.py:192
#: build/lib/core/models.py:190 core/models.py:190
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Indica se questo utente deve essere trattato come attivo. Deseleziona invece di eliminare gli account."
#: build/lib/core/models.py:204 core/models.py:204
#: build/lib/core/models.py:202 core/models.py:202
msgid "user"
msgstr "utente"
#: build/lib/core/models.py:205 core/models.py:205
#: build/lib/core/models.py:203 core/models.py:203
msgid "users"
msgstr "utenti"
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
#: core/models.py:361 core/models.py:1276
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
#: core/models.py:359 core/models.py:1282
msgid "title"
msgstr "titolo"
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:360 core/models.py:360
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:411 core/models.py:411
#: build/lib/core/models.py:409 core/models.py:409
msgid "Document"
msgstr "Documento"
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:410 core/models.py:410
msgid "Documents"
msgstr "Documenti"
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
#: core/models.py:820
msgid "Untitled Document"
msgstr "Documento senza titolo"
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:855 core/models.py:855
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} ha condiviso un documento con te!"
#: build/lib/core/models.py:861 core/models.py:861
#: build/lib/core/models.py:859 core/models.py:859
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} ti ha invitato con il ruolo \"{role}\" nel seguente documento:"
#: build/lib/core/models.py:867 core/models.py:867
#: build/lib/core/models.py:865 core/models.py:865
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} ha condiviso un documento con te: {title}"
#: build/lib/core/models.py:967 core/models.py:967
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:968 core/models.py:968
#: build/lib/core/models.py:966 core/models.py:966
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:974 core/models.py:974
#: build/lib/core/models.py:972 core/models.py:972
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:997 core/models.py:997
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorite"
msgstr "Documento preferito"
#: build/lib/core/models.py:998 core/models.py:998
#: build/lib/core/models.py:996 core/models.py:996
msgid "Document favorites"
msgstr "Documenti preferiti"
#: build/lib/core/models.py:1004 core/models.py:1004
#: build/lib/core/models.py:1002 core/models.py:1002
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1026 core/models.py:1026
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1027 core/models.py:1027
#: build/lib/core/models.py:1025 core/models.py:1025
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1033 core/models.py:1033
#: build/lib/core/models.py:1031 core/models.py:1031
msgid "This user is already in this document."
msgstr "Questo utente è già presente in questo documento."
#: build/lib/core/models.py:1039 core/models.py:1039
#: build/lib/core/models.py:1037 core/models.py:1037
msgid "This team is already in this document."
msgstr "Questo team è già presente in questo documento."
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
#: core/models.py:1045 core/models.py:1362
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
#: core/models.py:1043 core/models.py:1368
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1191 core/models.py:1191
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1192 core/models.py:1192
#: build/lib/core/models.py:1190 core/models.py:1190
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1196 core/models.py:1196
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1255 core/models.py:1255
#: build/lib/core/models.py:1261 core/models.py:1261
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1259 core/models.py:1259
#: build/lib/core/models.py:1265 core/models.py:1265
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1265 core/models.py:1265
#: build/lib/core/models.py:1271 core/models.py:1271
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1277 core/models.py:1277
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "description"
msgstr "descrizione"
#: build/lib/core/models.py:1278 core/models.py:1278
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "code"
msgstr "code"
#: build/lib/core/models.py:1279 core/models.py:1279
#: build/lib/core/models.py:1285 core/models.py:1285
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1281 core/models.py:1281
#: build/lib/core/models.py:1287 core/models.py:1287
msgid "public"
msgstr "pubblico"
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "Whether this template is public for anyone to use."
msgstr "Indica se questo modello è pubblico per chiunque."
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Template"
msgstr "Modello"
#: build/lib/core/models.py:1290 core/models.py:1290
#: build/lib/core/models.py:1296 core/models.py:1296
msgid "Templates"
msgstr "Modelli"
#: build/lib/core/models.py:1343 core/models.py:1343
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1344 core/models.py:1344
#: build/lib/core/models.py:1350 core/models.py:1350
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1350 core/models.py:1350
#: build/lib/core/models.py:1356 core/models.py:1356
msgid "This user is already in this template."
msgstr "Questo utente è già in questo modello."
#: build/lib/core/models.py:1356 core/models.py:1356
#: build/lib/core/models.py:1362 core/models.py:1362
msgid "This team is already in this template."
msgstr "Questo team è già in questo modello."
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "email address"
msgstr "indirizzo e-mail"
#: build/lib/core/models.py:1452 core/models.py:1452
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitation"
msgstr "Invito al documento"
#: build/lib/core/models.py:1453 core/models.py:1453
#: build/lib/core/models.py:1459 core/models.py:1459
msgid "Document invitations"
msgstr "Inviti al documento"
#: build/lib/core/models.py:1473 core/models.py:1473
#: build/lib/core/models.py:1479 core/models.py:1479
msgid "This email is already associated to a registered user."
msgstr "Questa email è già associata a un utente registrato."
+107 -107
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
"PO-Revision-Date: 2025-11-19 10:13\n"
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
"PO-Revision-Date: 2025-10-14 13:09\n"
"Last-Translator: \n"
"Language-Team: Dutch\n"
"Language: nl_NL\n"
@@ -17,22 +17,22 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:36 core/admin.py:36
#: build/lib/core/admin.py:37 core/admin.py:37
msgid "Personal info"
msgstr "Persoonlijke informatie"
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr "Machtigingen"
msgstr "Toestemmingen"
#: build/lib/core/admin.py:61 core/admin.py:61
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
msgstr "Belangrijke data"
msgstr "Belangrijke datums"
#: build/lib/core/admin.py:147 core/admin.py:147
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr "Boomstructuur"
msgstr "Document structuur"
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
msgid "Title"
@@ -40,7 +40,7 @@ msgstr "Titel"
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
msgid "Creator is me"
msgstr "Ik ben eigenaar"
msgstr "Ik ben Eigenaar"
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
msgid "Masked"
@@ -48,15 +48,15 @@ msgstr "Gemaskeerd"
#: build/lib/core/api/filters.py:67 core/api/filters.py:67
msgid "Favorite"
msgstr "Favoriet"
msgstr "Favoriete"
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
msgid "A new document was created on your behalf!"
msgstr "Een nieuw document is namens u gemaakt!"
msgstr "Een nieuw document was gecreëerd voor u!"
#: build/lib/core/api/serializers.py:500 core/api/serializers.py:500
msgid "You have been granted ownership of a new document:"
msgstr "U heeft eigenaarschap van een nieuw document gekregen:"
msgstr "U heeft eigenaarschap van een nieuw document:"
#: build/lib/core/api/serializers.py:536 core/api/serializers.py:536
msgid "This field is required."
@@ -79,7 +79,7 @@ msgstr "Text type"
msgid "Format"
msgstr "Formaat"
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
#, python-brace-format
msgid "copy of {title}"
msgstr "kopie van {title}"
@@ -92,11 +92,11 @@ msgstr "Lezer"
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
#: core/choices.py:43
msgid "Editor"
msgstr "Redacteur"
msgstr "Bewerker"
#: build/lib/core/choices.py:44 core/choices.py:44
msgid "Administrator"
msgstr "Beheerder"
msgstr "Administrator"
#: build/lib/core/choices.py:45 core/choices.py:45
msgid "Owner"
@@ -104,7 +104,7 @@ msgstr "Eigenaar"
#: build/lib/core/choices.py:56 core/choices.py:56
msgid "Restricted"
msgstr "Beperkt"
msgstr "Niet toegestaan"
#: build/lib/core/choices.py:60 core/choices.py:60
msgid "Authenticated"
@@ -148,11 +148,11 @@ msgstr "primaire sleutel voor dossier als UUID"
#: build/lib/core/models.py:87 core/models.py:87
msgid "created on"
msgstr "gecreëerd op"
msgstr "gemaakt op"
#: build/lib/core/models.py:88 core/models.py:88
msgid "date and time at which a record was created"
msgstr "datum en tijd waarop dossier is gecreeërd"
msgstr "datum en tijd wanneer dossier was gecreëerd"
#: build/lib/core/models.py:93 core/models.py:93
msgid "updated on"
@@ -164,7 +164,7 @@ msgstr "datum en tijd waarop dossier laatst was gewijzigd"
#: build/lib/core/models.py:130 core/models.py:130
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 dit id, maar de email is al geassocieerd met een geregistreerde gebruiker."
msgstr "Wij konden geen gebruiker vinden met deze id, maar de email is al geassocieerd met een geregistreerde gebruiker."
#: build/lib/core/models.py:141 core/models.py:141
msgid "sub"
@@ -172,234 +172,234 @@ msgstr "id"
#: build/lib/core/models.py:142 core/models.py:142
msgid "Required. 255 characters or fewer. ASCII characters only."
msgstr "Vereist. 255 tekens of minder. Alleen ASCII tekens."
msgstr "Vereist. Minder dan 255 ASCII tekens."
#: build/lib/core/models.py:150 core/models.py:150
msgid "full name"
msgstr "volledige naam"
#: build/lib/core/models.py:152 core/models.py:152
#: build/lib/core/models.py:151 core/models.py:151
msgid "short name"
msgstr "gebruikersnaam"
#: build/lib/core/models.py:155 core/models.py:155
#: build/lib/core/models.py:153 core/models.py:153
msgid "identity email address"
msgstr "identiteit emailadres"
msgstr "identiteit email adres"
#: build/lib/core/models.py:160 core/models.py:160
#: build/lib/core/models.py:158 core/models.py:158
msgid "admin email address"
msgstr "admin emailadres"
msgstr "admin email adres"
#: build/lib/core/models.py:167 core/models.py:167
#: build/lib/core/models.py:165 core/models.py:165
msgid "language"
msgstr "taal"
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:166 core/models.py:166
msgid "The language in which the user wants to see the interface."
msgstr "De taal waarin de gebruiker de interface wil zien."
msgstr "De taal waarin de gebruiker de interface wilt zien."
#: build/lib/core/models.py:176 core/models.py:176
#: build/lib/core/models.py:174 core/models.py:174
msgid "The timezone in which the user wants to see times."
msgstr "De tijdzone waarin de gebruiker de tijden wil zien."
msgstr "De tijdzone waarin de gebruiker de tijden wilt zien."
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:177 core/models.py:177
msgid "device"
msgstr "apparaat"
#: build/lib/core/models.py:181 core/models.py:181
#: build/lib/core/models.py:179 core/models.py:179
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:184 core/models.py:184
#: build/lib/core/models.py:182 core/models.py:182
msgid "staff status"
msgstr "beheerder status"
#: build/lib/core/models.py:186 core/models.py:186
#: build/lib/core/models.py:184 core/models.py:184
msgid "Whether the user can log into this admin site."
msgstr "Of de gebruiker kan inloggen in het beheer gedeelte."
msgstr "Of de gebruiker kan inloggen in het admin gedeelte."
#: build/lib/core/models.py:189 core/models.py:189
#: build/lib/core/models.py:187 core/models.py:187
msgid "active"
msgstr "actief"
#: build/lib/core/models.py:192 core/models.py:192
#: build/lib/core/models.py:190 core/models.py:190
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:204 core/models.py:204
#: build/lib/core/models.py:202 core/models.py:202
msgid "user"
msgstr "gebruiker"
#: build/lib/core/models.py:205 core/models.py:205
#: build/lib/core/models.py:203 core/models.py:203
msgid "users"
msgstr "gebruikers"
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
#: core/models.py:361 core/models.py:1276
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
#: core/models.py:359 core/models.py:1282
msgid "title"
msgstr "titel"
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:360 core/models.py:360
msgid "excerpt"
msgstr "uittreksel"
#: build/lib/core/models.py:411 core/models.py:411
#: build/lib/core/models.py:409 core/models.py:409
msgid "Document"
msgstr "Document"
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:410 core/models.py:410
msgid "Documents"
msgstr "Documenten"
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
#: core/models.py:820
msgid "Untitled Document"
msgstr "Naamloos Document"
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:855 core/models.py:855
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} heeft een document met u gedeeld!"
msgstr "{name} heeft een document met gedeeld!"
#: build/lib/core/models.py:861 core/models.py:861
#: build/lib/core/models.py:859 core/models.py:859
#, 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:"
#: build/lib/core/models.py:867 core/models.py:867
#: build/lib/core/models.py:865 core/models.py:865
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} heeft een document met u gedeeld: {title}"
#: build/lib/core/models.py:967 core/models.py:967
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link trace"
msgstr "Document/gebruiker link"
msgstr "Document/gebruiker url"
#: build/lib/core/models.py:968 core/models.py:968
#: build/lib/core/models.py:966 core/models.py:966
msgid "Document/user link traces"
msgstr "Document/gebruiker link"
msgstr "Document/gebruiker url"
#: build/lib/core/models.py:974 core/models.py:974
#: build/lib/core/models.py:972 core/models.py:972
msgid "A link trace already exists for this document/user."
msgstr "Een link bestaat al voor dit document/deze gebruiker."
msgstr "Een url bestaat al voor dit document/deze gebruiker."
#: build/lib/core/models.py:997 core/models.py:997
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorite"
msgstr "Document favoriet"
#: build/lib/core/models.py:998 core/models.py:998
#: build/lib/core/models.py:996 core/models.py:996
msgid "Document favorites"
msgstr "Document favorieten"
#: build/lib/core/models.py:1004 core/models.py:1004
#: build/lib/core/models.py:1002 core/models.py:1002
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Dit document is al in gebruik als favoriet door dezelfde gebruiker."
msgstr "Dit document is al in gebruik als favoriete door dezelfde gebruiker."
#: build/lib/core/models.py:1026 core/models.py:1026
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relation"
msgstr "Document/gebruiker relatie"
#: build/lib/core/models.py:1027 core/models.py:1027
#: build/lib/core/models.py:1025 core/models.py:1025
msgid "Document/user relations"
msgstr "Document/gebruiker relaties"
#: build/lib/core/models.py:1033 core/models.py:1033
#: build/lib/core/models.py:1031 core/models.py:1031
msgid "This user is already in this document."
msgstr "De gebruiker bestaat al in dit document."
msgstr "De gebruiker is al in dit document."
#: build/lib/core/models.py:1039 core/models.py:1039
#: build/lib/core/models.py:1037 core/models.py:1037
msgid "This team is already in this document."
msgstr "Dit team bestaat al in dit document."
msgstr "Het team is al in dit document."
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
#: core/models.py:1045 core/models.py:1362
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
#: core/models.py:1043 core/models.py:1368
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:1191 core/models.py:1191
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for access"
msgstr "Document verzoekt om toegang"
#: build/lib/core/models.py:1192 core/models.py:1192
#: build/lib/core/models.py:1190 core/models.py:1190
msgid "Document ask for accesses"
msgstr "Document verzoekt om toegangen"
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1196 core/models.py:1196
msgid "This user has already asked for access to this document."
msgstr "Deze gebruiker heeft al om toegang tot dit document gevraagd."
#: build/lib/core/models.py:1255 core/models.py:1255
#: build/lib/core/models.py:1261 core/models.py:1261
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} verzoekt toegang tot een document!"
#: build/lib/core/models.py:1259 core/models.py:1259
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} verzoekt toegang tot het volgende document:"
msgstr "{name} wenst toegang tot een document!"
#: build/lib/core/models.py:1265 core/models.py:1265
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} verzoekt toegang tot het document: {title}"
msgid "{name} would like access to the following document:"
msgstr "{name} wenst toegang tot het volgende document:"
#: build/lib/core/models.py:1277 core/models.py:1277
#: build/lib/core/models.py:1271 core/models.py:1271
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} vraagt toegang tot het document: {title}"
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "description"
msgstr "omschrijving"
#: build/lib/core/models.py:1278 core/models.py:1278
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "code"
msgstr "code"
#: build/lib/core/models.py:1279 core/models.py:1279
#: build/lib/core/models.py:1285 core/models.py:1285
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1281 core/models.py:1281
#: build/lib/core/models.py:1287 core/models.py:1287
msgid "public"
msgstr "publiek"
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "Whether this template is public for anyone to use."
msgstr "Of dit sjabloon door iedereen publiekelijk te gebruiken is."
#: build/lib/core/models.py:1289 core/models.py:1289
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:1295 core/models.py:1295
msgid "Template"
msgstr "Sjabloon"
msgstr "Template"
#: build/lib/core/models.py:1290 core/models.py:1290
#: build/lib/core/models.py:1296 core/models.py:1296
msgid "Templates"
msgstr "Sjabloon"
msgstr "Templates"
#: build/lib/core/models.py:1343 core/models.py:1343
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relation"
msgstr "Sjabloon/gebruiker relatie"
#: build/lib/core/models.py:1344 core/models.py:1344
msgid "Template/user relations"
msgstr "Sjabloon/gebruiker relaties"
msgstr "Template/gebruiker relatie"
#: build/lib/core/models.py:1350 core/models.py:1350
msgid "This user is already in this template."
msgstr "De gebruiker bestaat al in dit sjabloon."
msgid "Template/user relations"
msgstr "Template/gebruiker relaties"
#: build/lib/core/models.py:1356 core/models.py:1356
msgid "This user is already in this template."
msgstr "De gebruiker bestaat al in dit template."
#: build/lib/core/models.py:1362 core/models.py:1362
msgid "This team is already in this template."
msgstr "Het team bestaat al in dit sjabloon."
msgstr "Het team bestaat al in dit template."
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "email address"
msgstr "e-mailadres"
msgstr "email adres"
#: build/lib/core/models.py:1452 core/models.py:1452
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitation"
msgstr "Document uitnodiging"
#: build/lib/core/models.py:1453 core/models.py:1453
#: build/lib/core/models.py:1459 core/models.py:1459
msgid "Document invitations"
msgstr "Document uitnodigingen"
#: build/lib/core/models.py:1473 core/models.py:1473
#: build/lib/core/models.py:1479 core/models.py:1479
msgid "This email is already associated to a registered user."
msgstr "Deze email is al geassocieerd met een geregistreerde gebruiker."
+65 -65
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
"PO-Revision-Date: 2025-11-19 10:13\n"
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
"PO-Revision-Date: 2025-10-14 13:09\n"
"Last-Translator: \n"
"Language-Team: Portuguese\n"
"Language: pt_PT\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:36 core/admin.py:36
#: build/lib/core/admin.py:37 core/admin.py:37
msgid "Personal info"
msgstr "Informações Pessoais"
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr "Permissões"
#: build/lib/core/admin.py:61 core/admin.py:61
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
msgstr "Datas importantes"
#: build/lib/core/admin.py:147 core/admin.py:147
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr "Estrutura de árvore"
@@ -79,7 +79,7 @@ msgstr "Tipo de corpo"
msgid "Format"
msgstr "Formato"
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
#, python-brace-format
msgid "copy of {title}"
msgstr "cópia de {title}"
@@ -178,228 +178,228 @@ msgstr ""
msgid "full name"
msgstr ""
#: build/lib/core/models.py:152 core/models.py:152
#: build/lib/core/models.py:151 core/models.py:151
msgid "short name"
msgstr ""
#: build/lib/core/models.py:155 core/models.py:155
#: build/lib/core/models.py:153 core/models.py:153
msgid "identity email address"
msgstr ""
#: build/lib/core/models.py:160 core/models.py:160
#: build/lib/core/models.py:158 core/models.py:158
msgid "admin email address"
msgstr ""
#: build/lib/core/models.py:167 core/models.py:167
#: build/lib/core/models.py:165 core/models.py:165
msgid "language"
msgstr ""
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:166 core/models.py:166
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/core/models.py:176 core/models.py:176
#: build/lib/core/models.py:174 core/models.py:174
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:177 core/models.py:177
msgid "device"
msgstr ""
#: build/lib/core/models.py:181 core/models.py:181
#: build/lib/core/models.py:179 core/models.py:179
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:182 core/models.py:182
msgid "staff status"
msgstr ""
#: build/lib/core/models.py:186 core/models.py:186
#: build/lib/core/models.py:184 core/models.py:184
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/core/models.py:189 core/models.py:189
#: build/lib/core/models.py:187 core/models.py:187
msgid "active"
msgstr ""
#: build/lib/core/models.py:192 core/models.py:192
#: build/lib/core/models.py:190 core/models.py:190
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: build/lib/core/models.py:204 core/models.py:204
#: build/lib/core/models.py:202 core/models.py:202
msgid "user"
msgstr ""
#: build/lib/core/models.py:205 core/models.py:205
#: build/lib/core/models.py:203 core/models.py:203
msgid "users"
msgstr ""
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
#: core/models.py:361 core/models.py:1276
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
#: core/models.py:359 core/models.py:1282
msgid "title"
msgstr ""
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:360 core/models.py:360
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:411 core/models.py:411
#: build/lib/core/models.py:409 core/models.py:409
msgid "Document"
msgstr ""
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:410 core/models.py:410
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
#: core/models.py:820
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:855 core/models.py:855
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:861 core/models.py:861
#: build/lib/core/models.py:859 core/models.py:859
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:867 core/models.py:867
#: build/lib/core/models.py:865 core/models.py:865
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:967 core/models.py:967
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:968 core/models.py:968
#: build/lib/core/models.py:966 core/models.py:966
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:974 core/models.py:974
#: build/lib/core/models.py:972 core/models.py:972
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:997 core/models.py:997
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:998 core/models.py:998
#: build/lib/core/models.py:996 core/models.py:996
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1004 core/models.py:1004
#: build/lib/core/models.py:1002 core/models.py:1002
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1026 core/models.py:1026
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1027 core/models.py:1027
#: build/lib/core/models.py:1025 core/models.py:1025
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1033 core/models.py:1033
#: build/lib/core/models.py:1031 core/models.py:1031
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1039 core/models.py:1039
#: build/lib/core/models.py:1037 core/models.py:1037
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
#: core/models.py:1045 core/models.py:1362
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
#: core/models.py:1043 core/models.py:1368
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1191 core/models.py:1191
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1192 core/models.py:1192
#: build/lib/core/models.py:1190 core/models.py:1190
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1196 core/models.py:1196
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1255 core/models.py:1255
#: build/lib/core/models.py:1261 core/models.py:1261
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1259 core/models.py:1259
#: build/lib/core/models.py:1265 core/models.py:1265
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1265 core/models.py:1265
#: build/lib/core/models.py:1271 core/models.py:1271
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1277 core/models.py:1277
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "description"
msgstr ""
#: build/lib/core/models.py:1278 core/models.py:1278
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "code"
msgstr ""
#: build/lib/core/models.py:1279 core/models.py:1279
#: build/lib/core/models.py:1285 core/models.py:1285
msgid "css"
msgstr ""
#: build/lib/core/models.py:1281 core/models.py:1281
#: build/lib/core/models.py:1287 core/models.py:1287
msgid "public"
msgstr ""
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1290 core/models.py:1290
#: build/lib/core/models.py:1296 core/models.py:1296
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1343 core/models.py:1343
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1344 core/models.py:1344
#: build/lib/core/models.py:1350 core/models.py:1350
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1350 core/models.py:1350
#: build/lib/core/models.py:1356 core/models.py:1356
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1356 core/models.py:1356
#: build/lib/core/models.py:1362 core/models.py:1362
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1452 core/models.py:1452
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1453 core/models.py:1453
#: build/lib/core/models.py:1459 core/models.py:1459
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1473 core/models.py:1473
#: build/lib/core/models.py:1479 core/models.py:1479
msgid "This email is already associated to a registered user."
msgstr ""
+65 -65
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
"PO-Revision-Date: 2025-11-19 10:13\n"
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
"PO-Revision-Date: 2025-10-14 13:09\n"
"Last-Translator: \n"
"Language-Team: Russian\n"
"Language: ru_RU\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:36 core/admin.py:36
#: build/lib/core/admin.py:37 core/admin.py:37
msgid "Personal info"
msgstr "Личная информация"
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr "Разрешения"
#: build/lib/core/admin.py:61 core/admin.py:61
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
msgstr "Важные даты"
#: build/lib/core/admin.py:147 core/admin.py:147
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr "Древовидная структура"
@@ -79,7 +79,7 @@ msgstr "Тип сообщения"
msgid "Format"
msgstr "Формат"
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
#, python-brace-format
msgid "copy of {title}"
msgstr "копия {title}"
@@ -178,228 +178,228 @@ msgstr "Обязательно. 255 символов или меньше. Тол
msgid "full name"
msgstr "полное имя"
#: build/lib/core/models.py:152 core/models.py:152
#: build/lib/core/models.py:151 core/models.py:151
msgid "short name"
msgstr "короткое имя"
#: build/lib/core/models.py:155 core/models.py:155
#: build/lib/core/models.py:153 core/models.py:153
msgid "identity email address"
msgstr "личный адрес электронной почты"
#: build/lib/core/models.py:160 core/models.py:160
#: build/lib/core/models.py:158 core/models.py:158
msgid "admin email address"
msgstr "e-mail администратора"
#: build/lib/core/models.py:167 core/models.py:167
#: build/lib/core/models.py:165 core/models.py:165
msgid "language"
msgstr "язык"
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:166 core/models.py:166
msgid "The language in which the user wants to see the interface."
msgstr "Язык, на котором пользователь хочет видеть интерфейс."
#: build/lib/core/models.py:176 core/models.py:176
#: build/lib/core/models.py:174 core/models.py:174
msgid "The timezone in which the user wants to see times."
msgstr "Часовой пояс, в котором пользователь хочет видеть время."
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:177 core/models.py:177
msgid "device"
msgstr "устройство"
#: build/lib/core/models.py:181 core/models.py:181
#: build/lib/core/models.py:179 core/models.py:179
msgid "Whether the user is a device or a real user."
msgstr "Пользователь является устройством или человеком."
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:182 core/models.py:182
msgid "staff status"
msgstr "статус сотрудника"
#: build/lib/core/models.py:186 core/models.py:186
#: build/lib/core/models.py:184 core/models.py:184
msgid "Whether the user can log into this admin site."
msgstr "Может ли пользователь войти на этот административный сайт."
#: build/lib/core/models.py:189 core/models.py:189
#: build/lib/core/models.py:187 core/models.py:187
msgid "active"
msgstr "активный"
#: build/lib/core/models.py:192 core/models.py:192
#: build/lib/core/models.py:190 core/models.py:190
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Должен ли пользователь рассматриваться как активный. Альтернатива удалению учётных записей."
#: build/lib/core/models.py:204 core/models.py:204
#: build/lib/core/models.py:202 core/models.py:202
msgid "user"
msgstr "пользователь"
#: build/lib/core/models.py:205 core/models.py:205
#: build/lib/core/models.py:203 core/models.py:203
msgid "users"
msgstr "пользователи"
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
#: core/models.py:361 core/models.py:1276
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
#: core/models.py:359 core/models.py:1282
msgid "title"
msgstr "заголовок"
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:360 core/models.py:360
msgid "excerpt"
msgstr "отрывок"
#: build/lib/core/models.py:411 core/models.py:411
#: build/lib/core/models.py:409 core/models.py:409
msgid "Document"
msgstr "Документ"
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:410 core/models.py:410
msgid "Documents"
msgstr "Документы"
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
#: core/models.py:820
msgid "Untitled Document"
msgstr "Безымянный документ"
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:855 core/models.py:855
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} делится с вами документом!"
#: build/lib/core/models.py:861 core/models.py:861
#: build/lib/core/models.py:859 core/models.py:859
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} приглашает вас присоединиться к следующему документу с ролью \"{role}\":"
#: build/lib/core/models.py:867 core/models.py:867
#: build/lib/core/models.py:865 core/models.py:865
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} делится с вами документом: {title}"
#: build/lib/core/models.py:967 core/models.py:967
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link trace"
msgstr "Трассировка связи документ/пользователь"
#: build/lib/core/models.py:968 core/models.py:968
#: build/lib/core/models.py:966 core/models.py:966
msgid "Document/user link traces"
msgstr "Трассировка связей документ/пользователь"
#: build/lib/core/models.py:974 core/models.py:974
#: build/lib/core/models.py:972 core/models.py:972
msgid "A link trace already exists for this document/user."
msgstr "Для этого документа/пользователя уже существует трассировка ссылки."
#: build/lib/core/models.py:997 core/models.py:997
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorite"
msgstr "Избранный документ"
#: build/lib/core/models.py:998 core/models.py:998
#: build/lib/core/models.py:996 core/models.py:996
msgid "Document favorites"
msgstr "Избранные документы"
#: build/lib/core/models.py:1004 core/models.py:1004
#: build/lib/core/models.py:1002 core/models.py:1002
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Этот документ уже помечен как избранный для этого пользователя."
#: build/lib/core/models.py:1026 core/models.py:1026
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relation"
msgstr "Отношение документ/пользователь"
#: build/lib/core/models.py:1027 core/models.py:1027
#: build/lib/core/models.py:1025 core/models.py:1025
msgid "Document/user relations"
msgstr "Отношения документ/пользователь"
#: build/lib/core/models.py:1033 core/models.py:1033
#: build/lib/core/models.py:1031 core/models.py:1031
msgid "This user is already in this document."
msgstr "Этот пользователь уже имеет доступ к этому документу."
#: build/lib/core/models.py:1039 core/models.py:1039
#: build/lib/core/models.py:1037 core/models.py:1037
msgid "This team is already in this document."
msgstr "Эта команда уже имеет доступ к этому документу."
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
#: core/models.py:1045 core/models.py:1362
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
#: core/models.py:1043 core/models.py:1368
msgid "Either user or team must be set, not both."
msgstr "Может быть выбран либо пользователь, либо команда, но не оба варианта сразу."
#: build/lib/core/models.py:1191 core/models.py:1191
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for access"
msgstr "Документ запрашивает доступ"
#: build/lib/core/models.py:1192 core/models.py:1192
#: build/lib/core/models.py:1190 core/models.py:1190
msgid "Document ask for accesses"
msgstr "Документ запрашивает доступы"
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1196 core/models.py:1196
msgid "This user has already asked for access to this document."
msgstr "Этот пользователь уже запросил доступ к этому документу."
#: build/lib/core/models.py:1255 core/models.py:1255
#: build/lib/core/models.py:1261 core/models.py:1261
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} хочет получить доступ к документу!"
#: build/lib/core/models.py:1259 core/models.py:1259
#: build/lib/core/models.py:1265 core/models.py:1265
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} хочет получить доступ к следующему документу:"
#: build/lib/core/models.py:1265 core/models.py:1265
#: build/lib/core/models.py:1271 core/models.py:1271
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} запрашивает доступ к документу: {title}"
#: build/lib/core/models.py:1277 core/models.py:1277
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "description"
msgstr "описание"
#: build/lib/core/models.py:1278 core/models.py:1278
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "code"
msgstr "код"
#: build/lib/core/models.py:1279 core/models.py:1279
#: build/lib/core/models.py:1285 core/models.py:1285
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1281 core/models.py:1281
#: build/lib/core/models.py:1287 core/models.py:1287
msgid "public"
msgstr "доступно всем"
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "Whether this template is public for anyone to use."
msgstr "Этот шаблон доступен всем пользователям."
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Template"
msgstr "Шаблон"
#: build/lib/core/models.py:1290 core/models.py:1290
#: build/lib/core/models.py:1296 core/models.py:1296
msgid "Templates"
msgstr "Шаблоны"
#: build/lib/core/models.py:1343 core/models.py:1343
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relation"
msgstr "Отношение шаблон/пользователь"
#: build/lib/core/models.py:1344 core/models.py:1344
#: build/lib/core/models.py:1350 core/models.py:1350
msgid "Template/user relations"
msgstr "Отношения шаблон/пользователь"
#: build/lib/core/models.py:1350 core/models.py:1350
#: build/lib/core/models.py:1356 core/models.py:1356
msgid "This user is already in this template."
msgstr "Этот пользователь уже указан в этом шаблоне."
#: build/lib/core/models.py:1356 core/models.py:1356
#: build/lib/core/models.py:1362 core/models.py:1362
msgid "This team is already in this template."
msgstr "Эта команда уже указана в этом шаблоне."
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "email address"
msgstr "адрес электронной почты"
#: build/lib/core/models.py:1452 core/models.py:1452
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitation"
msgstr "Приглашение для документа"
#: build/lib/core/models.py:1453 core/models.py:1453
#: build/lib/core/models.py:1459 core/models.py:1459
msgid "Document invitations"
msgstr "Приглашения для документов"
#: build/lib/core/models.py:1473 core/models.py:1473
#: build/lib/core/models.py:1479 core/models.py:1479
msgid "This email is already associated to a registered user."
msgstr "Этот адрес уже связан с зарегистрированным пользователем."
+65 -65
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
"PO-Revision-Date: 2025-11-19 10:13\n"
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
"PO-Revision-Date: 2025-10-14 13:09\n"
"Last-Translator: \n"
"Language-Team: Slovenian\n"
"Language: sl_SI\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:36 core/admin.py:36
#: build/lib/core/admin.py:37 core/admin.py:37
msgid "Personal info"
msgstr "Osebni podatki"
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr "Dovoljenja"
#: build/lib/core/admin.py:61 core/admin.py:61
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
msgstr "Pomembni datumi"
#: build/lib/core/admin.py:147 core/admin.py:147
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr "Drevesna struktura"
@@ -79,7 +79,7 @@ msgstr "Vrsta telesa"
msgid "Format"
msgstr "Oblika"
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
#, python-brace-format
msgid "copy of {title}"
msgstr ""
@@ -178,228 +178,228 @@ msgstr ""
msgid "full name"
msgstr "polno ime"
#: build/lib/core/models.py:152 core/models.py:152
#: build/lib/core/models.py:151 core/models.py:151
msgid "short name"
msgstr "kratko ime"
#: build/lib/core/models.py:155 core/models.py:155
#: build/lib/core/models.py:153 core/models.py:153
msgid "identity email address"
msgstr "elektronski naslov identitete"
#: build/lib/core/models.py:160 core/models.py:160
#: build/lib/core/models.py:158 core/models.py:158
msgid "admin email address"
msgstr "elektronski naslov skrbnika"
#: build/lib/core/models.py:167 core/models.py:167
#: build/lib/core/models.py:165 core/models.py:165
msgid "language"
msgstr "jezik"
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:166 core/models.py:166
msgid "The language in which the user wants to see the interface."
msgstr "Jezik, v katerem uporabnik želi videti vmesnik."
#: build/lib/core/models.py:176 core/models.py:176
#: build/lib/core/models.py:174 core/models.py:174
msgid "The timezone in which the user wants to see times."
msgstr "Časovni pas, v katerem želi uporabnik videti uro."
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:177 core/models.py:177
msgid "device"
msgstr "naprava"
#: build/lib/core/models.py:181 core/models.py:181
#: build/lib/core/models.py:179 core/models.py:179
msgid "Whether the user is a device or a real user."
msgstr "Ali je uporabnik naprava ali pravi uporabnik."
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:182 core/models.py:182
msgid "staff status"
msgstr "kadrovski status"
#: build/lib/core/models.py:186 core/models.py:186
#: build/lib/core/models.py:184 core/models.py:184
msgid "Whether the user can log into this admin site."
msgstr "Ali se uporabnik lahko prijavi na to skrbniško mesto."
#: build/lib/core/models.py:189 core/models.py:189
#: build/lib/core/models.py:187 core/models.py:187
msgid "active"
msgstr "aktivni"
#: build/lib/core/models.py:192 core/models.py:192
#: build/lib/core/models.py:190 core/models.py:190
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Ali je treba tega uporabnika obravnavati kot aktivnega. Namesto brisanja računov počistite to izbiro."
#: build/lib/core/models.py:204 core/models.py:204
#: build/lib/core/models.py:202 core/models.py:202
msgid "user"
msgstr "uporabnik"
#: build/lib/core/models.py:205 core/models.py:205
#: build/lib/core/models.py:203 core/models.py:203
msgid "users"
msgstr "uporabniki"
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
#: core/models.py:361 core/models.py:1276
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
#: core/models.py:359 core/models.py:1282
msgid "title"
msgstr "naslov"
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:360 core/models.py:360
msgid "excerpt"
msgstr "odlomek"
#: build/lib/core/models.py:411 core/models.py:411
#: build/lib/core/models.py:409 core/models.py:409
msgid "Document"
msgstr "Dokument"
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:410 core/models.py:410
msgid "Documents"
msgstr "Dokumenti"
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
#: core/models.py:820
msgid "Untitled Document"
msgstr "Dokument brez naslova"
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:855 core/models.py:855
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} je delil dokument z vami!"
#: build/lib/core/models.py:861 core/models.py:861
#: build/lib/core/models.py:859 core/models.py:859
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} vas je povabil z vlogo \"{role}\" na naslednjem dokumentu:"
#: build/lib/core/models.py:867 core/models.py:867
#: build/lib/core/models.py:865 core/models.py:865
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} je delil dokument z vami: {title}"
#: build/lib/core/models.py:967 core/models.py:967
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link trace"
msgstr "Dokument/sled povezave uporabnika"
#: build/lib/core/models.py:968 core/models.py:968
#: build/lib/core/models.py:966 core/models.py:966
msgid "Document/user link traces"
msgstr "Sledi povezav dokumenta/uporabnika"
#: build/lib/core/models.py:974 core/models.py:974
#: build/lib/core/models.py:972 core/models.py:972
msgid "A link trace already exists for this document/user."
msgstr "Za ta dokument/uporabnika že obstaja sled povezave."
#: build/lib/core/models.py:997 core/models.py:997
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorite"
msgstr "Priljubljeni dokument"
#: build/lib/core/models.py:998 core/models.py:998
#: build/lib/core/models.py:996 core/models.py:996
msgid "Document favorites"
msgstr "Priljubljeni dokumenti"
#: build/lib/core/models.py:1004 core/models.py:1004
#: build/lib/core/models.py:1002 core/models.py:1002
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Ta dokument je že ciljno usmerjen s priljubljenim primerkom relacije za istega uporabnika."
#: build/lib/core/models.py:1026 core/models.py:1026
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relation"
msgstr "Odnos dokument/uporabnik"
#: build/lib/core/models.py:1027 core/models.py:1027
#: build/lib/core/models.py:1025 core/models.py:1025
msgid "Document/user relations"
msgstr "Odnosi dokument/uporabnik"
#: build/lib/core/models.py:1033 core/models.py:1033
#: build/lib/core/models.py:1031 core/models.py:1031
msgid "This user is already in this document."
msgstr "Ta uporabnik je že v tem dokumentu."
#: build/lib/core/models.py:1039 core/models.py:1039
#: build/lib/core/models.py:1037 core/models.py:1037
msgid "This team is already in this document."
msgstr "Ta ekipa je že v tem dokumentu."
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
#: core/models.py:1045 core/models.py:1362
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
#: core/models.py:1043 core/models.py:1368
msgid "Either user or team must be set, not both."
msgstr "Nastaviti je treba bodisi uporabnika ali ekipo, a ne obojega."
#: build/lib/core/models.py:1191 core/models.py:1191
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1192 core/models.py:1192
#: build/lib/core/models.py:1190 core/models.py:1190
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1196 core/models.py:1196
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1255 core/models.py:1255
#: build/lib/core/models.py:1261 core/models.py:1261
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1259 core/models.py:1259
#: build/lib/core/models.py:1265 core/models.py:1265
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1265 core/models.py:1265
#: build/lib/core/models.py:1271 core/models.py:1271
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1277 core/models.py:1277
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "description"
msgstr "opis"
#: build/lib/core/models.py:1278 core/models.py:1278
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "code"
msgstr "koda"
#: build/lib/core/models.py:1279 core/models.py:1279
#: build/lib/core/models.py:1285 core/models.py:1285
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1281 core/models.py:1281
#: build/lib/core/models.py:1287 core/models.py:1287
msgid "public"
msgstr "javno"
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "Whether this template is public for anyone to use."
msgstr "Ali je ta predloga javna za uporabo."
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Template"
msgstr "Predloga"
#: build/lib/core/models.py:1290 core/models.py:1290
#: build/lib/core/models.py:1296 core/models.py:1296
msgid "Templates"
msgstr "Predloge"
#: build/lib/core/models.py:1343 core/models.py:1343
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relation"
msgstr "Odnos predloga/uporabnik"
#: build/lib/core/models.py:1344 core/models.py:1344
#: build/lib/core/models.py:1350 core/models.py:1350
msgid "Template/user relations"
msgstr "Odnosi med predlogo in uporabnikom"
#: build/lib/core/models.py:1350 core/models.py:1350
#: build/lib/core/models.py:1356 core/models.py:1356
msgid "This user is already in this template."
msgstr "Ta uporabnik je že v tej predlogi."
#: build/lib/core/models.py:1356 core/models.py:1356
#: build/lib/core/models.py:1362 core/models.py:1362
msgid "This team is already in this template."
msgstr "Ta ekipa je že v tej predlogi."
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "email address"
msgstr "elektronski naslov"
#: build/lib/core/models.py:1452 core/models.py:1452
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitation"
msgstr "Vabilo na dokument"
#: build/lib/core/models.py:1453 core/models.py:1453
#: build/lib/core/models.py:1459 core/models.py:1459
msgid "Document invitations"
msgstr "Vabila na dokument"
#: build/lib/core/models.py:1473 core/models.py:1473
#: build/lib/core/models.py:1479 core/models.py:1479
msgid "This email is already associated to a registered user."
msgstr "Ta e-poštni naslov je že povezan z registriranim uporabnikom."
+65 -65
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
"PO-Revision-Date: 2025-11-19 10:13\n"
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
"PO-Revision-Date: 2025-10-14 13:09\n"
"Last-Translator: \n"
"Language-Team: Swedish\n"
"Language: sv_SE\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:36 core/admin.py:36
#: build/lib/core/admin.py:37 core/admin.py:37
msgid "Personal info"
msgstr "Personuppgifter"
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr "Behörigheter"
#: build/lib/core/admin.py:61 core/admin.py:61
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
msgstr "Viktiga datum"
#: build/lib/core/admin.py:147 core/admin.py:147
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr ""
@@ -79,7 +79,7 @@ msgstr ""
msgid "Format"
msgstr "Format"
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
#, python-brace-format
msgid "copy of {title}"
msgstr ""
@@ -178,228 +178,228 @@ msgstr ""
msgid "full name"
msgstr ""
#: build/lib/core/models.py:152 core/models.py:152
#: build/lib/core/models.py:151 core/models.py:151
msgid "short name"
msgstr ""
#: build/lib/core/models.py:155 core/models.py:155
#: build/lib/core/models.py:153 core/models.py:153
msgid "identity email address"
msgstr ""
#: build/lib/core/models.py:160 core/models.py:160
#: build/lib/core/models.py:158 core/models.py:158
msgid "admin email address"
msgstr ""
#: build/lib/core/models.py:167 core/models.py:167
#: build/lib/core/models.py:165 core/models.py:165
msgid "language"
msgstr ""
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:166 core/models.py:166
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/core/models.py:176 core/models.py:176
#: build/lib/core/models.py:174 core/models.py:174
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:177 core/models.py:177
msgid "device"
msgstr ""
#: build/lib/core/models.py:181 core/models.py:181
#: build/lib/core/models.py:179 core/models.py:179
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:182 core/models.py:182
msgid "staff status"
msgstr ""
#: build/lib/core/models.py:186 core/models.py:186
#: build/lib/core/models.py:184 core/models.py:184
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/core/models.py:189 core/models.py:189
#: build/lib/core/models.py:187 core/models.py:187
msgid "active"
msgstr "aktiv"
#: build/lib/core/models.py:192 core/models.py:192
#: build/lib/core/models.py:190 core/models.py:190
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: build/lib/core/models.py:204 core/models.py:204
#: build/lib/core/models.py:202 core/models.py:202
msgid "user"
msgstr ""
#: build/lib/core/models.py:205 core/models.py:205
#: build/lib/core/models.py:203 core/models.py:203
msgid "users"
msgstr ""
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
#: core/models.py:361 core/models.py:1276
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
#: core/models.py:359 core/models.py:1282
msgid "title"
msgstr ""
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:360 core/models.py:360
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:411 core/models.py:411
#: build/lib/core/models.py:409 core/models.py:409
msgid "Document"
msgstr ""
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:410 core/models.py:410
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
#: core/models.py:820
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:855 core/models.py:855
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:861 core/models.py:861
#: build/lib/core/models.py:859 core/models.py:859
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:867 core/models.py:867
#: build/lib/core/models.py:865 core/models.py:865
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:967 core/models.py:967
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:968 core/models.py:968
#: build/lib/core/models.py:966 core/models.py:966
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:974 core/models.py:974
#: build/lib/core/models.py:972 core/models.py:972
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:997 core/models.py:997
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:998 core/models.py:998
#: build/lib/core/models.py:996 core/models.py:996
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1004 core/models.py:1004
#: build/lib/core/models.py:1002 core/models.py:1002
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1026 core/models.py:1026
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1027 core/models.py:1027
#: build/lib/core/models.py:1025 core/models.py:1025
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1033 core/models.py:1033
#: build/lib/core/models.py:1031 core/models.py:1031
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1039 core/models.py:1039
#: build/lib/core/models.py:1037 core/models.py:1037
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
#: core/models.py:1045 core/models.py:1362
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
#: core/models.py:1043 core/models.py:1368
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1191 core/models.py:1191
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1192 core/models.py:1192
#: build/lib/core/models.py:1190 core/models.py:1190
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1196 core/models.py:1196
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1255 core/models.py:1255
#: build/lib/core/models.py:1261 core/models.py:1261
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1259 core/models.py:1259
#: build/lib/core/models.py:1265 core/models.py:1265
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1265 core/models.py:1265
#: build/lib/core/models.py:1271 core/models.py:1271
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1277 core/models.py:1277
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "description"
msgstr ""
#: build/lib/core/models.py:1278 core/models.py:1278
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "code"
msgstr ""
#: build/lib/core/models.py:1279 core/models.py:1279
#: build/lib/core/models.py:1285 core/models.py:1285
msgid "css"
msgstr ""
#: build/lib/core/models.py:1281 core/models.py:1281
#: build/lib/core/models.py:1287 core/models.py:1287
msgid "public"
msgstr ""
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1290 core/models.py:1290
#: build/lib/core/models.py:1296 core/models.py:1296
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1343 core/models.py:1343
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1344 core/models.py:1344
#: build/lib/core/models.py:1350 core/models.py:1350
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1350 core/models.py:1350
#: build/lib/core/models.py:1356 core/models.py:1356
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1356 core/models.py:1356
#: build/lib/core/models.py:1362 core/models.py:1362
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "email address"
msgstr "e-postadress"
#: build/lib/core/models.py:1452 core/models.py:1452
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitation"
msgstr "Bjud in dokument"
#: build/lib/core/models.py:1453 core/models.py:1453
#: build/lib/core/models.py:1459 core/models.py:1459
msgid "Document invitations"
msgstr "Inbjudningar dokument"
#: build/lib/core/models.py:1473 core/models.py:1473
#: build/lib/core/models.py:1479 core/models.py:1479
msgid "This email is already associated to a registered user."
msgstr "Denna e-postadress är redan associerad med en registrerad användare."
+65 -65
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
"PO-Revision-Date: 2025-11-19 10:13\n"
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
"PO-Revision-Date: 2025-10-14 13:09\n"
"Last-Translator: \n"
"Language-Team: Turkish\n"
"Language: tr_TR\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:36 core/admin.py:36
#: build/lib/core/admin.py:37 core/admin.py:37
msgid "Personal info"
msgstr ""
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr ""
#: build/lib/core/admin.py:61 core/admin.py:61
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
msgstr ""
#: build/lib/core/admin.py:147 core/admin.py:147
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr ""
@@ -79,7 +79,7 @@ msgstr ""
msgid "Format"
msgstr ""
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
#, python-brace-format
msgid "copy of {title}"
msgstr ""
@@ -178,228 +178,228 @@ msgstr ""
msgid "full name"
msgstr ""
#: build/lib/core/models.py:152 core/models.py:152
#: build/lib/core/models.py:151 core/models.py:151
msgid "short name"
msgstr ""
#: build/lib/core/models.py:155 core/models.py:155
#: build/lib/core/models.py:153 core/models.py:153
msgid "identity email address"
msgstr ""
#: build/lib/core/models.py:160 core/models.py:160
#: build/lib/core/models.py:158 core/models.py:158
msgid "admin email address"
msgstr ""
#: build/lib/core/models.py:167 core/models.py:167
#: build/lib/core/models.py:165 core/models.py:165
msgid "language"
msgstr ""
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:166 core/models.py:166
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/core/models.py:176 core/models.py:176
#: build/lib/core/models.py:174 core/models.py:174
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:177 core/models.py:177
msgid "device"
msgstr ""
#: build/lib/core/models.py:181 core/models.py:181
#: build/lib/core/models.py:179 core/models.py:179
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:182 core/models.py:182
msgid "staff status"
msgstr ""
#: build/lib/core/models.py:186 core/models.py:186
#: build/lib/core/models.py:184 core/models.py:184
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/core/models.py:189 core/models.py:189
#: build/lib/core/models.py:187 core/models.py:187
msgid "active"
msgstr ""
#: build/lib/core/models.py:192 core/models.py:192
#: build/lib/core/models.py:190 core/models.py:190
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: build/lib/core/models.py:204 core/models.py:204
#: build/lib/core/models.py:202 core/models.py:202
msgid "user"
msgstr ""
#: build/lib/core/models.py:205 core/models.py:205
#: build/lib/core/models.py:203 core/models.py:203
msgid "users"
msgstr ""
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
#: core/models.py:361 core/models.py:1276
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
#: core/models.py:359 core/models.py:1282
msgid "title"
msgstr ""
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:360 core/models.py:360
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:411 core/models.py:411
#: build/lib/core/models.py:409 core/models.py:409
msgid "Document"
msgstr ""
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:410 core/models.py:410
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
#: core/models.py:820
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:855 core/models.py:855
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:861 core/models.py:861
#: build/lib/core/models.py:859 core/models.py:859
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:867 core/models.py:867
#: build/lib/core/models.py:865 core/models.py:865
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:967 core/models.py:967
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:968 core/models.py:968
#: build/lib/core/models.py:966 core/models.py:966
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:974 core/models.py:974
#: build/lib/core/models.py:972 core/models.py:972
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:997 core/models.py:997
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:998 core/models.py:998
#: build/lib/core/models.py:996 core/models.py:996
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1004 core/models.py:1004
#: build/lib/core/models.py:1002 core/models.py:1002
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1026 core/models.py:1026
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1027 core/models.py:1027
#: build/lib/core/models.py:1025 core/models.py:1025
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1033 core/models.py:1033
#: build/lib/core/models.py:1031 core/models.py:1031
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1039 core/models.py:1039
#: build/lib/core/models.py:1037 core/models.py:1037
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
#: core/models.py:1045 core/models.py:1362
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
#: core/models.py:1043 core/models.py:1368
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1191 core/models.py:1191
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1192 core/models.py:1192
#: build/lib/core/models.py:1190 core/models.py:1190
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1196 core/models.py:1196
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1255 core/models.py:1255
#: build/lib/core/models.py:1261 core/models.py:1261
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1259 core/models.py:1259
#: build/lib/core/models.py:1265 core/models.py:1265
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1265 core/models.py:1265
#: build/lib/core/models.py:1271 core/models.py:1271
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1277 core/models.py:1277
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "description"
msgstr ""
#: build/lib/core/models.py:1278 core/models.py:1278
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "code"
msgstr ""
#: build/lib/core/models.py:1279 core/models.py:1279
#: build/lib/core/models.py:1285 core/models.py:1285
msgid "css"
msgstr ""
#: build/lib/core/models.py:1281 core/models.py:1281
#: build/lib/core/models.py:1287 core/models.py:1287
msgid "public"
msgstr ""
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1290 core/models.py:1290
#: build/lib/core/models.py:1296 core/models.py:1296
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1343 core/models.py:1343
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1344 core/models.py:1344
#: build/lib/core/models.py:1350 core/models.py:1350
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1350 core/models.py:1350
#: build/lib/core/models.py:1356 core/models.py:1356
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1356 core/models.py:1356
#: build/lib/core/models.py:1362 core/models.py:1362
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1452 core/models.py:1452
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1453 core/models.py:1453
#: build/lib/core/models.py:1459 core/models.py:1459
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1473 core/models.py:1473
#: build/lib/core/models.py:1479 core/models.py:1479
msgid "This email is already associated to a registered user."
msgstr ""
+65 -65
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
"PO-Revision-Date: 2025-11-19 10:13\n"
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
"PO-Revision-Date: 2025-10-14 13:09\n"
"Last-Translator: \n"
"Language-Team: Ukrainian\n"
"Language: uk_UA\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:36 core/admin.py:36
#: build/lib/core/admin.py:37 core/admin.py:37
msgid "Personal info"
msgstr "Особисті дані"
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr "Дозволи"
#: build/lib/core/admin.py:61 core/admin.py:61
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
msgstr "Важливі дати"
#: build/lib/core/admin.py:147 core/admin.py:147
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr "Ієрархічна структура"
@@ -79,7 +79,7 @@ msgstr "Тип вмісту"
msgid "Format"
msgstr "Формат"
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
#, python-brace-format
msgid "copy of {title}"
msgstr "копія {title}"
@@ -178,228 +178,228 @@ msgstr "Обов'язкове. 255 символів або менше. Тіль
msgid "full name"
msgstr "повне ім'я"
#: build/lib/core/models.py:152 core/models.py:152
#: build/lib/core/models.py:151 core/models.py:151
msgid "short name"
msgstr "коротке ім'я"
#: build/lib/core/models.py:155 core/models.py:155
#: build/lib/core/models.py:153 core/models.py:153
msgid "identity email address"
msgstr "адреса електронної пошти особи"
#: build/lib/core/models.py:160 core/models.py:160
#: build/lib/core/models.py:158 core/models.py:158
msgid "admin email address"
msgstr "електронна адреса адміністратора"
#: build/lib/core/models.py:167 core/models.py:167
#: build/lib/core/models.py:165 core/models.py:165
msgid "language"
msgstr "мова"
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:166 core/models.py:166
msgid "The language in which the user wants to see the interface."
msgstr "Мова, якою користувач хоче бачити інтерфейс."
#: build/lib/core/models.py:176 core/models.py:176
#: build/lib/core/models.py:174 core/models.py:174
msgid "The timezone in which the user wants to see times."
msgstr "Часовий пояс, в якому користувач хоче бачити час."
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:177 core/models.py:177
msgid "device"
msgstr "пристрій"
#: build/lib/core/models.py:181 core/models.py:181
#: build/lib/core/models.py:179 core/models.py:179
msgid "Whether the user is a device or a real user."
msgstr "Чи є користувач пристроєм чи реальним користувачем."
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:182 core/models.py:182
msgid "staff status"
msgstr "статус співробітника"
#: build/lib/core/models.py:186 core/models.py:186
#: build/lib/core/models.py:184 core/models.py:184
msgid "Whether the user can log into this admin site."
msgstr "Чи може користувач увійти на цей сайт адміністратора."
#: build/lib/core/models.py:189 core/models.py:189
#: build/lib/core/models.py:187 core/models.py:187
msgid "active"
msgstr "активний"
#: build/lib/core/models.py:192 core/models.py:192
#: build/lib/core/models.py:190 core/models.py:190
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Чи слід ставитися до цього користувача як до активного. Зніміть вибір замість видалення облікового запису."
#: build/lib/core/models.py:204 core/models.py:204
#: build/lib/core/models.py:202 core/models.py:202
msgid "user"
msgstr "користувач"
#: build/lib/core/models.py:205 core/models.py:205
#: build/lib/core/models.py:203 core/models.py:203
msgid "users"
msgstr "користувачі"
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
#: core/models.py:361 core/models.py:1276
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
#: core/models.py:359 core/models.py:1282
msgid "title"
msgstr "заголовок"
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:360 core/models.py:360
msgid "excerpt"
msgstr "уривок"
#: build/lib/core/models.py:411 core/models.py:411
#: build/lib/core/models.py:409 core/models.py:409
msgid "Document"
msgstr "Документ"
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:410 core/models.py:410
msgid "Documents"
msgstr "Документи"
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
#: core/models.py:820
msgid "Untitled Document"
msgstr "Документ без назви"
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:855 core/models.py:855
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} ділиться з вами документом!"
#: build/lib/core/models.py:861 core/models.py:861
#: build/lib/core/models.py:859 core/models.py:859
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} запрошує вас для роботи з документом із роллю \"{role}\":"
#: build/lib/core/models.py:867 core/models.py:867
#: build/lib/core/models.py:865 core/models.py:865
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} ділиться з вами документом: {title}"
#: build/lib/core/models.py:967 core/models.py:967
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link trace"
msgstr "Трасування посилання Документ/користувач"
#: build/lib/core/models.py:968 core/models.py:968
#: build/lib/core/models.py:966 core/models.py:966
msgid "Document/user link traces"
msgstr "Трасування посилань Документ/користувач"
#: build/lib/core/models.py:974 core/models.py:974
#: build/lib/core/models.py:972 core/models.py:972
msgid "A link trace already exists for this document/user."
msgstr "Відстеження вже існуючих посилань для цього документа/користувача."
#: build/lib/core/models.py:997 core/models.py:997
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorite"
msgstr "Обраний документ"
#: build/lib/core/models.py:998 core/models.py:998
#: build/lib/core/models.py:996 core/models.py:996
msgid "Document favorites"
msgstr "Обрані документи"
#: build/lib/core/models.py:1004 core/models.py:1004
#: build/lib/core/models.py:1002 core/models.py:1002
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Цей документ вже вказаний як обраний для одного користувача."
#: build/lib/core/models.py:1026 core/models.py:1026
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relation"
msgstr "Відносини документ/користувач"
#: build/lib/core/models.py:1027 core/models.py:1027
#: build/lib/core/models.py:1025 core/models.py:1025
msgid "Document/user relations"
msgstr "Відносини документ/користувач"
#: build/lib/core/models.py:1033 core/models.py:1033
#: build/lib/core/models.py:1031 core/models.py:1031
msgid "This user is already in this document."
msgstr "Цей користувач вже має доступ до цього документу."
#: build/lib/core/models.py:1039 core/models.py:1039
#: build/lib/core/models.py:1037 core/models.py:1037
msgid "This team is already in this document."
msgstr "Ця команда вже має доступ до цього документа."
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
#: core/models.py:1045 core/models.py:1362
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
#: core/models.py:1043 core/models.py:1368
msgid "Either user or team must be set, not both."
msgstr "Вкажіть користувача або команду, а не обох."
#: build/lib/core/models.py:1191 core/models.py:1191
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for access"
msgstr "Запит доступу до документа"
#: build/lib/core/models.py:1192 core/models.py:1192
#: build/lib/core/models.py:1190 core/models.py:1190
msgid "Document ask for accesses"
msgstr "Запит доступу для документа"
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1196 core/models.py:1196
msgid "This user has already asked for access to this document."
msgstr "Цей користувач вже попросив доступ до цього документа."
#: build/lib/core/models.py:1255 core/models.py:1255
#: build/lib/core/models.py:1261 core/models.py:1261
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} хоче отримати доступ до документа!"
#: build/lib/core/models.py:1259 core/models.py:1259
#: build/lib/core/models.py:1265 core/models.py:1265
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} бажає отримати доступ до наступного документа:"
#: build/lib/core/models.py:1265 core/models.py:1265
#: build/lib/core/models.py:1271 core/models.py:1271
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} запитує доступ до документа: {title}"
#: build/lib/core/models.py:1277 core/models.py:1277
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "description"
msgstr "опис"
#: build/lib/core/models.py:1278 core/models.py:1278
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "code"
msgstr "код"
#: build/lib/core/models.py:1279 core/models.py:1279
#: build/lib/core/models.py:1285 core/models.py:1285
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1281 core/models.py:1281
#: build/lib/core/models.py:1287 core/models.py:1287
msgid "public"
msgstr "публічне"
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "Whether this template is public for anyone to use."
msgstr "Чи є цей шаблон публічним для будь-кого користувача."
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Template"
msgstr "Шаблон"
#: build/lib/core/models.py:1290 core/models.py:1290
#: build/lib/core/models.py:1296 core/models.py:1296
msgid "Templates"
msgstr "Шаблони"
#: build/lib/core/models.py:1343 core/models.py:1343
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relation"
msgstr "Відношення шаблон/користувач"
#: build/lib/core/models.py:1344 core/models.py:1344
#: build/lib/core/models.py:1350 core/models.py:1350
msgid "Template/user relations"
msgstr "Відношення шаблон/користувач"
#: build/lib/core/models.py:1350 core/models.py:1350
#: build/lib/core/models.py:1356 core/models.py:1356
msgid "This user is already in this template."
msgstr "Цей користувач вже має доступ до цього шаблону."
#: build/lib/core/models.py:1356 core/models.py:1356
#: build/lib/core/models.py:1362 core/models.py:1362
msgid "This team is already in this template."
msgstr "Ця команда вже має доступ до цього шаблону."
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "email address"
msgstr "електронна адреса"
#: build/lib/core/models.py:1452 core/models.py:1452
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitation"
msgstr "Запрошення до редагування документа"
#: build/lib/core/models.py:1453 core/models.py:1453
#: build/lib/core/models.py:1459 core/models.py:1459
msgid "Document invitations"
msgstr "Запрошення до редагування документів"
#: build/lib/core/models.py:1473 core/models.py:1473
#: build/lib/core/models.py:1479 core/models.py:1479
msgid "This email is already associated to a registered user."
msgstr "Ця електронна пошта вже пов'язана з зареєстрованим користувачем."
+65 -65
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
"PO-Revision-Date: 2025-11-19 10:13\n"
"POT-Creation-Date: 2025-10-14 07:19+0000\n"
"PO-Revision-Date: 2025-10-14 13:09\n"
"Last-Translator: \n"
"Language-Team: Chinese Simplified\n"
"Language: zh_CN\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:36 core/admin.py:36
#: build/lib/core/admin.py:37 core/admin.py:37
msgid "Personal info"
msgstr "个人信息"
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr "权限"
#: build/lib/core/admin.py:61 core/admin.py:61
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
msgstr "重要日期"
#: build/lib/core/admin.py:147 core/admin.py:147
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr "树状结构"
@@ -79,7 +79,7 @@ msgstr "正文类型"
msgid "Format"
msgstr "格式"
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#: build/lib/core/api/viewsets.py:983 core/api/viewsets.py:983
#, python-brace-format
msgid "copy of {title}"
msgstr "{title} 的副本"
@@ -178,228 +178,228 @@ msgstr "必填项。限255个字符以内。仅支持ASCII字符。"
msgid "full name"
msgstr "全名"
#: build/lib/core/models.py:152 core/models.py:152
#: build/lib/core/models.py:151 core/models.py:151
msgid "short name"
msgstr "简称"
#: build/lib/core/models.py:155 core/models.py:155
#: build/lib/core/models.py:153 core/models.py:153
msgid "identity email address"
msgstr "身份电子邮件地址"
#: build/lib/core/models.py:160 core/models.py:160
#: build/lib/core/models.py:158 core/models.py:158
msgid "admin email address"
msgstr "管理员电子邮件地址"
#: build/lib/core/models.py:167 core/models.py:167
#: build/lib/core/models.py:165 core/models.py:165
msgid "language"
msgstr "语言"
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:166 core/models.py:166
msgid "The language in which the user wants to see the interface."
msgstr "用户希望看到的界面语言。"
#: build/lib/core/models.py:176 core/models.py:176
#: build/lib/core/models.py:174 core/models.py:174
msgid "The timezone in which the user wants to see times."
msgstr "用户查看时间希望的时区。"
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:177 core/models.py:177
msgid "device"
msgstr "设备"
#: build/lib/core/models.py:181 core/models.py:181
#: build/lib/core/models.py:179 core/models.py:179
msgid "Whether the user is a device or a real user."
msgstr "用户是设备还是真实用户。"
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:182 core/models.py:182
msgid "staff status"
msgstr "员工状态"
#: build/lib/core/models.py:186 core/models.py:186
#: build/lib/core/models.py:184 core/models.py:184
msgid "Whether the user can log into this admin site."
msgstr "用户是否可以登录该管理员站点。"
#: build/lib/core/models.py:189 core/models.py:189
#: build/lib/core/models.py:187 core/models.py:187
msgid "active"
msgstr "激活"
#: build/lib/core/models.py:192 core/models.py:192
#: build/lib/core/models.py:190 core/models.py:190
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "是否应将此用户视为活跃用户。取消选择此选项而不是删除账户。"
#: build/lib/core/models.py:204 core/models.py:204
#: build/lib/core/models.py:202 core/models.py:202
msgid "user"
msgstr "用户"
#: build/lib/core/models.py:205 core/models.py:205
#: build/lib/core/models.py:203 core/models.py:203
msgid "users"
msgstr "个用户"
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
#: core/models.py:361 core/models.py:1276
#: build/lib/core/models.py:359 build/lib/core/models.py:1282
#: core/models.py:359 core/models.py:1282
msgid "title"
msgstr "标题"
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:360 core/models.py:360
msgid "excerpt"
msgstr "摘要"
#: build/lib/core/models.py:411 core/models.py:411
#: build/lib/core/models.py:409 core/models.py:409
msgid "Document"
msgstr "文档"
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:410 core/models.py:410
msgid "Documents"
msgstr "个文档"
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
#: build/lib/core/models.py:422 build/lib/core/models.py:820 core/models.py:422
#: core/models.py:820
msgid "Untitled Document"
msgstr "未命名文档"
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:855 core/models.py:855
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} 与您共享了一个文档!"
#: build/lib/core/models.py:861 core/models.py:861
#: build/lib/core/models.py:859 core/models.py:859
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} 邀请您以“{role}”角色访问以下文档:"
#: build/lib/core/models.py:867 core/models.py:867
#: build/lib/core/models.py:865 core/models.py:865
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} 与您共享了一个文档:{title}"
#: build/lib/core/models.py:967 core/models.py:967
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link trace"
msgstr "文档/用户链接跟踪"
#: build/lib/core/models.py:968 core/models.py:968
#: build/lib/core/models.py:966 core/models.py:966
msgid "Document/user link traces"
msgstr "个文档/用户链接跟踪"
#: build/lib/core/models.py:974 core/models.py:974
#: build/lib/core/models.py:972 core/models.py:972
msgid "A link trace already exists for this document/user."
msgstr "此文档/用户的链接跟踪已存在。"
#: build/lib/core/models.py:997 core/models.py:997
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorite"
msgstr "文档收藏"
#: build/lib/core/models.py:998 core/models.py:998
#: build/lib/core/models.py:996 core/models.py:996
msgid "Document favorites"
msgstr "文档收藏夹"
#: build/lib/core/models.py:1004 core/models.py:1004
#: build/lib/core/models.py:1002 core/models.py:1002
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "该文档已被同一用户的收藏关系实例关联。"
#: build/lib/core/models.py:1026 core/models.py:1026
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relation"
msgstr "文档/用户关系"
#: build/lib/core/models.py:1027 core/models.py:1027
#: build/lib/core/models.py:1025 core/models.py:1025
msgid "Document/user relations"
msgstr "文档/用户关系集"
#: build/lib/core/models.py:1033 core/models.py:1033
#: build/lib/core/models.py:1031 core/models.py:1031
msgid "This user is already in this document."
msgstr "该用户已在此文档中。"
#: build/lib/core/models.py:1039 core/models.py:1039
#: build/lib/core/models.py:1037 core/models.py:1037
msgid "This team is already in this document."
msgstr "该团队已在此文档中。"
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
#: core/models.py:1045 core/models.py:1362
#: build/lib/core/models.py:1043 build/lib/core/models.py:1368
#: core/models.py:1043 core/models.py:1368
msgid "Either user or team must be set, not both."
msgstr "必须设置用户或团队之一,不能同时设置两者。"
#: build/lib/core/models.py:1191 core/models.py:1191
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for access"
msgstr "文档需要访问权限"
#: build/lib/core/models.py:1192 core/models.py:1192
#: build/lib/core/models.py:1190 core/models.py:1190
msgid "Document ask for accesses"
msgstr "文档需要访问权限"
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1196 core/models.py:1196
msgid "This user has already asked for access to this document."
msgstr "用户已申请该文档的访问权限。"
#: build/lib/core/models.py:1255 core/models.py:1255
#: build/lib/core/models.py:1261 core/models.py:1261
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} 申请访问文档!"
#: build/lib/core/models.py:1259 core/models.py:1259
#: build/lib/core/models.py:1265 core/models.py:1265
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} 申请访问以下文档:"
#: build/lib/core/models.py:1265 core/models.py:1265
#: build/lib/core/models.py:1271 core/models.py:1271
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name}申请文档:{title}的访问权限"
#: build/lib/core/models.py:1277 core/models.py:1277
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "description"
msgstr "说明"
#: build/lib/core/models.py:1278 core/models.py:1278
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "code"
msgstr "代码"
#: build/lib/core/models.py:1279 core/models.py:1279
#: build/lib/core/models.py:1285 core/models.py:1285
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1281 core/models.py:1281
#: build/lib/core/models.py:1287 core/models.py:1287
msgid "public"
msgstr "公开"
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "Whether this template is public for anyone to use."
msgstr "该模板是否公开供任何人使用。"
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Template"
msgstr "模板"
#: build/lib/core/models.py:1290 core/models.py:1290
#: build/lib/core/models.py:1296 core/models.py:1296
msgid "Templates"
msgstr "模板"
#: build/lib/core/models.py:1343 core/models.py:1343
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relation"
msgstr "模板/用户关系"
#: build/lib/core/models.py:1344 core/models.py:1344
#: build/lib/core/models.py:1350 core/models.py:1350
msgid "Template/user relations"
msgstr "模板/用户关系集"
#: build/lib/core/models.py:1350 core/models.py:1350
#: build/lib/core/models.py:1356 core/models.py:1356
msgid "This user is already in this template."
msgstr "该用户已在此模板中。"
#: build/lib/core/models.py:1356 core/models.py:1356
#: build/lib/core/models.py:1362 core/models.py:1362
msgid "This team is already in this template."
msgstr "该团队已在此模板中。"
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "email address"
msgstr "电子邮件地址"
#: build/lib/core/models.py:1452 core/models.py:1452
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitation"
msgstr "文档邀请"
#: build/lib/core/models.py:1453 core/models.py:1453
#: build/lib/core/models.py:1459 core/models.py:1459
msgid "Document invitations"
msgstr "文档邀请"
#: build/lib/core/models.py:1473 core/models.py:1473
#: build/lib/core/models.py:1479 core/models.py:1479
msgid "This email is already associated to a registered user."
msgstr "此电子邮件已经与现有注册用户关联。"
+15 -15
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "3.10.0"
version = "3.8.2"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -26,40 +26,40 @@ readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"beautifulsoup4==4.14.2",
"boto3==1.40.74",
"Brotli==1.2.0",
"boto3==1.40.59",
"Brotli==1.1.0",
"celery[redis]==5.5.3",
"django-configurations==2.5.1",
"django-cors-headers==4.9.0",
"django-countries==8.1.0",
"django-countries==7.6.1",
"django-csp==4.0",
"django-filter==25.2",
"django-lasuite[all]==0.0.18",
"django-lasuite[all]==0.0.16",
"django-parler==2.3",
"django-redis==6.0.0",
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django==5.2.8",
"django==5.2.7",
"django-treebeard==4.7.1",
"djangorestframework==3.16.1",
"drf_spectacular==0.29.0",
"drf_spectacular==0.28.0",
"dockerflow==2024.4.2",
"easy_thumbnails==2.10.1",
"factory_boy==3.3.3",
"gunicorn==23.0.0",
"jsonschema==4.25.1",
"lxml==6.0.2",
"markdown==3.10",
"markdown==3.9",
"mozilla-django-oidc==4.0.1",
"nested-multipart-parser==1.6.0",
"openai==2.8.0",
"openai==2.6.1",
"psycopg[binary]==3.2.12",
"pycrdt==0.12.43",
"pycrdt==0.12.42",
"PyJWT==2.10.1",
"python-magic==0.4.27",
"redis<6.0.0",
"requests==2.32.5",
"sentry-sdk==2.44.0",
"sentry-sdk==2.42.1",
"whitenoise==6.11.0",
]
@@ -76,17 +76,17 @@ dev = [
"drf-spectacular-sidecar==2025.10.1",
"freezegun==1.5.5",
"ipdb==0.13.13",
"ipython==9.7.0",
"pyfakefs==5.10.2",
"ipython==9.6.0",
"pyfakefs==5.10.0",
"pylint-django==2.6.1",
"pylint<4.0.0",
"pytest-cov==7.0.0",
"pytest-django==4.11.1",
"pytest==9.0.1",
"pytest==8.4.2",
"pytest-icdiff==0.9",
"pytest-xdist==3.8.0",
"responses==0.25.8",
"ruff==0.14.5",
"ruff==0.14.2",
"types-requests==2.32.4.20250913",
]
File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 839 KiB

@@ -1,295 +0,0 @@
import { expect, test } from '@playwright/test';
import { createDoc, getOtherBrowserName, verifyDocName } from './utils-common';
import { writeInEditor } from './utils-editor';
import {
addNewMember,
connectOtherUserToDoc,
updateRoleUser,
updateShareLink,
} from './utils-share';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Comments', () => {
test('it checks comments with 2 users in real time', async ({
page,
browserName,
}) => {
const [docTitle] = await createDoc(page, 'comment-doc', browserName, 1);
// We share the doc with another user
const otherBrowserName = getOtherBrowserName(browserName);
await page.getByRole('button', { name: 'Share' }).click();
await addNewMember(page, 0, 'Administrator', otherBrowserName);
await expect(
page
.getByRole('listbox', { name: 'Suggestions' })
.getByText(new RegExp(otherBrowserName)),
).toBeVisible();
await page.getByRole('button', { name: 'close' }).click();
// We add a comment with the first user
const editor = await writeInEditor({ page, text: 'Hello World' });
await editor.getByText('Hello').selectText();
await page.getByRole('button', { name: 'Comment' }).click();
const thread = page.locator('.bn-thread');
await thread.getByRole('paragraph').first().fill('This is a comment');
await thread.locator('[data-test="save"]').click();
await expect(thread.getByText('This is a comment').first()).toBeHidden();
await editor.getByText('Hello').click();
await thread.getByText('This is a comment').first().hover();
// We add a reaction with the first user
await thread.locator('[data-test="addreaction"]').first().click();
await page.getByRole('button', { name: '👍' }).click();
await expect(
thread.getByRole('img', { name: 'E2E Chromium' }).first(),
).toBeVisible();
await expect(thread.getByText('This is a comment').first()).toBeVisible();
await expect(thread.getByText(`E2E ${browserName}`).first()).toBeVisible();
await expect(thread.locator('.bn-comment-reaction')).toHaveText('👍1');
const urlCommentDoc = page.url();
const { otherPage, cleanup } = await connectOtherUserToDoc({
otherBrowserName,
docUrl: urlCommentDoc,
docTitle,
});
const otherEditor = otherPage.locator('.ProseMirror');
await otherEditor.getByText('Hello').click();
const otherThread = otherPage.locator('.bn-thread');
await otherThread.getByText('This is a comment').first().hover();
await otherThread.locator('[data-test="addreaction"]').first().click();
await otherPage.getByRole('button', { name: '👍' }).click();
// We check that the comment made by the first user is visible for the second user
await expect(
otherThread.getByText('This is a comment').first(),
).toBeVisible();
await expect(
otherThread.getByText(`E2E ${browserName}`).first(),
).toBeVisible();
await expect(otherThread.locator('.bn-comment-reaction')).toHaveText('👍2');
// We add a comment with the second user
await otherThread
.getByRole('paragraph')
.last()
.fill('This is a comment from the other user');
await otherThread.locator('[data-test="save"]').click();
// We check that the second user can see the comment he just made
await expect(
otherThread.getByRole('img', { name: `E2E ${otherBrowserName}` }).first(),
).toBeVisible();
await expect(
otherThread.getByText('This is a comment from the other user').first(),
).toBeVisible();
await expect(
otherThread.getByText(`E2E ${otherBrowserName}`).first(),
).toBeVisible();
// We check that the first user can see the comment made by the second user in real time
await expect(
thread.getByText('This is a comment from the other user').first(),
).toBeVisible();
await expect(
thread.getByText(`E2E ${otherBrowserName}`).first(),
).toBeVisible();
await cleanup();
});
test('it checks the comments interactions', async ({ page, browserName }) => {
await createDoc(page, 'comment-interaction', browserName, 1);
// Checks add react reaction
const editor = page.locator('.ProseMirror');
await editor.locator('.bn-block-outer').last().fill('Hello World');
await editor.getByText('Hello').selectText();
await page.getByRole('button', { name: 'Comment' }).click();
const thread = page.locator('.bn-thread');
await thread.getByRole('paragraph').first().fill('This is a comment');
await thread.locator('[data-test="save"]').click();
await expect(thread.getByText('This is a comment').first()).toBeHidden();
// Check background color changed
await expect(editor.getByText('Hello')).toHaveCSS(
'background-color',
'rgba(237, 180, 0, 0.4)',
);
await editor.getByText('Hello').click();
await thread.getByText('This is a comment').first().hover();
// We add a reaction with the first user
await thread.locator('[data-test="addreaction"]').first().click();
await page.getByRole('button', { name: '👍' }).click();
await expect(thread.locator('.bn-comment-reaction')).toHaveText('👍1');
// Edit Comment
await thread.getByText('This is a comment').first().hover();
await thread.locator('[data-test="moreactions"]').first().click();
await thread.getByRole('menuitem', { name: 'Edit comment' }).click();
const commentEditor = thread.getByText('This is a comment').first();
await commentEditor.fill('This is an edited comment');
const saveBtn = thread.getByRole('button', { name: 'Save' });
await saveBtn.click();
await expect(saveBtn).toBeHidden();
await expect(
thread.getByText('This is an edited comment').first(),
).toBeVisible();
await expect(thread.getByText('This is a comment').first()).toBeHidden();
// Add second comment
await thread.getByRole('paragraph').last().fill('This is a second comment');
await thread.getByRole('button', { name: 'Save' }).click();
await expect(
thread.getByText('This is an edited comment').first(),
).toBeVisible();
await expect(
thread.getByText('This is a second comment').first(),
).toBeVisible();
// Delete second comment
await thread.getByText('This is a second comment').first().hover();
await thread.locator('[data-test="moreactions"]').first().click();
await thread.getByRole('menuitem', { name: 'Delete comment' }).click();
await expect(
thread.getByText('This is a second comment').first(),
).toBeHidden();
// Resolve thread
await thread.getByText('This is an edited comment').first().hover();
await thread.locator('[data-test="resolve"]').click();
await expect(thread).toBeHidden();
await expect(editor.getByText('Hello')).toHaveCSS(
'background-color',
'rgba(0, 0, 0, 0)',
);
});
test('it checks the comments abilities', async ({ page, browserName }) => {
test.slow();
const [docTitle] = await createDoc(page, 'comment-doc', browserName, 1);
// We share the doc with another user
const otherBrowserName = getOtherBrowserName(browserName);
// Add a new member with editor role
await page.getByRole('button', { name: 'Share' }).click();
await addNewMember(page, 0, 'Editor', otherBrowserName);
await expect(
page
.getByRole('listbox', { name: 'Suggestions' })
.getByText(new RegExp(otherBrowserName)),
).toBeVisible();
const urlCommentDoc = page.url();
const { otherPage, cleanup } = await connectOtherUserToDoc({
otherBrowserName,
docUrl: urlCommentDoc,
docTitle,
});
const otherEditor = await writeInEditor({
page: otherPage,
text: 'Hello, I can edit the document',
});
await expect(
otherEditor.getByText('Hello, I can edit the document'),
).toBeVisible();
await otherEditor.getByText('Hello').selectText();
await otherPage.getByRole('button', { name: 'Comment' }).click();
const otherThread = otherPage.locator('.bn-thread');
await otherThread
.getByRole('paragraph')
.first()
.fill('I can add a comment');
await otherThread.locator('[data-test="save"]').click();
await expect(
otherThread.getByText('I can add a comment').first(),
).toBeHidden();
await expect(otherEditor.getByText('Hello')).toHaveCSS(
'background-color',
'rgba(237, 180, 0, 0.4)',
);
// We change the role of the second user to reader
await updateRoleUser(page, 'Reader', `user.test@${otherBrowserName}.test`);
// With the reader role, the second user cannot see comments
await otherPage.reload();
await verifyDocName(otherPage, docTitle);
await expect(otherEditor.getByText('Hello')).toHaveCSS(
'background-color',
'rgba(0, 0, 0, 0)',
);
await otherEditor.getByText('Hello').click();
await expect(otherThread).toBeHidden();
await otherEditor.getByText('Hello').selectText();
await expect(
otherPage.getByRole('button', { name: 'Comment' }),
).toBeHidden();
await otherPage.reload();
// Change the link role of the doc to set it in commenting mode
await updateShareLink(page, 'Public', 'Editing');
// Anonymous user can see and add comments
await otherPage.getByRole('button', { name: 'Logout' }).click();
await otherPage.goto(urlCommentDoc);
await verifyDocName(otherPage, docTitle);
await expect(otherEditor.getByText('Hello')).toHaveCSS(
'background-color',
'rgba(237, 180, 0, 0.4)',
);
await otherEditor.getByText('Hello').click();
await expect(
otherThread.getByText('I can add a comment').first(),
).toBeVisible();
await otherThread
.locator('.ProseMirror.bn-editor[contenteditable="true"]')
.getByRole('paragraph')
.first()
.fill('Comment by anonymous user');
await otherThread.locator('[data-test="save"]').click();
await expect(
otherThread.getByText('Comment by anonymous user').first(),
).toBeVisible();
await expect(
otherThread.getByRole('img', { name: `Anonymous` }).first(),
).toBeVisible();
await otherThread.getByText('Comment by anonymous user').first().hover();
await expect(otherThread.locator('[data-test="moreactions"]')).toBeHidden();
await cleanup();
});
});
@@ -24,13 +24,15 @@ test.beforeEach(async ({ page }) => {
});
test.describe('Doc Editor', () => {
test('it checks toolbar buttons are displayed', async ({
test('it checks default toolbar buttons are displayed', async ({
page,
browserName,
}) => {
await createDoc(page, 'doc-toolbar', browserName, 1);
const editor = await writeInEditor({ page, text: 'test content' });
const editor = page.locator('.ProseMirror');
await editor.click();
await editor.fill('test content');
await editor
.getByText('test content', {
@@ -39,9 +41,6 @@ test.describe('Doc Editor', () => {
.selectText();
const toolbar = page.locator('.bn-formatting-toolbar');
await expect(
toolbar.locator('button[data-test="comment-toolbar-button"]'),
).toBeVisible();
await expect(toolbar.locator('button[data-test="bold"]')).toBeVisible();
await expect(toolbar.locator('button[data-test="italic"]')).toBeVisible();
await expect(
@@ -64,53 +63,6 @@ test.describe('Doc Editor', () => {
await expect(
toolbar.locator('button[data-test="createLink"]'),
).toBeVisible();
await expect(
toolbar.locator('button[data-test="ai-actions"]'),
).toBeVisible();
await expect(
toolbar.locator('button[data-test="convertMarkdown"]'),
).toBeVisible();
await page.keyboard.press('Escape');
await page.locator('.bn-block-outer').last().click();
await page.keyboard.press('Enter');
const fileChooserPromise = page.waitForEvent('filechooser');
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Resizable image with caption').click();
await page.getByText('Upload image').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(
path.join(__dirname, 'assets/logo-suite-numerique.png'),
);
const image = page
.locator('.--docs--editor-container img.bn-visual-media')
.first();
await expect(image).toHaveAttribute('role', 'presentation');
await image.dblclick();
await expect(
toolbar.locator('button[data-test="comment-toolbar-button"]'),
).toBeHidden();
await expect(
toolbar.locator('button[data-test="ai-actions"]'),
).toBeHidden();
await expect(
toolbar.locator('button[data-test="convertMarkdown"]'),
).toBeHidden();
await expect(
toolbar.locator('button[data-test="editcaption"]'),
).toBeVisible();
await expect(
toolbar.locator('button[data-test="downloadfile"]'),
).toBeVisible();
});
/**
@@ -289,66 +241,20 @@ test.describe('Doc Editor', () => {
await expect(editor.getByText('Hello World Doc persisted 2')).toBeVisible();
});
test('it cannot edit if viewer but see and can get resources', async ({
page,
browserName,
}) => {
const [docTitle] = await createDoc(page, 'doc-viewer', browserName, 1);
await verifyDocName(page, docTitle);
await writeInEditor({ page, text: 'Hello World' });
await page.getByRole('button', { name: 'Share' }).click();
await updateShareLink(page, 'Public', 'Reading');
// Close the modal
await page.getByRole('button', { name: 'close' }).first().click();
const { otherPage, cleanup } = await connectOtherUserToDoc({
browserName,
docUrl: page.url(),
withoutSignIn: true,
docTitle,
test('it cannot edit if viewer', async ({ page }) => {
await mockedDocument(page, {
user_role: 'reader',
});
await expect(
otherPage.getByLabel('It is the card information').getByText('Reader'),
).toBeVisible();
await goToGridDoc(page);
// Cannot edit
const editor = otherPage.locator('.ProseMirror');
const card = page.getByLabel('It is the card information');
await expect(card).toBeVisible();
await expect(card.getByText('Reader')).toBeVisible();
const editor = page.locator('.ProseMirror');
await expect(editor).toHaveAttribute('contenteditable', 'false');
// Owner add a image
const fileChooserPromise = page.waitForEvent('filechooser');
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Resizable image with caption').click();
await page.getByText('Upload image').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(
path.join(__dirname, 'assets/logo-suite-numerique.png'),
);
// Owner see the image
await expect(
page.locator('.--docs--editor-container img.bn-visual-media').first(),
).toBeVisible();
// Viewser see the image
const viewerImg = otherPage
.locator('.--docs--editor-container img.bn-visual-media')
.first();
await expect(viewerImg).toBeVisible();
// Viewer can download the image
await viewerImg.click();
const downloadPromise = otherPage.waitForEvent('download');
await otherPage.getByRole('button', { name: 'Download image' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('logo-suite-numerique.png');
await cleanup();
});
test('it adds an image to the doc editor', async ({ page, browserName }) => {
@@ -588,7 +494,7 @@ test.describe('Doc Editor', () => {
if (request.method().includes('GET')) {
await route.fulfill({
json: {
status: requestCount > 1 ? 'ready' : 'processing',
status: requestCount ? 'ready' : 'processing',
file: '/anything.html',
},
});
@@ -612,12 +518,6 @@ test.describe('Doc Editor', () => {
await fileChooser.setFiles(path.join(__dirname, 'assets/test.html'));
await expect(editor.getByText('Analyzing file...')).toBeVisible();
// To be sure the retry happens even after a page reload
await page.reload();
await expect(editor.getByText('Analyzing file...')).toBeVisible();
// The retry takes a few seconds
await expect(editor.getByText('test.html')).toBeVisible({
timeout: 7000,
@@ -900,12 +800,6 @@ test.describe('Doc Editor', () => {
});
await expect(interlinkChild1).toBeVisible({ timeout: 10000 });
await expect(interlinkChild1.locator('svg').first()).toBeVisible();
await page.keyboard.press('@');
await page.keyboard.press('Escape');
await expect(editor.getByText('@')).toBeVisible();
});
test('it checks multiple big doc scroll to the top', async ({
@@ -1,48 +1,19 @@
import fs from 'fs';
import path from 'path';
import { expect, test } from '@playwright/test';
import cs from 'convert-stream';
import { PDFParse } from 'pdf-parse';
import { pdf } from 'pdf-parse';
import {
TestLanguage,
createDoc,
randomName,
verifyDocName,
waitForLanguageSwitch,
} from './utils-common';
import { openSuggestionMenu, writeInEditor } from './utils-editor';
import { createRootSubPage } from './utils-sub-pages';
const REGRESSION_FIXTURE_CONTENT = fs.readFileSync(
path.join(__dirname, 'assets/content.txt'),
'utf-8',
);
const REGRESSION_SNAPSHOT_NAME = 'doc-export-regression.pdf';
const REGRESSION_DOC_TITLE = 'doc-export-regression-reference';
/**
* Playwright snapshots store the raw PDF bytes. However, each export embeds
* dynamic metadata (timestamps, font-subset identifiers, etc.) that would make
* the snapshot differ at every run. To ensure deterministic comparisons we
* strip/neutralize those fields before matching against the reference PDF.
*/
const sanitizePdfBuffer = (buffer: Buffer) => {
const pdfText = buffer.toString('latin1');
const neutralized = pdfText
// Remove per-export timestamps
.replace(/\/CreationDate\s*\(.*?\)/g, '/CreationDate ()')
.replace(/\/ModDate\s*\(.*?\)/g, '/ModDate ()')
// Remove file identifiers
.replace(/\/ID\s*\[<[^>]+>\s*<[^>]+>\]/g, '/ID [<0><0>]')
.replace(/D:\d{14}Z/g, 'D:00000000000000Z')
// Remove subset font prefixes generated by PDF renderer
.replace(/\b[A-Z]{6}\+(Inter18pt-[A-Za-z]+)\b/g, 'STATIC+$1')
.replace(/\b[A-Z]{6}\+(GeistMono-[A-Za-z]+)\b/g, 'STATIC+$1');
return Buffer.from(neutralized, 'latin1');
};
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
@@ -61,7 +32,7 @@ test.describe('Doc Export', () => {
await expect(page.getByTestId('modal-export-title')).toBeVisible();
await expect(
page.getByText('Download your document in a .docx, .odt or .pdf format.'),
page.getByText('Download your document in a .docx or .pdf format.'),
).toBeVisible();
await expect(
page.getByRole('combobox', { name: 'Template' }),
@@ -115,16 +86,11 @@ test.describe('Doc Export', () => {
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfParse = new PDFParse({ data: pdfBuffer });
const pdfInfo = await pdfParse.getInfo();
const pdfText = await pdfParse.getText();
const pdfData = await pdf(pdfBuffer);
expect(pdfInfo.total).toBe(2);
expect(pdfText.pages).toStrictEqual([
{ text: 'Hello', num: 1 },
{ text: 'World', num: 2 },
]);
expect(pdfInfo?.info.Title).toBe(randomDoc);
expect(pdfData.total).toBe(2);
expect(pdfData.text).toContain('Hello\n\nWorld\n\n'); // This is the doc text
expect(pdfData.info?.Title).toBe(randomDoc);
});
test('it exports the doc to docx', async ({ page, browserName }) => {
@@ -172,51 +138,6 @@ test.describe('Doc Export', () => {
expect(download.suggestedFilename()).toBe(`${randomDoc}.docx`);
});
test('it exports the doc to odt', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-editor-odt', browserName, 1);
await verifyDocName(page, randomDoc);
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World ODT');
await page.keyboard.press('Enter');
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Resizable image with caption').click();
const fileChooserPromise = page.waitForEvent('filechooser');
await page.getByText('Upload image').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(path.join(__dirname, 'assets/test.svg'));
const image = page
.locator('.--docs--editor-container img.bn-visual-media')
.first();
await expect(image).toBeVisible();
await page
.getByRole('button', {
name: 'Export the document',
})
.click();
await page.getByRole('combobox', { name: 'Format' }).click();
await page.getByRole('option', { name: 'Odt' }).click();
await expect(page.getByTestId('doc-export-download-button')).toBeVisible();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.odt`);
});
void page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDoc}.odt`);
});
/**
* This test tell us that the export to pdf is working with images
* but it does not tell us if the images are being displayed correctly
@@ -298,10 +219,10 @@ test.describe('Doc Export', () => {
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfExport = await pdf(pdfBuffer);
const pdfText = pdfExport.text;
const pdfParse = new PDFParse({ data: pdfBuffer });
const pdfText = await pdfParse.getText();
expect(pdfText.text).toContain('Hello World');
expect(pdfText).toContain('Hello World');
});
test('it exports the doc with quotes', async ({ page, browserName }) => {
@@ -344,9 +265,9 @@ test.describe('Doc Export', () => {
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfParse = new PDFParse({ data: pdfBuffer });
const pdfText = await pdfParse.getText();
expect(pdfText.text).toContain('Hello World');
const pdfData = await pdf(pdfBuffer);
expect(pdfData.text).toContain('Hello World'); // This is the pdf text
});
test('it exports the doc with multi columns', async ({
@@ -399,33 +320,46 @@ test.describe('Doc Export', () => {
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfParse = new PDFParse({ data: pdfBuffer });
const pdfText = await pdfParse.getText();
expect(pdfText.text).toContain('Column 1');
expect(pdfText.text).toContain('Column 2');
expect(pdfText.text).toContain('Column 3');
const pdfData = await pdf(pdfBuffer);
expect(pdfData.text).toContain('Column 1');
expect(pdfData.text).toContain('Column 2');
expect(pdfData.text).toContain('Column 3');
});
test('it injects the correct language attribute into PDF export', async ({
page,
browserName,
}) => {
const [randomDocFrench] = await createDoc(
page,
'doc-language-export-french',
browserName,
1,
);
await waitForLanguageSwitch(page, TestLanguage.French);
// Wait for the page to be ready after language switch
await page.waitForLoadState('domcontentloaded');
await writeInEditor({
page,
text: 'Contenu de test pour export en français',
});
const header = page.locator('header').first();
await header.locator('h1').getByText('Docs').click();
const randomDocFrench = randomName(
'doc-language-export-french',
browserName,
1,
)[0];
await page
.getByRole('button', {
name: 'Nouveau doc',
})
.click();
const input = page.getByRole('textbox', { name: 'Titre du document' });
await expect(input).toBeVisible();
await expect(input).toHaveText('', { timeout: 10000 });
await input.click();
await input.fill(randomDocFrench);
await input.blur();
const editor = page.locator('.ProseMirror.bn-editor');
await editor.click();
await editor.fill('Contenu de test pour export en français');
await page
.getByRole('button', {
@@ -513,186 +447,8 @@ test.describe('Doc Export', () => {
expect(download.suggestedFilename()).toBe(`${docChild}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfParse = new PDFParse({ data: pdfBuffer });
const pdfText = await pdfParse.getText();
expect(pdfText.text).toContain(randomDoc);
});
const pdfData = await pdf(pdfBuffer);
test('it exports the doc with interlinking to odt', async ({
page,
browserName,
}) => {
const [randomDoc] = await createDoc(
page,
'export-interlinking-odt',
browserName,
1,
);
await verifyDocName(page, randomDoc);
const { name: docChild } = await createRootSubPage(
page,
browserName,
'export-interlink-child-odt',
);
await verifyDocName(page, docChild);
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Link a doc').first().click();
const input = page.locator(
"span[data-inline-content-type='interlinkingSearchInline'] input",
);
const searchContainer = page.locator('.quick-search-container');
await input.fill('export-interlink');
await expect(searchContainer).toBeVisible();
await expect(searchContainer.getByText(randomDoc)).toBeVisible();
// We are in docChild, we want to create a link to randomDoc (parent)
await searchContainer.getByText(randomDoc).click();
// Search the interlinking link in the editor (not in the document tree)
const editor = page.locator('.ProseMirror.bn-editor');
const interlink = editor.getByRole('button', {
name: randomDoc,
});
await expect(interlink).toBeVisible();
await page
.getByRole('button', {
name: 'Export the document',
})
.click();
await page.getByRole('combobox', { name: 'Format' }).click();
await page.getByRole('option', { name: 'Odt' }).click();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${docChild}.odt`);
});
void page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${docChild}.odt`);
});
/**
* Regression guard for the full PDF export pipeline.
*
* Usage reminder:
* 1. `npx playwright test __tests__/app-impress/doc-export.spec.ts --update-snapshots -g "full document" --project=chromium`
* -> refresh the reference PDF whenever we intentionally change the export output.
* 2. `npx playwright test __tests__/app-impress/doc-export.spec.ts -g "full document" --project=chromium`
* -> CI (and local runs without --update-snapshots) will compare the PDF to the reference
* and fail on any byte-level difference once the dynamic metadata has been sanitized.
*/
test('it keeps the full document PDF export identical to the reference snapshot', async ({
page,
browserName,
}, testInfo) => {
// PDF generation for a large, image-heavy document can be slow in CI.
// Give this regression test a higher timeout budget than the default.
testInfo.setTimeout(120000);
const snapshotPath = testInfo.snapshotPath(REGRESSION_SNAPSHOT_NAME);
test.skip(
!fs.existsSync(snapshotPath) &&
testInfo.config.updateSnapshots === 'none',
`Missing PDF snapshot at ${snapshotPath}. Run Playwright with --update-snapshots to record it.`,
);
// We must use a deterministic title so that block content (and thus the
// exported PDF) stays identical between runs.
await createDoc(page, 'doc-export-regression', browserName, 1);
const titleInput = page.getByRole('textbox', { name: 'Document title' });
await expect(titleInput).toBeVisible();
await titleInput.fill(REGRESSION_DOC_TITLE);
await titleInput.blur();
await verifyDocName(page, REGRESSION_DOC_TITLE);
const regressionDoc = REGRESSION_DOC_TITLE;
const docId = page
.url()
.split('/docs/')[1]
?.split('/')
.filter(Boolean)
.shift();
expect(docId).toBeTruthy();
// Inject the pre-crafted blocknote document via the REST API to avoid
// rebuilding it through the UI (which would be slow and flaky).
const cookies = await page.context().cookies();
const csrfToken = cookies.find(
(cookie) => cookie.name === 'csrftoken',
)?.value;
const headers: Record<string, string> = {
'content-type': 'application/json',
};
if (csrfToken) {
headers['X-CSRFToken'] = csrfToken;
}
const updateResponse = await page.request.patch(
`http://localhost:8071/api/v1.0/documents/${docId}/`,
{
headers,
data: {
content: REGRESSION_FIXTURE_CONTENT,
websocket: true,
},
},
);
if (!updateResponse.ok()) {
throw new Error(
`Failed to seed document content. Status: ${updateResponse.status()}, body: ${await updateResponse.text()}`,
);
}
await page.reload();
// After reloading, just ensure the editor container is present before exporting.
await expect(page.locator('.--docs--editor-container')).toBeVisible({
timeout: 15000,
});
await page
.getByRole('button', {
name: 'Export the document',
})
.click();
await expect(
page.getByTestId('doc-open-modal-download-button'),
).toBeVisible();
await expect(page.getByTestId('doc-export-download-button')).toBeEnabled({
timeout: 15000,
});
// Export to PDF and confirm the generated bytes match the reference file.
const downloadPromise = page.waitForEvent('download', {
timeout: 60000,
predicate: (download) =>
download.suggestedFilename().includes(`${regressionDoc}.pdf`),
});
void page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${regressionDoc}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const normalizedPdfBuffer = sanitizePdfBuffer(pdfBuffer);
expect(normalizedPdfBuffer).toMatchSnapshot(REGRESSION_SNAPSHOT_NAME);
expect(pdfData.text).toContain(randomDoc);
});
});
@@ -1,7 +1,6 @@
import { expect, test } from '@playwright/test';
import { createDoc, getGridRow, verifyDocName } from './utils-common';
import { addNewMember, connectOtherUserToDoc } from './utils-share';
import { createDoc, getGridRow } from './utils-common';
type SmallDoc = {
id: string;
@@ -12,7 +11,7 @@ test.describe('Documents Grid mobile', () => {
test.use({ viewport: { width: 500, height: 1200 } });
test('it checks the grid when mobile', async ({ page }) => {
await page.route(/.*\/documents\/.*/, async (route) => {
await page.route('**/documents/**', async (route) => {
const request = route.request();
if (request.method().includes('GET') && request.url().includes('page=')) {
await route.fulfill({
@@ -134,8 +133,6 @@ test.describe('Document grid item options', () => {
test('it deletes the document', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, `delete doc`, browserName);
await verifyDocName(page, docTitle);
await page.goto('/');
await expect(page.getByText(docTitle)).toBeVisible();
@@ -164,7 +161,7 @@ test.describe('Document grid item options', () => {
test("it checks if the delete option is disabled if we don't have the destroy capability", async ({
page,
}) => {
await page.route(/.*\/api\/v1.0\/documents\/\?page=1/, async (route) => {
await page.route('*/**/api/v1.0/documents/?page=1', async (route) => {
await route.fulfill({
json: {
results: [
@@ -195,68 +192,90 @@ test.describe('Document grid item options', () => {
});
await page.goto('/');
const button = page
.getByTestId(`docs-grid-actions-button-mocked-document-id`)
.first();
const button = page.getByTestId(
`docs-grid-actions-button-mocked-document-id`,
);
await expect(button).toBeVisible();
await button.click();
const removeButton = page
.getByTestId(`docs-grid-actions-remove-mocked-document-id`)
.first();
const removeButton = page.getByTestId(
`docs-grid-actions-remove-mocked-document-id`,
);
await expect(removeButton).toBeVisible();
await removeButton.isDisabled();
});
});
test.describe('Documents filters', () => {
test('it checks the left panel filters', async ({ page, browserName }) => {
test('it checks the prebuild left panel filters', async ({ page }) => {
void page.goto('/');
// Create my doc
const [docName] = await createDoc(page, 'my-doc', browserName, 1);
await verifyDocName(page, docName);
// Another user create a doc and share it with me
const { cleanup, otherPage, otherBrowserName } =
await connectOtherUserToDoc({
browserName,
docUrl: '/',
});
const [docShareName] = await createDoc(
otherPage,
'my-share-doc',
otherBrowserName,
1,
);
await verifyDocName(otherPage, docShareName);
await otherPage.getByRole('button', { name: 'Share' }).click();
await addNewMember(otherPage, 0, 'Editor', browserName);
// Let's check the filters
await page.getByRole('button', { name: 'Back to homepage' }).click();
const row = await getGridRow(page, docName);
const rowShare = await getGridRow(page, docShareName);
// All Docs
await expect(row).toBeVisible();
await expect(rowShare).toBeVisible();
const response = await page.waitForResponse(
(response) =>
response.url().endsWith('documents/?page=1') &&
response.status() === 200,
);
const result = await response.json();
const allCount = result.count as number;
await expect(page.getByTestId('grid-loader')).toBeHidden();
// My Docs
await page.getByRole('link', { name: 'My docs' }).click();
await expect(row).toBeVisible();
await expect(rowShare).toBeHidden();
const allDocs = page.getByLabel('All docs');
const myDocs = page.getByLabel('My docs');
const sharedWithMe = page.getByLabel('Shared with me');
// Initial state
await expect(allDocs).toBeVisible();
await expect(allDocs).toHaveAttribute('aria-current', 'page');
await expect(myDocs).toBeVisible();
await expect(myDocs).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
await expect(myDocs).not.toHaveAttribute('aria-current');
await expect(sharedWithMe).toBeVisible();
await expect(sharedWithMe).toHaveCSS(
'background-color',
'rgba(0, 0, 0, 0)',
);
await expect(sharedWithMe).not.toHaveAttribute('aria-current');
await allDocs.click();
await page.waitForURL('**/?target=all_docs');
let url = new URL(page.url());
let target = url.searchParams.get('target');
expect(target).toBe('all_docs');
// My docs
await myDocs.click();
url = new URL(page.url());
target = url.searchParams.get('target');
expect(target).toBe('my_docs');
const responseMyDocs = await page.waitForResponse(
(response) =>
response.url().endsWith('documents/?page=1&is_creator_me=true') &&
response.status() === 200,
);
const resultMyDocs = await responseMyDocs.json();
const countMyDocs = resultMyDocs.count as number;
await expect(page.getByTestId('grid-loader')).toBeHidden();
expect(countMyDocs).toBeLessThanOrEqual(allCount);
// Shared with me
await page.getByRole('link', { name: 'Shared with me' }).click();
await expect(row).toBeHidden();
await expect(rowShare).toBeVisible();
await cleanup();
await sharedWithMe.click();
url = new URL(page.url());
target = url.searchParams.get('target');
expect(target).toBe('shared_with_me');
const responseSharedWithMe = await page.waitForResponse(
(response) =>
response.url().includes('documents/?page=1&is_creator_me=false') &&
response.status() === 200,
);
const resultSharedWithMe = await responseSharedWithMe.json();
const countSharedWithMe = resultSharedWithMe.count as number;
await expect(page.getByTestId('grid-loader')).toBeHidden();
expect(countSharedWithMe).toBeLessThanOrEqual(allCount);
expect(countSharedWithMe + countMyDocs).toEqual(allCount);
});
});
@@ -68,18 +68,9 @@ test.describe('Doc Header', () => {
await createDoc(page, 'doc-update-emoji', browserName, 1);
const emojiPicker = page.locator('.--docs--doc-title').getByRole('button');
const optionMenu = page.getByLabel('Open the document options');
const addEmojiMenuItem = page.getByRole('menuitem', { name: 'Add emoji' });
const removeEmojiMenuItem = page.getByRole('menuitem', {
name: 'Remove emoji',
});
// Top parent should not have emoji picker
await expect(emojiPicker).toBeHidden();
await optionMenu.click();
await expect(addEmojiMenuItem).toBeHidden();
await expect(removeEmojiMenuItem).toBeHidden();
await page.keyboard.press('Escape');
const { name: docChild } = await createRootSubPage(
page,
@@ -89,23 +80,13 @@ test.describe('Doc Header', () => {
await verifyDocName(page, docChild);
// Emoji picker should be hidden initially
await expect(emojiPicker).toBeHidden();
// Add emoji
await optionMenu.click();
await expect(removeEmojiMenuItem).toBeHidden();
await addEmojiMenuItem.click();
await expect(emojiPicker).toHaveText('📄');
// Change emoji
await expect(emojiPicker).toBeVisible();
await emojiPicker.click({
delay: 100,
});
await page.getByRole('button', { name: '😀' }).first().click();
await expect(emojiPicker).toHaveText('😀');
// Update title
const docTitle = page.getByRole('textbox', { name: 'Document title' });
await docTitle.fill('Hello Emoji World');
await docTitle.blur();
@@ -114,12 +95,6 @@ test.describe('Doc Header', () => {
// Check the tree
const row = await getTreeRow(page, 'Hello Emoji World');
await expect(row.getByText('😀')).toBeVisible();
// Remove emoji
await optionMenu.click();
await expect(addEmojiMenuItem).toBeHidden();
await removeEmojiMenuItem.click();
await expect(emojiPicker).toBeHidden();
});
test('it deletes the doc', async ({ page, browserName }) => {
@@ -208,7 +183,7 @@ test.describe('Doc Header', () => {
await expect(
invitationCard.getByText('test.test@invitation.test').first(),
).toBeVisible();
const invitationRole = invitationCard.getByTestId('doc-role-dropdown');
const invitationRole = invitationCard.getByLabel('doc-role-dropdown');
await expect(invitationRole).toBeVisible();
await invitationRole.click();
@@ -217,7 +192,7 @@ test.describe('Doc Header', () => {
await expect(invitationCard).toBeHidden();
const memberCard = shareModal.getByLabel('List members card');
const roles = memberCard.getByTestId('doc-role-dropdown');
const roles = memberCard.getByLabel('doc-role-dropdown');
await expect(memberCard).toBeVisible();
await expect(
memberCard.getByText('test.test@accesses.test').first(),
@@ -74,7 +74,7 @@ test.describe('Document create member', () => {
await expect(list.getByText(email)).toBeVisible();
// Check roles are displayed
await list.getByTestId('doc-role-dropdown').click();
await list.getByLabel('doc-role-dropdown').click();
await expect(page.getByRole('menuitem', { name: 'Reader' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Editor' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Owner' })).toBeVisible();
@@ -84,7 +84,7 @@ test.describe('Document create member', () => {
// Validate
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await page.getByTestId('doc-share-invite-button').click();
await page.getByRole('button', { name: 'Invite' }).click();
// Check invitation added
await expect(
@@ -128,14 +128,14 @@ test.describe('Document create member', () => {
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByTestId('doc-role-dropdown').click();
await container.getByLabel('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Owner' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
response.url().includes('/invitations/') && response.status() === 201,
);
await page.getByTestId('doc-share-invite-button').click();
await page.getByRole('button', { name: 'Invite' }).click();
// Check invitation sent
@@ -146,7 +146,7 @@ test.describe('Document create member', () => {
await page.getByTestId(`search-user-row-${email}`).click();
// Choose a role
await container.getByTestId('doc-role-dropdown').click();
await container.getByLabel('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Owner' }).click();
const responsePromiseCreateInvitationFail = page.waitForResponse(
@@ -154,7 +154,7 @@ test.describe('Document create member', () => {
response.url().includes('/invitations/') && response.status() === 400,
);
await page.getByTestId('doc-share-invite-button').click();
await page.getByRole('button', { name: 'Invite' }).click();
await expect(
page.getByText(`"${email}" is already invited to the document.`),
).toBeVisible();
@@ -183,7 +183,7 @@ test.describe('Document create member', () => {
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByTestId('doc-role-dropdown').click();
await container.getByLabel('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
@@ -191,7 +191,7 @@ test.describe('Document create member', () => {
response.url().includes('/invitations/') && response.status() === 201,
);
await page.getByTestId('doc-share-invite-button').click();
await page.getByRole('button', { name: 'Invite' }).click();
// Check invitation sent
const responseCreateInvitation = await responsePromiseCreateInvitation;
@@ -210,14 +210,18 @@ test.describe('Document create member', () => {
response.request().method() === 'PATCH',
);
await userInvitation.getByTestId('doc-role-dropdown').click();
await userInvitation.getByLabel('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Reader' }).click();
const responsePatchInvitation = await responsePromisePatchInvitation;
expect(responsePatchInvitation.ok()).toBeTruthy();
await userInvitation.getByTestId('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Remove access' }).click();
const moreActions = userInvitation.getByRole('button', {
name: 'Open invitation actions menu',
});
await moreActions.click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await expect(userInvitation).toBeHidden();
});
@@ -268,7 +272,7 @@ test.describe('Document create member', () => {
const container = page.getByTestId(
`doc-share-access-request-row-${emailRequest}`,
);
await container.getByTestId('doc-role-dropdown').click();
await container.getByLabel('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await container.getByRole('button', { name: 'Approve' }).click();
@@ -31,7 +31,7 @@ test.describe('Document list members', () => {
return cleanUrl.split('/').pop() || '';
})();
await page.route(/.*\/documents\/.*\/accesses\//, async (route) => {
await page.route('**/documents/**/accesses/', async (route) => {
const request = route.request();
const url = new URL(request.url());
const pageId = url.searchParams.get('page') ?? '1';
@@ -152,7 +152,7 @@ test.describe('Document list members', () => {
const currentUser = list.getByTestId(
`doc-share-member-row-user.test@${browserName}.test`,
);
const currentUserRole = currentUser.getByTestId('doc-role-dropdown');
const currentUserRole = currentUser.getByLabel('doc-role-dropdown');
await expect(currentUser).toBeVisible();
await expect(currentUserRole).toBeVisible();
await currentUserRole.click();
@@ -160,16 +160,13 @@ test.describe('Document list members', () => {
`You are the sole owner of this group, make another member the group owner before you can change your own role or be removed from your document.`,
);
await expect(soloOwner).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Administrator' }),
).toBeDisabled();
await list.click({
force: true, // Force click to close the dropdown
});
const newUserEmail = await addNewMember(page, 0, 'Owner');
const newUser = list.getByTestId(`doc-share-member-row-${newUserEmail}`);
const newUserRoles = newUser.getByTestId('doc-role-dropdown');
const newUserRoles = newUser.getByLabel('doc-role-dropdown');
await expect(newUser).toBeVisible();
@@ -189,17 +186,9 @@ test.describe('Document list members', () => {
await list.click();
await expect(currentUserRole).toBeVisible();
await newUserRoles.click();
await expect(page.getByRole('menuitem', { name: 'Owner' })).toBeDisabled();
await list.click({
force: true, // Force click to close the dropdown
});
await currentUserRole.click();
await page.getByRole('menuitem', { name: 'Reader' }).click();
await list.click({
force: true, // Force click to close the dropdown
});
await list.click();
await expect(currentUserRole).toBeHidden();
});
@@ -214,7 +203,9 @@ test.describe('Document list members', () => {
const emailMyself = `user.test@${browserName}.test`;
const mySelf = list.getByTestId(`doc-share-member-row-${emailMyself}`);
const mySelfRole = mySelf.getByTestId('doc-role-dropdown');
const mySelfRole = mySelf.getByRole('button', {
name: 'doc-role-dropdown',
});
const userOwnerEmail = await addNewMember(page, 0, 'Owner');
const userOwner = list.getByTestId(
@@ -229,7 +220,9 @@ test.describe('Document list members', () => {
const userReader = list.getByTestId(
`doc-share-member-row-${userReaderEmail}`,
);
const userReaderRole = userReader.getByTestId('doc-role-dropdown');
const userReaderRole = userReader.getByRole('button', {
name: 'doc-role-dropdown',
});
await expect(mySelf).toBeVisible();
await expect(userOwner).toBeVisible();
@@ -119,10 +119,6 @@ test.describe('Doc Trashbin', () => {
await row.getByText(subDocName).click();
await verifyDocName(page, subDocName);
await expect(
page.locator('.--docs--editor-container.--docs--doc-deleted'),
).toBeVisible();
await expect(page.getByLabel('Alert deleted document')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeDisabled();
await expect(page.locator('.bn-editor')).toHaveAttribute(
@@ -226,7 +226,7 @@ test.describe('Doc Tree', () => {
const currentUser = list.getByTestId(
`doc-share-member-row-user.test@${browserName}.test`,
);
const currentUserRole = currentUser.getByTestId('doc-role-dropdown');
const currentUserRole = currentUser.getByLabel('doc-role-dropdown');
await currentUserRole.click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await list.click();
@@ -352,7 +352,7 @@ test.describe('Doc Tree', () => {
await page.getByRole('menuitem', { name: 'Remove emoji' }).click();
await expect(row.getByText('😀')).toBeHidden();
await expect(titleEmojiPicker).toBeHidden();
await expect(titleEmojiPicker).not.toHaveText('😀');
});
});
@@ -7,7 +7,7 @@ import {
keyCloakSignIn,
verifyDocName,
} from './utils-common';
import { getEditor, writeInEditor } from './utils-editor';
import { writeInEditor } from './utils-editor';
import { addNewMember, connectOtherUserToDoc } from './utils-share';
import { createRootSubPage } from './utils-sub-pages';
@@ -182,14 +182,15 @@ test.describe('Doc Visibility: Restricted', () => {
});
test.describe('Doc Visibility: Public', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.use({ storageState: { cookies: [], origins: [] } });
test('It checks a public doc in read only mode', async ({
page,
browserName,
}) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docTitle] = await createDoc(
page,
'Public read only',
@@ -199,8 +200,6 @@ test.describe('Doc Visibility: Public', () => {
await verifyDocName(page, docTitle);
await writeInEditor({ page, text: 'Hello Public Viewonly' });
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
@@ -242,67 +241,49 @@ test.describe('Doc Visibility: Public', () => {
await expect(page.getByTestId('search-docs-button')).toBeVisible();
await expect(page.getByTestId('new-doc-button')).toBeVisible();
const docUrl = page.url();
const urlDoc = page.url();
const { otherPage, cleanup } = await connectOtherUserToDoc({
browserName,
docUrl,
withoutSignIn: true,
});
await page
.getByRole('button', {
name: 'Logout',
})
.click();
await expect(otherPage.locator('h2').getByText(docTitle)).toBeVisible();
await expect(otherPage.getByTestId('search-docs-button')).toBeHidden();
await expect(otherPage.getByTestId('new-doc-button')).toBeHidden();
await expect(
otherPage.getByRole('button', { name: 'Share' }),
).toBeVisible();
const card = otherPage.getByLabel('It is the card information');
await expectLoginPage(page);
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await expect(page.getByTestId('search-docs-button')).toBeHidden();
await expect(page.getByTestId('new-doc-button')).toBeHidden();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
const card = page.getByLabel('It is the card information');
await expect(card).toBeVisible();
await expect(card.getByText('Reader')).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await expect(
otherPage.locator('.--docs--editor-container.--docs--doc-readonly'),
).toBeVisible();
const otherEditor = await getEditor({ page: otherPage });
await expect(otherEditor).toHaveAttribute('contenteditable', 'false');
await expect(otherEditor.getByText('Hello Public Viewonly')).toBeVisible();
// Cursor and selection of the anonymous user are not visible
await otherEditor.getByText('Hello Public').selectText();
await expect(
page.locator('.collaboration-cursor-custom__base'),
).toBeHidden();
await expect(page.locator('.ProseMirror-yjs-selection')).toBeHidden();
// Can still see changes made by others
await writeInEditor({ page, text: 'Can you see it ?' });
await expect(otherEditor.getByText('Can you see it ?')).toBeVisible();
await otherPage.getByRole('button', { name: 'Share' }).click();
await expect(
otherPage.getByText(
page.getByText(
'You can view this document but need additional access to see its members or modify settings.',
),
).toBeVisible();
await expect(
otherPage.getByRole('button', { name: 'Request access' }),
page.getByRole('button', { name: 'Request access' }),
).toBeHidden();
await cleanup();
});
test('It checks a public doc in editable mode', async ({
page,
browserName,
}) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docTitle] = await createDoc(page, 'Public editable', browserName, 1);
await verifyDocName(page, docTitle);
await writeInEditor({ page, text: 'Hello Public Editable' });
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
@@ -336,47 +317,20 @@ test.describe('Doc Visibility: Public', () => {
cardContainer.getByText('Public document', { exact: true }),
).toBeVisible();
const docUrl = page.url();
const urlDoc = page.url();
const { otherPage, cleanup } = await connectOtherUserToDoc({
browserName,
docUrl,
withoutSignIn: true,
docTitle,
});
await page
.getByRole('button', {
name: 'Logout',
})
.click();
await expect(otherPage.getByTestId('search-docs-button')).toBeHidden();
await expect(otherPage.getByTestId('new-doc-button')).toBeHidden();
await expectLoginPage(page);
const otherEditor = await getEditor({ page: otherPage });
await expect(otherEditor).toHaveAttribute('contenteditable', 'true');
await expect(otherEditor.getByText('Hello Public Editable')).toBeVisible();
await page.goto(urlDoc);
// We can see the collaboration cursor of the anonymous user
await otherEditor.getByText('Hello Public').selectText();
await expect(
page.locator('.collaboration-cursor-custom__base').getByText('Anonymous'),
).toBeVisible();
await expect(
otherPage.getByRole('button', { name: 'Share' }),
).toBeVisible();
const card = otherPage.getByLabel('It is the card information');
await expect(card).toBeVisible();
await expect(card.getByText('Editor')).toBeVisible();
await otherPage.getByRole('button', { name: 'Share' }).click();
await expect(
otherPage.getByText(
'You can view this document but need additional access to see its members or modify settings.',
),
).toBeVisible();
await expect(
otherPage.getByRole('button', { name: 'Request access' }),
).toBeHidden();
await cleanup();
await verifyDocName(page, docTitle);
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
});
});
@@ -156,10 +156,9 @@ test.describe('Header: Override configuration', () => {
FRONTEND_THEME: 'dsfr',
theme_customization: {
header: {
icon: {
logo: {
src: '/assets/logo-gouv.svg',
width: '220px',
height: 'auto',
alt: '',
},
},
@@ -27,33 +27,6 @@ test.describe('Home page', () => {
// Check the titles
const h2 = page.locator('h2');
await expect(h2.getByText('Govs ❤️ Open Source.')).toBeVisible();
await expect(page.getByText('Docs is built on top of')).toBeVisible();
await expect(
page.getByRole('link', {
name: 'Django Rest Framework',
}),
).toHaveAttribute('href', 'https://www.django-rest-framework.org/');
await expect(page.getByText('You can easily self-host Docs')).toBeVisible();
await expect(
page
.getByRole('link', {
name: 'licence',
})
.first(),
).toHaveAttribute(
'href',
'https://github.com/suitenumerique/docs/blob/main/LICENSE',
);
await expect(
page.getByText('Docs is the result of a joint effort lead by the French'),
).toBeVisible();
await expect(
page
.getByRole('link', {
name: 'Zendis',
})
.first(),
).toHaveAttribute('href', 'https://zendis.de/');
await expect(
h2.getByText('Collaborative writing, Simplified.'),
).toBeVisible();
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { createDoc, goToGridDoc, verifyDocName } from './utils-common';
import { createDoc } from './utils-common';
test.describe('Left panel desktop', () => {
test.beforeEach(async ({ page }) => {
@@ -10,12 +10,8 @@ test.describe('Left panel desktop', () => {
test('checks all the elements are visible', async ({ page }) => {
await expect(page.getByTestId('left-panel-desktop')).toBeVisible();
await expect(page.getByTestId('left-panel-mobile')).toBeHidden();
await expect(page.getByTestId('home-button')).toBeHidden();
await expect(page.getByTestId('new-doc-button')).toBeVisible();
await goToGridDoc(page);
await expect(page.getByTestId('home-button')).toBeVisible();
await expect(page.getByTestId('new-doc-button')).toBeVisible();
});
test('checks resize handle is present and functional on document page', async ({
@@ -92,9 +88,6 @@ test.describe('Left panel mobile', () => {
await expect(languageButton).not.toBeInViewport();
await expect(logoutButton).not.toBeInViewport();
const title = await goToGridDoc(page);
await verifyDocName(page, title);
await header.getByLabel('Open the header menu').click();
await expect(page.getByTestId('left-panel-mobile')).toBeInViewport();
@@ -0,0 +1,37 @@
/**
* Type definitions for pdf-parse library
* The library doesn't export complete type definitions for the parsed PDF data
*/
declare module 'pdf-parse' {
export interface PdfInfo {
Title?: string;
Author?: string;
Subject?: string;
Keywords?: string;
Creator?: string;
Producer?: string;
CreationDate?: string;
ModDate?: string;
[key: string]: unknown;
}
export interface PdfData {
/** Total number of pages */
numpages: number;
/** Alias for numpages */
total?: number;
/** Extracted text content from the PDF */
text: string;
/** PDF metadata information */
info?: PdfInfo;
/** PDF metadata (alternative structure) */
metadata?: unknown;
/** PDF version */
version?: string;
}
export function pdf(buffer: Buffer): Promise<PdfData>;
export default pdf;
}
@@ -31,7 +31,7 @@ export const overrideConfig = async (
page: Page,
newConfig: { [_K in keyof typeof CONFIG]?: unknown },
) =>
await page.route(/.*\/api\/v1.0\/config\/.*/, async (route) => {
await page.route('**/api/v1.0/config/', async (route) => {
const request = route.request();
if (request.method().includes('GET')) {
await route.fulfill({
@@ -70,14 +70,6 @@ export const keyCloakSignIn = async (
await page.click('button[type="submit"]', { force: true });
};
export const getOtherBrowserName = (browserName: BrowserName) => {
const otherBrowserName = BROWSERS.find((b) => b !== browserName);
if (!otherBrowserName) {
throw new Error('No alternative browser found');
}
return otherBrowserName;
};
export const randomName = (name: string, browserName: string, length: number) =>
Array.from({ length }, (_el, index) => {
return `${browserName}-${Math.floor(Math.random() * 10000)}-${index}-${name}`;
@@ -133,9 +125,7 @@ export const verifyDocName = async (page: Page, docName: string) => {
try {
await expect(
page.getByRole('textbox', { name: 'Document title' }),
).toContainText(docName, {
timeout: 1000,
});
).toContainText(docName);
} catch {
await expect(page.getByRole('heading', { name: docName })).toBeVisible();
}
@@ -214,7 +204,7 @@ export const waitForResponseCreateDoc = (page: Page) => {
};
export const mockedDocument = async (page: Page, data: object) => {
await page.route(/\**\/documents\/\**/, async (route) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
@@ -266,7 +256,7 @@ export const mockedDocument = async (page: Page, data: object) => {
};
export const mockedListDocs = async (page: Page, data: object[] = []) => {
await page.route(/\**\/documents\/\**/, async (route) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (request.method().includes('GET') && request.url().includes('page=')) {
await route.fulfill({
@@ -287,7 +277,6 @@ export const expectLoginPage = async (page: Page) =>
).toBeVisible({
timeout: 10000,
});
// language helper
export const TestLanguage = {
English: {
@@ -311,7 +300,7 @@ export async function waitForLanguageSwitch(
page: Page,
lang: TestLanguageValue,
) {
await page.route(/\**\/api\/v1.0\/users\/\**/, async (route, request) => {
await page.route('**/api/v1.0/users/**', async (route, request) => {
if (request.method().includes('PATCH')) {
await route.fulfill({
json: {
@@ -1,20 +1,20 @@
import { Page, chromium, expect } from '@playwright/test';
import {
BROWSERS,
BrowserName,
getOtherBrowserName,
keyCloakSignIn,
verifyDocName,
} from './utils-common';
export type Role = 'Administrator' | 'Owner' | 'Editor' | 'Reader';
export type Role = 'Administrator' | 'Owner' | 'Member' | 'Editor' | 'Reader';
export type LinkReach = 'Private' | 'Connected' | 'Public';
export type LinkRole = 'Reading' | 'Editing';
export const addNewMember = async (
page: Page,
index: number,
role: Role,
role: 'Administrator' | 'Owner' | 'Editor' | 'Reader',
fillText = 'user.test',
) => {
const responsePromiseSearchUser = page.waitForResponse(
@@ -38,9 +38,9 @@ export const addNewMember = async (
await page.getByRole('option', { name: users[index].email }).click();
// Choose a role
await page.getByTestId('doc-role-dropdown').click();
await page.getByLabel('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: role }).click();
await page.getByTestId('doc-share-invite-button').click();
await page.getByRole('button', { name: 'Invite' }).click();
return users[index].email;
};
@@ -74,7 +74,7 @@ export const updateRoleUser = async (
const list = page.getByTestId('doc-share-quick-search');
const currentUser = list.getByTestId(`doc-share-member-row-${email}`);
const currentUserRole = currentUser.getByTestId('doc-role-dropdown');
const currentUserRole = currentUser.getByLabel('doc-role-dropdown');
await currentUserRole.click();
await page.getByRole('menuitem', { name: role }).click();
await list.click();
@@ -88,30 +88,21 @@ export const updateRoleUser = async (
* @param docTitle The title of the document (optional).
* @returns An object containing the other browser, context, and page.
*/
type ConnectOtherUserToDocParams = {
docUrl: string;
docTitle?: string;
withoutSignIn?: boolean;
} & (
| {
otherBrowserName: BrowserName;
browserName?: never;
}
| {
browserName: BrowserName;
otherBrowserName?: never;
}
);
export const connectOtherUserToDoc = async ({
browserName,
docUrl,
docTitle,
otherBrowserName: _otherBrowserName,
withoutSignIn,
}: ConnectOtherUserToDocParams) => {
const otherBrowserName =
_otherBrowserName || getOtherBrowserName(browserName);
}: {
browserName: BrowserName;
docUrl: string;
docTitle?: string;
withoutSignIn?: boolean;
}) => {
const otherBrowserName = BROWSERS.find((b) => b !== browserName);
if (!otherBrowserName) {
throw new Error('No alternative browser found');
}
const otherBrowser = await chromium.launch({ headless: true });
const otherContext = await otherBrowser.newContext({
@@ -127,16 +118,12 @@ export const connectOtherUserToDoc = async ({
await otherPage.goto(docUrl);
if (!withoutSignIn) {
const loginFromApp = otherPage
await otherPage
.getByRole('main', { name: 'Main content' })
.getByLabel('Login');
const loginFromHome = otherPage.getByRole('button', {
name: 'Start Writing',
});
await loginFromApp.or(loginFromHome).first().click({
timeout: 15000,
});
.getByLabel('Login')
.click({
timeout: 15000,
});
await keyCloakSignIn(otherPage, otherBrowserName, false);
}
@@ -172,7 +159,7 @@ export const mockedInvitations = async (page: Page, json?: object) => {
...json,
},
];
await page.route(/.*\/invitations\/.*/, async (route) => {
await page.route('**/invitations/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
@@ -193,7 +180,7 @@ export const mockedInvitations = async (page: Page, json?: object) => {
});
await page.route(
/.*\/invitations\/120ec765-43af-4602-83eb-7f4e1224548a\/.*/,
'**/invitations/120ec765-43af-4602-83eb-7f4e1224548a/**/',
async (route) => {
const request = route.request();
if (request.method().includes('DELETE')) {
@@ -208,7 +195,7 @@ export const mockedInvitations = async (page: Page, json?: object) => {
};
export const mockedAccesses = async (page: Page, json?: object) => {
await page.route(/.*\/accesses\/.*/, async (route) => {
await page.route('**/accesses/**/', async (route) => {
const request = route.request();
if (
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "3.10.0",
"version": "3.8.2",
"repository": "https://github.com/suitenumerique/docs",
"author": "DINUM",
"license": "MIT",
@@ -15,7 +15,7 @@
"test:ui::chromium": "yarn test:ui --project=chromium"
},
"devDependencies": {
"@playwright/test": "1.56.1",
"@playwright/test": "1.55.1",
"@types/node": "*",
"@types/pdf-parse": "1.1.5",
"eslint-plugin-docs": "*",
@@ -23,7 +23,7 @@
},
"dependencies": {
"convert-stream": "1.0.2",
"pdf-parse": "2.4.5"
"pdf-parse": "2.1.7"
},
"packageManager": "yarn@1.22.22"
}
-29
View File
@@ -1,29 +0,0 @@
//utilitary script to print the datauris of the targeted assets
const fs = require('fs');
const path = require('path');
const ASSETS_ROOT = path.resolve(__dirname, '__tests__/app-impress/assets');
function saveDataUrl(file, mime, outName) {
const abs = path.join(ASSETS_ROOT, file);
const base64 = fs.readFileSync(abs).toString('base64');
const dataUrl = `data:${mime};base64,${base64}`;
const outPath = path.join(ASSETS_ROOT, outName);
fs.writeFileSync(outPath, dataUrl, 'utf8');
console.log(`Wrote ${outName}`);
}
// PNG
saveDataUrl('panopng.png', 'image/png', 'pano-png-dataurl.txt');
// JPG
saveDataUrl('panojpg.jpeg', 'image/jpeg', 'pano-jpg-dataurl.txt');
// SVG
const svgPath = path.join(ASSETS_ROOT, 'test.svg');
const svgText = fs.readFileSync(svgPath, 'utf8');
const svgDataUrl =
'data:image/svg+xml;base64,' + Buffer.from(svgText).toString('base64');
fs.writeFileSync(path.join(ASSETS_ROOT, 'test-svg-dataurl.txt'), svgDataUrl, 'utf8');
console.log('Wrote test-svg-dataurl.txt');
+207 -329
View File
@@ -1,25 +1,57 @@
import { cunninghamConfig as tokens } from '@gouvfr-lasuite/ui-kit';
import { defaultTokens } from '@openfun/cunningham-react';
import merge from 'lodash/merge';
// Uikit does not provide the full list of tokens.
// To be able to override correctly, we need to merge with the default tokens.
let mergedColors = merge(
defaultTokens.globals.colors,
tokens.themes.default.globals.colors,
);
mergedColors = {
...mergedColors,
'logo-1': '#2845C1',
const customColors = {
'primary-action': '#1212FF',
'primary-bg': '#FAFAFA',
'primary-focus': '#0A76F6',
'secondary-icon': 'var(--c--theme--colors--primary-text)',
'blue-400': '#7AB1E8',
'blue-500': '#417DC4',
'blue-600': '#3558A2',
'brown-400': '#E6BE92',
'brown-500': '#BD987A',
'brown-600': '#745B47',
'cyan-400': '#34BAB5',
'cyan-500': '#009099',
'cyan-600': '#006A6F',
'gold-400': '#FFCA00',
'gold-500': '#C3992A',
'gold-600': '#695240',
'green-400': '#34CB6A',
'green-500': '#00A95F',
'green-600': '#297254',
'olive-400': '#99C221',
'olive-500': '#68A532',
'olive-600': '#447049',
'orange-400': '#FF732C',
'orange-500': '#E4794A',
'orange-600': '#755348',
'pink-400': '#FFB7AE',
'pink-500': '#E18B76',
'pink-600': '#8D533E',
'purple-400': '#CE70CC',
'purple-500': '#A558A0',
'purple-600': '#6E445A',
'yellow-400': '#D8C634',
'yellow-500': '#B7A73F',
'yellow-600': '#66673D',
};
tokens.themes.default.globals = {
...tokens.themes.default.globals,
tokens.themes.default.theme = {
...tokens.themes.default.theme,
...{
colors: mergedColors,
logo: {
src: '',
alt: '',
widthHeader: '',
widthFooter: '',
},
colors: {
...tokens.themes.default.theme.colors,
...customColors,
},
font: {
...tokens.themes.default.globals.font,
...tokens.themes.default.theme.font,
families: {
base: 'sans-serif',
accent: 'sans-serif',
@@ -31,50 +63,49 @@ tokens.themes.default.globals = {
tokens.themes.default.components = {
...tokens.themes.default.components,
...{
logo: {
src: '',
alt: '',
widthHeader: '',
widthFooter: '',
},
'la-gaufre': false,
'home-proconnect': false,
icon: {
src: '/assets/icon-docs.svg',
width: '32px',
height: 'auto',
},
'image-system-filter': '',
favicon: {
ico: '/assets/favicon-light.ico',
'png-light': '/assets/favicon-light.png',
'png-dark': '/assets/favicon-dark.png',
},
button: {
...tokens.themes.default.components.button,
primary: {
...tokens.themes.default.components.button.primary,
...{
'background--disabled': 'var(--c--theme--colors--greyscale-100)',
},
disabled: 'var(--c--theme--colors--greyscale-400)',
},
},
},
};
const dsfrTheme = {
dsfr: {
globals: {
font: {
families: {
base: 'Marianne, Inter, Roboto Flex Variable, sans-serif',
accent: 'Marianne, Inter, Roboto Flex Variable, sans-serif',
},
theme: {
colors: {
'secondary-icon': '#C9191E',
},
},
components: {
logo: {
src: '/assets/logo-gouv.svg',
widthHeader: '110px',
widthFooter: '220px',
alt: 'Gouvernement Logo',
},
font: {
families: {
base: 'Marianne',
accent: 'Marianne',
},
},
},
components: {
'la-gaufre': true,
'home-proconnect': true,
icon: {
src: '/assets/icon-docs-dsfr.svg',
width: '32px',
height: 'auto',
},
favicon: {
ico: '/assets/favicon-dsfr.ico',
'png-light': '/assets/favicon-dsfr.png',
@@ -86,296 +117,131 @@ const dsfrTheme = {
const genericTheme = {
generic: {
globals: {
theme: {
colors: {
'brand-050': '#EEF1FA',
'brand-100': '#DDE2F5',
'brand-150': '#CED3F1',
'brand-200': '#BEC5F0',
'brand-250': '#AFB5F1',
'brand-300': '#A0A5F6',
'brand-350': '#8F94FD',
'brand-400': '#8184FC',
'brand-450': '#7576EE',
'brand-500': '#6969DF',
'brand-550': '#5E5CD0',
'brand-600': '#534FC2',
'brand-650': '#4844AD',
'brand-700': '#3E3B98',
'brand-750': '#36347D',
'brand-800': '#2D2F5F',
'brand-850': '#262848',
'brand-900': '#1C1E32',
'brand-950': '#11131F',
'gray-000': '#FFFFFF',
'gray-025': '#F8F8F9',
'gray-050': '#F0F0F3',
'gray-100': '#E2E2EA',
'gray-150': '#D3D4E0',
'gray-200': '#C5C6D5',
'gray-250': '#B7B7CB',
'gray-300': '#A9A9BF',
'gray-350': '#9C9CB2',
'gray-400': '#8F8FA4',
'gray-450': '#828297',
'gray-500': '#75758A',
'gray-550': '#69697D',
'gray-600': '#5D5D70',
'gray-650': '#515164',
'gray-700': '#454558',
'gray-750': '#3A3A4C',
'gray-800': '#2F303D',
'gray-850': '#25252F',
'gray-900': '#1B1B23',
'gray-950': '#111114',
'gray-1000': '#000000',
'info-050': '#EAF2F9',
'info-100': '#D5E4F3',
'info-150': '#BFD7F0',
'info-200': '#A7CAEE',
'info-250': '#8DBDEF',
'info-300': '#6EB0F2',
'info-350': '#50A2F5',
'info-400': '#3593F4',
'info-450': '#1185ED',
'info-500': '#0077DE',
'info-550': '#0069CF',
'info-600': '#005BC0',
'info-650': '#0D4EAA',
'info-700': '#124394',
'info-750': '#163878',
'info-800': '#192F5A',
'info-850': '#192541',
'info-900': '#141B2D',
'info-950': '#0C111C',
'success-050': '#E8F1EA',
'success-100': '#CFE4D4',
'success-150': '#BAD9C1',
'success-200': '#A2CFAD',
'success-250': '#86C597',
'success-300': '#6CBA83',
'success-350': '#4FB070',
'success-400': '#40A363',
'success-450': '#309556',
'success-500': '#1E884A',
'success-550': '#027B3E',
'success-600': '#016D31',
'success-650': '#006024',
'success-700': '#005317',
'success-750': '#0D4511',
'success-800': '#11380E',
'success-850': '#132A11',
'success-900': '#101E0F',
'success-950': '#091209',
'warning-050': '#F8F0E9',
'warning-100': '#F1E0D3',
'warning-150': '#ECD0BC',
'warning-200': '#E8C0A4',
'warning-250': '#E8AE8A',
'warning-300': '#EB9970',
'warning-350': '#E98456',
'warning-400': '#E57036',
'warning-450': '#DA5E18',
'warning-500': '#CB5000',
'warning-550': '#BC4200',
'warning-600': '#AD3300',
'warning-650': '#9E2300',
'warning-700': '#882011',
'warning-750': '#731E16',
'warning-800': '#58201A',
'warning-850': '#401D18',
'warning-900': '#2E1714',
'warning-950': '#1D0F0D',
'error-050': '#F9EFEC',
'error-100': '#F4DFD9',
'error-150': '#F0CEC6',
'error-200': '#EEBCB2',
'error-250': '#EEA99D',
'error-300': '#EF9486',
'error-350': '#F37C6E',
'error-400': '#F65F53',
'error-450': '#F0463D',
'error-500': '#E82322',
'error-550': '#D7010E',
'error-600': '#C00100',
'error-650': '#AA0000',
'error-700': '#910C06',
'error-750': '#731E16',
'error-800': '#58201A',
'error-850': '#401D18',
'error-900': '#2E1714',
'error-950': '#1D0F0D',
'red-050': '#FAEFEE',
'red-100': '#F4DEDD',
'red-150': '#F1CDCB',
'red-200': '#EFBBBA',
'red-250': '#EEA8A8',
'red-300': '#F09394',
'red-350': '#F37B7E',
'red-400': '#EF6569',
'red-450': '#E94A55',
'red-500': '#DA3B49',
'red-550': '#CA2A3C',
'red-600': '#BB1330',
'red-650': '#A90021',
'red-700': '#910A13',
'red-750': '#731E16',
'red-800': '#58201A',
'red-850': '#411D18',
'red-900': '#2E1714',
'red-950': '#1D0F0D',
'orange-050': '#F8F0E9',
'orange-100': '#F1E0D3',
'orange-150': '#ECD0BD',
'orange-200': '#EABFA6',
'orange-250': '#EBAC90',
'orange-300': '#EC9772',
'orange-350': '#E5845A',
'orange-400': '#D6774D',
'orange-450': '#C86A40',
'orange-500': '#B95D33',
'orange-550': '#AB5025',
'orange-600': '#9D4315',
'orange-650': '#8F3600',
'orange-700': '#812900',
'orange-750': '#6C2511',
'orange-800': '#572017',
'orange-850': '#401D18',
'orange-900': '#2E1714',
'orange-950': '#1D0F0D',
'brown-050': '#F6F0E8',
'brown-100': '#F1E0D3',
'brown-150': '#EBD0BA',
'brown-200': '#E2C0A6',
'brown-250': '#D4B398',
'brown-300': '#C6A58B',
'brown-350': '#B8987E',
'brown-400': '#AA8B71',
'brown-450': '#9D7E65',
'brown-500': '#8F7158',
'brown-550': '#82654C',
'brown-600': '#765841',
'brown-650': '#694C35',
'brown-700': '#5D412A',
'brown-750': '#51361E',
'brown-800': '#452A13',
'brown-850': '#392008',
'brown-900': '#29180A',
'brown-950': '#1B0F08',
'yellow-050': '#F3F0E7',
'yellow-100': '#E9E2CF',
'yellow-150': '#E1D4B7',
'yellow-200': '#D9C599',
'yellow-250': '#D2B677',
'yellow-300': '#CAA756',
'yellow-350': '#C2972E',
'yellow-400': '#B98900',
'yellow-450': '#AB7B00',
'yellow-500': '#9D6E00',
'yellow-550': '#916100',
'yellow-600': '#855400',
'yellow-650': '#784700',
'yellow-700': '#6C3A00',
'yellow-750': '#5F2E00',
'yellow-800': '#512302',
'yellow-850': '#3E1D10',
'yellow-900': '#2D1711',
'yellow-950': '#1D0F0D',
'green-050': '#E6F1E9',
'green-100': '#CFE4D5',
'green-150': '#B8D8C1',
'green-200': '#A0CFAE',
'green-250': '#84C59A',
'green-300': '#65BA86',
'green-350': '#45B173',
'green-400': '#23A562',
'green-450': '#029755',
'green-500': '#008948',
'green-550': '#017B3B',
'green-600': '#006E2E',
'green-650': '#006022',
'green-700': '#005314',
'green-750': '#0D4510',
'green-800': '#11380E',
'green-850': '#132A11',
'green-900': '#101E0F',
'green-950': '#091209',
'blue1-050': '#EBF1F9',
'blue1-100': '#D6E4F4',
'blue1-150': '#C1D7F0',
'blue1-200': '#AACAEF',
'blue1-250': '#8FBCEF',
'blue1-300': '#7CAFEB',
'blue1-350': '#68A1E4',
'blue1-400': '#5B94D6',
'blue1-450': '#4E86C7',
'blue1-500': '#4279B9',
'blue1-550': '#356CAC',
'blue1-600': '#28609E',
'blue1-650': '#1B5390',
'blue1-700': '#0B4783',
'blue1-750': '#0F3C6E',
'blue1-800': '#133059',
'blue1-850': '#152641',
'blue1-900': '#121C2D',
'blue1-950': '#0B111C',
'blue2-050': '#E7F3F4',
'blue2-100': '#CEE7E9',
'blue2-150': '#B2DCE0',
'blue2-200': '#91D1D7',
'blue2-250': '#68C7D0',
'blue2-300': '#43BBC5',
'blue2-350': '#00AFBA',
'blue2-400': '#01A0AA',
'blue2-450': '#00929D',
'blue2-500': '#00848F',
'blue2-550': '#007682',
'blue2-600': '#016874',
'blue2-650': '#005B67',
'blue2-700': '#004E5A',
'blue2-750': '#00424E',
'blue2-800': '#003642',
'blue2-850': '#002A38',
'blue2-900': '#061E28',
'blue2-950': '#071219',
'purple-050': '#F7F0F6',
'purple-100': '#EEE0EE',
'purple-150': '#E7D1E7',
'purple-200': '#DBBFE4',
'purple-250': '#D3AEE2',
'purple-300': '#CB99E1',
'purple-350': '#C188D9',
'purple-400': '#B47BCB',
'purple-450': '#A66EBD',
'purple-500': '#9961AF',
'purple-550': '#8B55A1',
'purple-600': '#7E4894',
'purple-650': '#723C87',
'purple-700': '#633376',
'purple-750': '#552A65',
'purple-800': '#452551',
'purple-850': '#35213D',
'purple-900': '#261A2C',
'purple-950': '#17111C',
'pink-050': '#F8EFF4',
'pink-100': '#F0DFEA',
'pink-150': '#EACEDF',
'pink-200': '#E9BBD1',
'pink-250': '#E9A7C2',
'pink-300': '#E095B4',
'pink-350': '#D685A8',
'pink-400': '#C7799B',
'pink-450': '#B86C8D',
'pink-500': '#AA5F80',
'pink-550': '#9C5374',
'pink-600': '#8E4767',
'pink-650': '#813B5B',
'pink-700': '#732E4F',
'pink-750': '#632643',
'pink-800': '#521F38',
'pink-850': '#3E1C2B',
'pink-900': '#2D171F',
'pink-950': '#1C0E12',
'primary-action': '#206EBD',
'primary-focus': '#1E64BF',
'primary-text': '#2E2C28',
'primary-050': '#F8F8F7',
'primary-100': '#F0EFEC',
'primary-150': '#F4F4FD',
'primary-200': '#E8E7E4',
'primary-300': '#CFCDC9',
'primary-400': '#979592',
'primary-500': '#82807D',
'primary-600': '#3F3D39',
'primary-700': '#2E2C28',
'primary-800': '#302E29',
'primary-900': '#282622',
'primary-950': '#201F1C',
'secondary-text': '#fff',
'secondary-50': '#F4F7FA',
'secondary-100': '#D7E3EE',
'secondary-200': '#B8CCE1',
'secondary-300': '#99B4D3',
'secondary-400': '#7595BE',
'secondary-500': '#5874A0',
'secondary-600': '#3A5383',
'secondary-700': '#1E3462',
'secondary-800': '#091B41',
'secondary-900': '#08183B',
'secondary-950': '#071636',
'greyscale-text': '#3C3B38',
'greyscale-000': '#fff',
'greyscale-050': '#F8F7F7',
'greyscale-100': '#F3F3F2',
'greyscale-200': '#ECEBEA',
'greyscale-250': '#E4E3E2',
'greyscale-300': '#D3D2CF',
'greyscale-350': '#eee',
'greyscale-400': '#96948E',
'greyscale-500': '#817E77',
'greyscale-600': '#6A6862',
'greyscale-700': '#3C3B38',
'greyscale-750': '#383632',
'greyscale-800': '#2D2B27',
'greyscale-900': '#262522',
'greyscale-950': '#201F1C',
'greyscale-1000': '#181714',
'success-text': '#234935',
'success-50': '#F3FBF5',
'success-100': '#E4F7EA',
'success-200': '#CAEED4',
'success-300': '#A0E0B5',
'success-400': '#6CC88C',
'success-500': '#6CC88C',
'success-600': '#358D5C',
'success-700': '#2D704B',
'success-800': '#28583F',
'success-900': '#234935',
'success-950': '#0F281B',
'info-text': '#212445',
'info-50': '#F2F6FB',
'info-100': '#E2E9F5',
'info-200': '#CCD8EE',
'info-300': '#A9C0E3',
'info-400': '#809DD4',
'info-500': '#617BC7',
'info-600': '#4A5CBF',
'info-700': '#3E49B2',
'info-800': '#353C8F',
'info-900': '#303771',
'info-950': '#212445',
'warning-text': '#D97C3A',
'warning-50': '#FDF7F1',
'warning-100': '#FBEDDC',
'warning-200': '#F5D9B9',
'warning-300': '#EDBE8C',
'warning-400': '#E2985C',
'warning-500': '#D97C3A',
'warning-600': '#C96330',
'warning-700': '#A34B32',
'warning-800': '#813B2C',
'warning-900': '#693327',
'warning-950': '#381713',
'danger-action': '#C0182A',
'danger-text': '#FFF',
'danger-050': '#FDF5F4',
'danger-100': '#FBEBE8',
'danger-200': '#F9E0DC',
'danger-300': '#F3C3BD',
'danger-400': '#E26552',
'danger-500': '#C91F00',
'danger-600': '#A71901',
'danger-700': '#562C2B',
'danger-800': '#392425',
'danger-900': '#311F20',
'danger-950': '#2A191A',
'blue-400': '#8BAECC',
'blue-500': '#567AA2',
'blue-600': '#455784',
'brown-400': '#E4C090',
'brown-500': '#BA9977',
'brown-600': '#735C45',
'cyan-400': '#5CBEC9',
'cyan-500': '#43A1B3',
'cyan-600': '#39809B',
'gold-400': '#ECBF50',
'gold-500': '#DFA038',
'gold-600': '#C17B31',
'green-400': '#5DBD9A',
'green-500': '#3AA183',
'green-600': '#2A816D',
'olive-400': '#AFD662',
'olive-500': '#90BB4B',
'olive-600': '#6E9441',
'orange-400': '#E2985C',
'orange-500': '#D97C3A',
'orange-600': '#C96330',
'pink-400': '#BE8FC8',
'pink-500': '#A563B1',
'pink-600': '#8B44A5',
'purple-400': '#BE8FC8',
'purple-500': '#A563B1',
'purple-600': '#8B44A5',
'yellow-400': '#EDC947',
'yellow-500': '#DBB13A',
'yellow-600': '#B88A34',
},
font: {
families: {
@@ -384,6 +250,18 @@ const genericTheme = {
},
},
},
components: {
button: {
primary: {
background: {
'color-hover': 'var(--c--theme--colors--primary-focus)',
'color-active': 'var(--c--theme--colors--primary-focus)',
'color-focus': 'var(--c--theme--colors--primary-focus)',
},
},
},
'image-system-filter': 'saturate(0.2)',
},
},
};
+27 -28
View File
@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "3.10.0",
"version": "3.8.2",
"repository": "https://github.com/suitenumerique/docs",
"author": "DINUM",
"license": "MIT",
@@ -19,51 +19,50 @@
},
"dependencies": {
"@ag-media/react-pdf-table": "2.0.3",
"@blocknote/code-block": "0.42.3",
"@blocknote/core": "0.42.3",
"@blocknote/mantine": "0.42.3",
"@blocknote/react": "0.42.3",
"@blocknote/xl-docx-exporter": "0.42.3",
"@blocknote/xl-multi-column": "0.42.3",
"@blocknote/xl-odt-exporter": "0.42.3",
"@blocknote/xl-pdf-exporter": "0.42.3",
"@blocknote/code-block": "0.41.1",
"@blocknote/core": "0.41.1",
"@blocknote/mantine": "0.41.1",
"@blocknote/react": "0.41.1",
"@blocknote/xl-docx-exporter": "0.41.1",
"@blocknote/xl-multi-column": "0.41.1",
"@blocknote/xl-pdf-exporter": "0.41.1",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/modifiers": "9.0.0",
"@emoji-mart/data": "1.2.1",
"@emoji-mart/react": "1.1.1",
"@fontsource-variable/inter": "5.2.8",
"@fontsource-variable/material-symbols-outlined": "5.2.28",
"@fontsource-variable/material-symbols-outlined": "5.2.25",
"@fontsource/material-icons": "5.2.7",
"@gouvfr-lasuite/integration": "1.0.3",
"@gouvfr-lasuite/ui-kit": "0.18.0",
"@hocuspocus/provider": "3.4.0",
"@mantine/core": "8.3.6",
"@mantine/hooks": "8.3.6",
"@openfun/cunningham-react": "4.0.0",
"@gouvfr-lasuite/ui-kit": "0.16.2",
"@hocuspocus/provider": "3.3.0",
"@mantine/core": "8.3.4",
"@mantine/hooks": "8.3.4",
"@openfun/cunningham-react": "3.2.3",
"@react-pdf/renderer": "4.3.1",
"@sentry/nextjs": "10.27.0",
"@tanstack/react-query": "5.90.6",
"@tiptap/extensions": "*",
"@sentry/nextjs": "10.17.0",
"@tanstack/react-query": "5.90.2",
"@tiptap/extensions": "3.4.4",
"canvg": "4.0.3",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"crisp-sdk-web": "1.0.26",
"crisp-sdk-web": "1.0.25",
"docx": "*",
"emoji-datasource-apple": "16.0.0",
"emoji-mart": "5.6.0",
"emoji-regex": "10.6.0",
"i18next": "25.6.0",
"emoji-regex": "10.5.0",
"i18next": "25.5.3",
"i18next-browser-languagedetector": "8.2.0",
"idb": "8.0.3",
"lodash": "4.17.21",
"luxon": "3.7.2",
"next": "15.5.4",
"posthog-js": "1.284.0",
"posthog-js": "1.271.0",
"react": "*",
"react-aria-components": "1.13.0",
"react-dom": "*",
"react-i18next": "16.3.3",
"react-intersection-observer": "10.0.0",
"react-i18next": "16.0.0",
"react-intersection-observer": "9.16.0",
"react-resizable-panels": "3.0.6",
"react-select": "5.10.2",
"styled-components": "6.1.19",
@@ -84,13 +83,13 @@
"@types/node": "*",
"@types/react": "*",
"@types/react-dom": "*",
"@vitejs/plugin-react": "5.1.0",
"@vitejs/plugin-react": "5.0.4",
"copy-webpack-plugin": "13.0.1",
"cross-env": "10.1.0",
"dotenv": "17.2.3",
"eslint-plugin-docs": "*",
"fetch-mock": "9.11.0",
"jsdom": "27.1.0",
"jsdom": "27.0.0",
"node-fetch": "2.7.0",
"prettier": "3.6.2",
"stylelint": "16.25.0",
@@ -98,8 +97,8 @@
"stylelint-prettier": "5.0.3",
"typescript": "*",
"vite-tsconfig-paths": "5.1.4",
"vitest": "4.0.6",
"webpack": "5.102.1",
"vitest": "3.2.4",
"webpack": "5.102.0",
"workbox-webpack-plugin": "7.1.0"
},
"packageManager": "yarn@1.22.22"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -1,12 +0,0 @@
<svg viewBox="0 0 32 33" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M21.6305 29.5812C22.7983 29.2538 23.9166 28.6562 24.6505 27.6003C25.3749 26.5663 25.5789 25.2547 25.5789 23.9925V5.50099C25.5789 5.17358 25.5611 4.84557 25.5216 4.52148C26.1016 4.74961 26.5486 5.12658 26.8626 5.65239C27.2331 6.25024 27.4184 7.03757 27.4184 8.01435V26.7964C27.4184 28.1184 27.0942 29.1078 26.4458 29.7646C25.7974 30.4214 24.8207 30.7498 23.5155 30.7498H16.4209C16.5889 30.7204 16.7574 30.6901 16.9262 30.659C18.4067 30.3944 19.9713 30.0354 21.6185 29.5846L21.6305 29.5812Z"
fill="#C9191E"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M4.58203 26.405V7.5977C4.58203 6.45251 4.88938 5.58519 5.50408 4.99575C6.1272 4.40631 6.95242 4.08212 7.97972 4.02318C9.49542 3.93055 10.9311 3.80425 12.2868 3.64425C13.6425 3.47584 14.9393 3.28217 16.1771 3.06324C17.4234 2.8443 18.6359 2.60011 19.8148 2.33065C21.0274 2.04435 21.9578 2.1875 22.6062 2.7601C23.2546 3.33269 23.5788 4.24632 23.5788 5.50099V23.9925C23.5788 25.0956 23.3893 25.9166 23.0104 26.4555C22.6315 27.0029 21.9915 27.4028 21.0905 27.6554C19.4906 28.0933 17.9833 28.4386 16.5687 28.6912C15.154 28.9522 13.7731 29.1501 12.4258 29.2848C11.0785 29.4196 9.69751 29.5248 8.28286 29.6006C7.11241 29.668 6.20299 29.4238 5.5546 28.868C4.90622 28.3207 4.58203 27.4997 4.58203 26.405ZM9.20865 11.0124C11.0635 10.8944 12.7632 10.7131 14.3075 10.4683C14.6822 10.4072 15.0564 10.3436 15.4291 10.2776C15.8192 10.2085 16.1013 9.86859 16.1013 9.47337C16.1013 8.96154 15.638 8.57609 15.135 8.66189C14.846 8.71118 14.5555 8.75909 14.2635 8.80562C12.7346 9.04923 11.0452 9.22998 9.19523 9.3477C8.91819 9.36558 8.69776 9.45188 8.55608 9.62391C8.42209 9.78661 8.35645 9.98229 8.35645 10.2053C8.35645 10.4321 8.43296 10.6295 8.58568 10.7918L8.58783 10.7939C8.75336 10.9595 8.96369 11.0311 9.20865 11.0124ZM9.20801 15.206C11.0631 15.088 12.763 14.9066 14.3075 14.6619C15.8588 14.4089 17.3936 14.1138 18.9112 13.7766C19.2191 13.7081 19.4498 13.6003 19.5652 13.433C19.6786 13.2721 19.7347 13.0876 19.7347 12.8832C19.7347 12.6526 19.6469 12.454 19.476 12.2926C19.2921 12.1189 19.0348 12.0784 18.7304 12.1411L18.7285 12.1415C17.2823 12.4694 15.794 12.7553 14.2635 12.9992C12.7346 13.2428 11.0452 13.4235 9.19523 13.5413C8.91819 13.5591 8.69776 13.6454 8.55608 13.8175C8.42276 13.9794 8.35645 14.1705 8.35645 14.3863C8.35645 14.6203 8.43209 14.8223 8.58558 14.9854L8.59 14.9896C8.75499 15.1449 8.96316 15.2155 9.20551 15.2062L9.20801 15.206ZM9.20847 19.3994C11.0634 19.2729 12.7631 19.0874 14.3075 18.8427C15.8589 18.5982 17.3934 18.3073 18.9112 17.97C19.2199 17.9014 19.4508 17.7891 19.566 17.6127C19.6783 17.4529 19.7347 17.2733 19.7347 17.0766C19.7347 16.8461 19.6469 16.6474 19.476 16.4861C19.2921 16.3123 19.0348 16.2718 18.7304 16.3345L18.729 16.3348C17.2827 16.6543 15.7942 16.9361 14.2635 17.18C12.7345 17.4236 11.045 17.6086 9.19495 17.7347C8.91804 17.7526 8.69771 17.8389 8.55608 18.0109C8.42276 18.1728 8.35645 18.3639 8.35645 18.5797C8.35645 18.8137 8.43209 19.0158 8.58558 19.1789L8.59 19.183C8.75499 19.3383 8.96316 19.4089 9.20551 19.3996L9.20847 19.3994ZM14.3075 23.007C12.7632 23.2518 11.0635 23.4331 9.20867 23.5512C8.9637 23.5698 8.75337 23.4982 8.58783 23.3326L8.58572 23.3305C8.433 23.1682 8.35645 22.9708 8.35645 22.7441C8.35645 22.521 8.42209 22.3253 8.55608 22.1626C8.69776 21.9906 8.91827 21.9043 9.19531 21.8864C11.0453 21.7687 12.7346 21.588 14.2635 21.3443C14.5555 21.2978 14.846 21.2499 15.135 21.2006C15.638 21.1148 16.1013 21.5003 16.1013 22.0121C16.1013 22.4073 15.8192 22.7472 15.4291 22.8163C15.0564 22.8823 14.6822 22.9459 14.3075 23.007Z"
fill="#2845C1"
/>
</svg>

Before

Width:  |  Height:  |  Size: 3.6 KiB

@@ -1,4 +1,12 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21.6305 28.8312C22.7983 28.5038 23.9166 27.9062 24.6505 26.8503C25.3749 25.8163 25.5789 24.5047 25.5789 23.2425V4.75099C25.5789 4.42358 25.5611 4.09557 25.5216 3.77148C26.1016 3.99961 26.5486 4.37658 26.8626 4.90239C27.2331 5.50024 27.4184 6.28757 27.4184 7.26435V26.0464C27.4184 27.3684 27.0942 28.3578 26.4458 29.0146C25.7974 29.6714 24.8207 29.9998 23.5155 29.9998H16.4209C16.5889 29.9704 16.7574 29.9401 16.9262 29.909C18.4067 29.6444 19.9713 29.2854 21.6185 28.8346L21.6305 28.8312Z" fill="#4747A3"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.58203 25.655V6.8477C4.58203 5.70251 4.88938 4.83519 5.50408 4.24575C6.1272 3.65631 6.95242 3.33212 7.97972 3.27318C9.49542 3.18055 10.9311 3.05425 12.2868 2.89425C13.6425 2.72584 14.9393 2.53217 16.1771 2.31324C17.4234 2.0943 18.6359 1.85011 19.8148 1.58065C21.0274 1.29435 21.9578 1.4375 22.6062 2.0101C23.2546 2.58269 23.5788 3.49632 23.5788 4.75099V23.2425C23.5788 24.3456 23.3893 25.1666 23.0104 25.7055C22.6315 26.2529 21.9915 26.6528 21.0905 26.9054C19.4906 27.3433 17.9833 27.6886 16.5687 27.9412C15.154 28.2022 13.7731 28.4001 12.4258 28.5348C11.0785 28.6696 9.69751 28.7748 8.28286 28.8506C7.11241 28.918 6.20299 28.6738 5.5546 28.118C4.90622 27.5707 4.58203 26.7497 4.58203 25.655ZM9.20865 10.2624C11.0635 10.1444 12.7632 9.96305 14.3075 9.71831C14.6822 9.65722 15.0564 9.5936 15.4291 9.52759C15.8192 9.45851 16.1013 9.11859 16.1013 8.72337C16.1013 8.21154 15.638 7.82609 15.135 7.91189C14.846 7.96118 14.5555 8.00909 14.2635 8.05562C12.7346 8.29923 11.0452 8.47998 9.19523 8.5977C8.91819 8.61558 8.69776 8.70188 8.55608 8.87391C8.42209 9.03661 8.35645 9.23229 8.35645 9.45535C8.35645 9.68212 8.43296 9.87951 8.58568 10.0418L8.58783 10.0439C8.75336 10.2095 8.96369 10.2811 9.20865 10.2624ZM9.20801 14.456C11.0631 14.338 12.763 14.1566 14.3075 13.9119C15.8588 13.6589 17.3936 13.3638 18.9112 13.0266C19.2191 12.9581 19.4498 12.8503 19.5652 12.683C19.6786 12.5221 19.7347 12.3376 19.7347 12.1332C19.7347 11.9026 19.6469 11.704 19.476 11.5426C19.2921 11.3689 19.0348 11.3284 18.7304 11.3911L18.7285 11.3915C17.2823 11.7194 15.794 12.0053 14.2635 12.2492C12.7346 12.4928 11.0452 12.6735 9.19523 12.7913C8.91819 12.8091 8.69776 12.8954 8.55608 13.0675C8.42276 13.2294 8.35645 13.4205 8.35645 13.6363C8.35645 13.8703 8.43209 14.0723 8.58558 14.2354L8.59 14.2396C8.75499 14.3949 8.96316 14.4655 9.20551 14.4562L9.20801 14.456ZM9.20847 18.6494C11.0634 18.5229 12.7631 18.3374 14.3075 18.0927C15.8589 17.8482 17.3934 17.5573 18.9112 17.22C19.2199 17.1514 19.4508 17.0391 19.566 16.8627C19.6783 16.7029 19.7347 16.5233 19.7347 16.3266C19.7347 16.0961 19.6469 15.8974 19.476 15.7361C19.2921 15.5623 19.0348 15.5218 18.7304 15.5845L18.729 15.5848C17.2827 15.9043 15.7942 16.1861 14.2635 16.43C12.7345 16.6736 11.045 16.8586 9.19495 16.9847C8.91804 17.0026 8.69771 17.0889 8.55608 17.2609C8.42276 17.4228 8.35645 17.6139 8.35645 17.8297C8.35645 18.0637 8.43209 18.2658 8.58558 18.4289L8.59 18.433C8.75499 18.5883 8.96316 18.6589 9.20551 18.6496L9.20847 18.6494ZM14.3075 22.257C12.7632 22.5018 11.0635 22.6831 9.20867 22.8012C8.9637 22.8198 8.75337 22.7482 8.58783 22.5826L8.58572 22.5805C8.433 22.4182 8.35645 22.2208 8.35645 21.9941C8.35645 21.771 8.42209 21.5753 8.55608 21.4126C8.69776 21.2406 8.91827 21.1543 9.19531 21.1364C11.0453 21.0187 12.7346 20.838 14.2635 20.5943C14.5555 20.5478 14.846 20.4999 15.135 20.4506C15.638 20.3648 16.1013 20.7503 16.1013 21.2621C16.1013 21.6573 15.8192 21.9972 15.4291 22.0663C15.0564 22.1323 14.6822 22.1959 14.3075 22.257Z" fill="#4747A3"/>
<svg viewBox="0 0 32 33" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M21.6305 29.5812C22.7983 29.2538 23.9166 28.6562 24.6505 27.6003C25.3749 26.5663 25.5789 25.2547 25.5789 23.9925V5.50099C25.5789 5.17358 25.5611 4.84557 25.5216 4.52148C26.1016 4.74961 26.5486 5.12658 26.8626 5.65239C27.2331 6.25024 27.4184 7.03757 27.4184 8.01435V26.7964C27.4184 28.1184 27.0942 29.1078 26.4458 29.7646C25.7974 30.4214 24.8207 30.7498 23.5155 30.7498H16.4209C16.5889 30.7204 16.7574 30.6901 16.9262 30.659C18.4067 30.3944 19.9713 30.0354 21.6185 29.5846L21.6305 29.5812Z"
fill="#C9191E"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M4.58203 26.405V7.5977C4.58203 6.45251 4.88938 5.58519 5.50408 4.99575C6.1272 4.40631 6.95242 4.08212 7.97972 4.02318C9.49542 3.93055 10.9311 3.80425 12.2868 3.64425C13.6425 3.47584 14.9393 3.28217 16.1771 3.06324C17.4234 2.8443 18.6359 2.60011 19.8148 2.33065C21.0274 2.04435 21.9578 2.1875 22.6062 2.7601C23.2546 3.33269 23.5788 4.24632 23.5788 5.50099V23.9925C23.5788 25.0956 23.3893 25.9166 23.0104 26.4555C22.6315 27.0029 21.9915 27.4028 21.0905 27.6554C19.4906 28.0933 17.9833 28.4386 16.5687 28.6912C15.154 28.9522 13.7731 29.1501 12.4258 29.2848C11.0785 29.4196 9.69751 29.5248 8.28286 29.6006C7.11241 29.668 6.20299 29.4238 5.5546 28.868C4.90622 28.3207 4.58203 27.4997 4.58203 26.405ZM9.20865 11.0124C11.0635 10.8944 12.7632 10.7131 14.3075 10.4683C14.6822 10.4072 15.0564 10.3436 15.4291 10.2776C15.8192 10.2085 16.1013 9.86859 16.1013 9.47337C16.1013 8.96154 15.638 8.57609 15.135 8.66189C14.846 8.71118 14.5555 8.75909 14.2635 8.80562C12.7346 9.04923 11.0452 9.22998 9.19523 9.3477C8.91819 9.36558 8.69776 9.45188 8.55608 9.62391C8.42209 9.78661 8.35645 9.98229 8.35645 10.2053C8.35645 10.4321 8.43296 10.6295 8.58568 10.7918L8.58783 10.7939C8.75336 10.9595 8.96369 11.0311 9.20865 11.0124ZM9.20801 15.206C11.0631 15.088 12.763 14.9066 14.3075 14.6619C15.8588 14.4089 17.3936 14.1138 18.9112 13.7766C19.2191 13.7081 19.4498 13.6003 19.5652 13.433C19.6786 13.2721 19.7347 13.0876 19.7347 12.8832C19.7347 12.6526 19.6469 12.454 19.476 12.2926C19.2921 12.1189 19.0348 12.0784 18.7304 12.1411L18.7285 12.1415C17.2823 12.4694 15.794 12.7553 14.2635 12.9992C12.7346 13.2428 11.0452 13.4235 9.19523 13.5413C8.91819 13.5591 8.69776 13.6454 8.55608 13.8175C8.42276 13.9794 8.35645 14.1705 8.35645 14.3863C8.35645 14.6203 8.43209 14.8223 8.58558 14.9854L8.59 14.9896C8.75499 15.1449 8.96316 15.2155 9.20551 15.2062L9.20801 15.206ZM9.20847 19.3994C11.0634 19.2729 12.7631 19.0874 14.3075 18.8427C15.8589 18.5982 17.3934 18.3073 18.9112 17.97C19.2199 17.9014 19.4508 17.7891 19.566 17.6127C19.6783 17.4529 19.7347 17.2733 19.7347 17.0766C19.7347 16.8461 19.6469 16.6474 19.476 16.4861C19.2921 16.3123 19.0348 16.2718 18.7304 16.3345L18.729 16.3348C17.2827 16.6543 15.7942 16.9361 14.2635 17.18C12.7345 17.4236 11.045 17.6086 9.19495 17.7347C8.91804 17.7526 8.69771 17.8389 8.55608 18.0109C8.42276 18.1728 8.35645 18.3639 8.35645 18.5797C8.35645 18.8137 8.43209 19.0158 8.58558 19.1789L8.59 19.183C8.75499 19.3383 8.96316 19.4089 9.20551 19.3996L9.20847 19.3994ZM14.3075 23.007C12.7632 23.2518 11.0635 23.4331 9.20867 23.5512C8.9637 23.5698 8.75337 23.4982 8.58783 23.3326L8.58572 23.3305C8.433 23.1682 8.35645 22.9708 8.35645 22.7441C8.35645 22.521 8.42209 22.3253 8.55608 22.1626C8.69776 21.9906 8.91827 21.9043 9.19531 21.8864C11.0453 21.7687 12.7346 21.588 14.2635 21.3443C14.5555 21.2978 14.846 21.2499 15.135 21.2006C15.638 21.1148 16.1013 21.5003 16.1013 22.0121C16.1013 22.4073 15.8192 22.7472 15.4291 22.8163C15.0564 22.8823 14.6822 22.9459 14.3075 23.007Z"
fill="#000091"
/>
</svg>

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

@@ -1,7 +1,7 @@
<svg viewBox="0 0 32 33" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M21.6305 29.5812C22.7983 29.2538 23.9166 28.6562 24.6505 27.6003C25.3749 26.5663 25.5789 25.2547 25.5789 23.9925V5.50099C25.5789 5.17358 25.5611 4.84557 25.5216 4.52148C26.1016 4.74961 26.5486 5.12658 26.8626 5.65239C27.2331 6.25024 27.4184 7.03757 27.4184 8.01435V26.7964C27.4184 28.1184 27.0942 29.1078 26.4458 29.7646C25.7974 30.4214 24.8207 30.7498 23.5155 30.7498H16.4209C16.5889 30.7204 16.7574 30.6901 16.9262 30.659C18.4067 30.3944 19.9713 30.0354 21.6185 29.5846L21.6305 29.5812Z"
fill="var(--c--globals--colors--red-600)"
fill="var(--c--theme--colors--secondary-icon)"
/>
<path
fill-rule="evenodd"

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

@@ -14,7 +14,6 @@ export interface BoxProps {
as?: HTMLElementType;
$align?: CSSProperties['alignItems'];
$background?: CSSProperties['background'];
$border?: CSSProperties['border'];
$color?: CSSProperties['color'];
$css?: string | RuleSet<object>;
$cursor?: CSSProperties['cursor'];
@@ -37,34 +36,41 @@ export interface BoxProps {
$position?: CSSProperties['position'];
$radius?: CSSProperties['borderRadius'];
$shrink?: CSSProperties['flexShrink'];
$transition?: CSSProperties['transition'];
$width?: CSSProperties['width'];
$zIndex?: CSSProperties['zIndex'];
$wrap?: CSSProperties['flexWrap'];
// Theming props
$layer?: 'background' | 'content' | 'border';
$theme?:
| 'brand'
| 'error'
| 'gray'
| 'primary'
| 'primary-text'
| 'secondary'
| 'secondary-text'
| 'info'
| 'success'
| 'warning'
| 'neutral'
| 'contextual'
| 'disabled'
| (string & {});
$scope?: 'surface' | 'semantic' | 'palette' | (string & {});
$variation?: 'primary' | 'secondary' | 'tertiary' | (string & {});
$withThemeBG?: boolean;
$withThemeBorder?: boolean;
$withThemeInherited?: boolean;
| 'danger'
| 'greyscale';
$transition?: CSSProperties['transition'];
$variation?:
| 'text'
| '000'
| '100'
| '200'
| '300'
| '400'
| '500'
| '600'
| '700'
| '800'
| '900'
| '1000';
$width?: CSSProperties['width'];
$wrap?: CSSProperties['flexWrap'];
$zIndex?: CSSProperties['zIndex'];
}
export type BoxType = ComponentPropsWithRef<typeof Box>;
export const Box = styled('div')<BoxProps>`
${({ $align }) => $align && `align-items: ${$align};`}
${({ $background }) => $background && `background: ${$background};`}
${({ $color }) => $color && `color: ${$color};`}
${({ $cursor }) => $cursor && `cursor: ${$cursor};`}
${({ $direction }) => `flex-direction: ${$direction || 'column'};`}
${({ $display, as }) =>
@@ -74,9 +80,9 @@ export const Box = styled('div')<BoxProps>`
${({ $height }) => $height && `height: ${$height};`}
${({ $hasTransition }) =>
$hasTransition && $hasTransition === 'slow'
? `transition: all 0.5s var(--c--globals--transitions--ease-out);`
? `transition: all 0.5s ease-in-out;`
: $hasTransition
? `transition: all var(--c--globals--transitions--duration) var(--c--globals--transitions--ease-out);`
? `transition: all 0.3s ease-in-out;`
: ''}
${({ $justify }) => $justify && `justify-content: ${$justify};`}
${({ $margin }) => $margin && stylesMargin($margin)}
@@ -90,85 +96,11 @@ export const Box = styled('div')<BoxProps>`
${({ $position }) => $position && `position: ${$position};`}
${({ $radius }) => $radius && `border-radius: ${$radius};`}
${({ $shrink }) => $shrink && `flex-shrink: ${$shrink};`}
${({
$layer = 'border',
$theme = 'brand',
$variation = 'primary',
$scope = 'semantic',
$border,
$withThemeBorder,
$withThemeInherited,
}) => {
if ($border) {
return `border: ${$border};`;
}
if (!$layer || !$scope || !$theme || !$withThemeBorder) {
${({ $theme, $variation }) => {
if (!$theme || !$variation) {
return '';
}
if ($withThemeInherited) {
return `border: inherit;`;
}
return `border: 1px solid var(--c--contextuals--${$layer}--${$scope}${$theme ? `--${$theme}` : ''}${$variation ? `--${$variation}` : ''});`;
}}
${({
$layer = 'background',
$theme = 'brand',
$variation = 'primary',
$scope = 'semantic',
$background,
$withThemeBG,
$withThemeInherited,
}) => {
if ($background) {
return `background: ${$background};`;
}
if (!$layer || !$scope || !$theme || !$withThemeBG) {
return '';
}
if ($withThemeInherited) {
return `background: inherit;`;
}
return `background: var(--c--contextuals--${$layer}--${$scope}${$theme ? `--${$theme}` : ''}${$variation ? `--${$variation}` : ''});`;
}}
${({
$layer = 'content',
$theme = 'neutral',
$variation = 'primary',
$scope = 'semantic',
$color,
$withThemeBG,
$withThemeInherited,
}) => {
if ($color) {
return `color: ${$color};`;
}
if (!$layer || !$scope) {
return '';
}
// There is a special case when primary with background
if (
$withThemeBG &&
$layer === 'content' &&
$scope === 'semantic' &&
$variation === 'primary' &&
$theme
) {
$variation = `on-${$theme}`;
}
if ($withThemeInherited) {
return `color: inherit;`;
}
return `color: var(--c--contextuals--${$layer}--${$scope}${$theme ? `--${$theme}` : ''}${$variation ? `--${$variation}` : ''});`;
return `color: var(--c--theme--colors--${$theme}-${$variation});`;
}}
${({ $transition }) => $transition && `transition: ${$transition};`}
${({ $width }) => $width && `width: ${$width};`}
@@ -189,7 +121,7 @@ export const Box = styled('div')<BoxProps>`
return (
effect &&
`
transition: all var(--c--globals--transitions--duration) var(--c--globals--transitions--ease-out);
transition: all 0.3s ease-in-out;
${effect}
`
);
@@ -24,8 +24,8 @@ export type BoxButtonType = BoxType & {
*/
const BoxButton = forwardRef<HTMLDivElement, BoxButtonType>(
({ $css, ...props }, ref) => {
const theme = props.$theme || 'gray';
const variation = props.$variation || 'primary';
const theme = props.$theme || 'greyscale';
const variation = props.$variation || '400';
return (
<Box
@@ -40,14 +40,15 @@ const BoxButton = forwardRef<HTMLDivElement, BoxButtonType>(
border: none;
outline: none;
font-family: inherit;
color: ${props.disabled &&
`var(--c--contextuals--content--semantic--disabled--primary)`};
color: ${props.disabled
? `var(--c--theme--colors--${theme}-400) !important`
: `inherit`};
&:focus-visible {
transition: none;
outline: 2px solid
var(--c--contextuals--content--semantic--${theme}--${variation});
outline: 2px solid var(--c--theme--colors--${theme}-${variation});
border-radius: 1px;
outline-offset: var(--c--globals--spacings--st);
outline-offset: 4px;
}
${$css || ''}
`}
@@ -1,23 +1,27 @@
import { PropsWithChildren } from 'react';
import { css } from 'styled-components';
import { useCunninghamTheme } from '@/cunningham';
import { Box, BoxType } from '.';
export const Card = ({
children,
className,
$css,
...props
}: PropsWithChildren<BoxType>) => {
const { colorsTokens } = useCunninghamTheme();
return (
<Box
role="region"
$withThemeBG
$withThemeBorder
className={`--docs--card ${className || ''}`}
$radius="var(--c--globals--spacings--st)"
$padding={{ horizontal: 'xs', vertical: '3xs' }}
$scope={props.$theme ? props.$scope || 'semantic' : 'surface'}
$theme={props.$theme || 'primary'}
$variation={props.$theme ? props.$variation || 'tertiary' : ''}
$background="white"
$radius="4px"
$css={css`
border: 1px solid ${colorsTokens['greyscale-200']};
${$css}
`}
{...props}
>
{children}
@@ -14,11 +14,10 @@ import { BoxProps } from './Box';
const StyledPopover = styled(Popover)`
background-color: white;
border-radius: var(--c--globals--spacings--st);
box-shadow: 0 0 6px 0 rgba(0, 0, 145, 0.1);
border: 1px solid var(--c--contextuals--border--surface--primary);
transition: opacity var(--c--globals--transitions--duration)
var(--c--globals--transitions--ease-out);
border-radius: 4px;
box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.1);
border: 1px solid #dddddd;
transition: opacity 0.2s ease-in-out;
`;
interface StyledButtonProps {
@@ -29,24 +28,19 @@ const StyledButton = styled(Button)<StyledButtonProps>`
border: none;
background: none;
outline: none;
font-weight: var(--c--components--button--font-weight);
font-size: var(--c--components--button--medium-font-size);
padding: var(--c--globals--spacings--0);
border-radius: var(--c--globals--spacings--st);
color: var(--c--contextuals--content--semantic--brand--tertiary);
font-weight: 500;
font-size: 0.938rem;
padding: 0;
${({ $css }) => $css};
&:hover {
background-color: var(
--c--contextuals--background--semantic--contextual--primary
--c--components--button--primary-text--background--color-hover
);
}
&:focus-visible {
box-shadow: 0 0 0 2px var(--c--globals--colors--brand-400);
background-color: var(
--c--contextuals--background--semantic--brand--tertiary-hover
);
border-radius: var(--c--globals--spacings--st);
box-shadow: 0 0 0 2px var(--c--theme--colors--primary-400);
border-radius: 4px;
}
${({ $css }) => $css};
`;
export interface DropButtonProps {
@@ -13,7 +13,7 @@ export const Icon = ({
iconName,
disabled,
variant = 'outlined',
$theme = 'neutral',
$variation,
...textProps
}: IconProps) => {
const hasLabel = 'aria-label' in textProps || 'aria-labelledby' in textProps;
@@ -28,7 +28,7 @@ export const Icon = ({
'material-icons': variant === 'outlined',
'material-symbols-outlined': variant === 'symbols-outlined',
})}
$theme={disabled ? 'disabled' : $theme}
$variation={disabled ? '300' : $variation}
aria-disabled={disabled}
{...textProps}
>
@@ -41,19 +41,15 @@ type IconOptionsProps = TextType & {
isHorizontal?: boolean;
};
export const IconOptions = ({
isHorizontal,
$css,
...props
}: IconOptionsProps) => {
export const IconOptions = ({ isHorizontal, ...props }: IconOptionsProps) => {
return (
<Icon
{...props}
iconName={isHorizontal ? 'more_horiz' : 'more_vert'}
$css={css`
user-select: none;
${$css}
${props.$css}
`}
{...props}
/>
);
};
@@ -39,8 +39,7 @@ export const InfiniteScroll = ({
{!isLoading && hasMore && (
<Button
onClick={() => void next()}
color="brand"
variant="bordered"
color="primary-text"
icon={<Icon iconName="arrow_downward" />}
>
{buttonLabel ?? t('Load more')}

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