Compare commits

..

21 Commits

Author SHA1 Message Date
Fabre Florian e1c527432a (backend) use batches in indexing task
Reduce the number of Find API calls by grouping all the latest changes
for indexation : send all the documents updated or deleted since the
triggering of the task.

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-11-13 10:07:39 +01:00
Fabre Florian e9fdc43b48 WIP 💩(front) hack to use the fulltext search api 2025-11-13 10:03:29 +01:00
Fabre Florian 52ebc6b6fb 📝(backend) add fulltext search documentation
Add documentation for env & Find+Docs configuration in dev mode

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-11-13 10:03:28 +01:00
Fabre Florian 59d33e0f29 🔧(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-13 10:03:28 +01:00
Fabre Florian 3973adcd8b (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-13 10:03:28 +01:00
Fabre Florian b6de0645c7 (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 prevents any other task creation.

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-11-13 10:03:27 +01:00
Fabre Florian 14585d3493 (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-13 10:03:27 +01:00
Fabre Florian 1653b631cc 🔧(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-13 10:03:27 +01:00
Fabre Florian 2f58e48297 🔧(backend) tool for valid fernet key used in OIDC token storage
Add bin/fernetkey that generates a key for the OIDC_STORE_REFRESH_TOKEN_KEY
setting.

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-11-13 10:03:26 +01:00
Fabre Florian 3612daa0af (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-13 10:03:26 +01:00
Fabre Florian 6d2334f8cd (backend) Index partially empty documents
Only documents without title and content are ignored by indexer.
2025-11-13 10:03:26 +01:00
Fabre Florian eea783d733 (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-13 10:03:25 +01:00
Fabre Florian 4831b5e83d (backend) refactor indexation signals and fix circular import issues
Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-11-13 10:03:25 +01:00
Fabre Florian 4f0f9519d6 (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-13 10:03:25 +01:00
Fabre Florian 7a84362457 (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-13 10:03:24 +01:00
Fabre Florian 460d8e21f6 (backend) add unit test for the 'index' command
Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-11-13 10:03:24 +01:00
Fabre Florian bcf38d7ee4 🔧(compose) Add some ignore for docker-compose local overrides
Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-11-13 10:03:24 +01:00
Samuel Paccoud - DINUM fe5c51ed54 (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-13 10:03:23 +01:00
Samuel Paccoud - DINUM 145f840ca0 (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-13 10:03:23 +01:00
Samuel Paccoud - DINUM 96f81d6539 (backend) add dummy content to demo documents
We need to content in our demo documents so that we can test
indexing.
2025-11-13 10:03:23 +01:00
Samuel Paccoud - DINUM 16530fbde4 🔧(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-13 10:03:22 +01:00
300 changed files with 11186 additions and 23685 deletions
@@ -1,24 +0,0 @@
name: 'Free Disk Space'
description: 'Free up disk space by removing large preinstalled items and cleaning up Docker'
runs:
using: "composite"
steps:
- name: Free disk space (Linux only)
if: runner.os == 'Linux'
shell: bash
run: |
echo "Disk usage before cleanup:"
df -h
# Remove large preinstalled items that are not used on GitHub-hosted runners
sudo rm -rf /usr/share/dotnet || true
sudo rm -rf /opt/ghc || true
sudo rm -rf /usr/local/lib/android || true
# Clean up Docker
docker system prune -af || true
docker volume prune -f || true
echo "Disk usage after cleanup:"
df -h
-18
View File
@@ -52,12 +52,6 @@ jobs:
push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
-
name: Cleanup Docker after build
if: always()
run: |
docker system prune -af
docker volume prune -f
build-and-push-frontend:
runs-on: ubuntu-latest
@@ -97,12 +91,6 @@ jobs:
push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
-
name: Cleanup Docker after build
if: always()
run: |
docker system prune -af
docker volume prune -f
build-and-push-y-provider:
runs-on: ubuntu-latest
@@ -137,12 +125,6 @@ jobs:
push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
-
name: Cleanup Docker after build
if: always()
run: |
docker system prune -af
docker volume prune -f
notify-argocd:
needs:
+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
-6
View File
@@ -85,9 +85,6 @@ jobs:
- name: Install Playwright Browsers
run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright chromium
- name: Free disk space before Docker
uses: ./.github/actions/free-disk-space
- name: Start Docker services
run: make bootstrap-e2e FLUSH_ARGS='--no-input'
@@ -127,9 +124,6 @@ jobs:
- name: Install Playwright Browsers
run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright firefox webkit chromium
- name: Free disk space before Docker
uses: ./.github/actions/free-disk-space
- name: Start Docker services
run: make bootstrap-e2e FLUSH_ARGS='--no-input'
+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:
+8 -118
View File
@@ -6,124 +6,14 @@ and this project adheres to
## [Unreleased]
### Added
- ✨(helm) redirecting system #1697
- 📱(frontend) add comments for smaller device #1737
- ✨(project) add custom js support via config #1759
### Changed
- 🥅(frontend) intercept 401 error on GET threads #1754
- 🦺(frontend) check content type pdf on PdfBlock #1756
- ✈️(frontend) pause Posthog when offline #1755
### Fixed
- 🐛(frontend) fix tables deletion #1752
- 🐛(frontend) fix children not display when first resize #1753
- 📝(doc) fix publiccode.yml syntax #1770
## [4.2.0] - 2025-12-17
### Added
- ✨(backend) allow to create a new user in a marketing system #1707
- ✨(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
- 🥅(frontend) add boundary error page #1728
### Changed
- 🛂(backend) stop throttling collaboration servers #1730
- 🚸(backend) use unaccented full name for user search #1637
- 🌐(backend) internationalize demo #1644
- ♿(frontend) improve accessibility:
- ♿️Improve keyboard accessibility for the document tree #1681
### Fixed
- 🐛(frontend) paste content with comments from another document #1732
- 🐛(frontend) Select text + Go back one page crash the app #1733
- 🐛(frontend) fix versioning conflict #1742
## [4.1.0] - 2025-12-09
### Added
- ⚡️(frontend) export html #1669
### Changed
- ♿(frontend) improve accessibility:
- ♿(frontend) add skip to content button for keyboard accessibility #1624
- ♿(frontend) fix toggle panel button a11y labels #1634
- 🔒️(frontend) remove dangerouslySetInnerHTML from codebase #1712
- ⚡️(frontend) improve Comments feature #1687
### Fixed
- 🐛(nginx) fix / location to handle new static pages #1682
- 🐛(frontend) rerendering during resize window #1715
## [4.0.0] - 2025-12-01
### Added
- ✨ Add comments feature to the editor #1330
- ✨(backend) Comments on text editor #1330
- ✨(frontend) link to create new doc #1574
### Changed
- ⚡️(sw) stop to cache external resources likes videos #1655
- 💥(frontend) upgrade to ui-kit v2 #1605
- ⚡️(frontend) improve perf on upload and table of contents #1662
- ♿(frontend) improve accessibility:
- ♿(frontend) improve share modal button accessibility #1626
- ♿(frontend) improve screen reader support in DocShare modal #1628
### Fixed
- 🐛(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
### 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
- 🐛(docx) fix image overflow by limiting width to 600px during export #1525
- 🐛(frontend) preserve @ character when esc is pressed after typing it #1512
- 🐛(frontend) fix pdf embed to use full width #1526
## [3.9.0] - 2025-11-10
@@ -143,6 +33,7 @@ and this project adheres to
- 🐛(frontend) fix duplicate document entries in grid #1479
- 🐛(backend) fix trashbin list #1520
- ♿(frontend) improve accessibility:
- ♿(frontend) remove empty alt on logo due to Axe a11y error #1516
- 🐛(backend) fix s3 version_id validation #1543
@@ -200,6 +91,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
@@ -962,11 +856,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/v4.2.0...main
[v4.2.0]: https://github.com/suitenumerique/docs/releases/v4.2.0
[v4.1.0]: https://github.com/suitenumerique/docs/releases/v4.1.0
[v4.0.0]: https://github.com/suitenumerique/docs/releases/v4.0.0
[v3.10.0]: https://github.com/suitenumerique/docs/releases/v3.10.0
[unreleased]: https://github.com/suitenumerique/docs/compare/v3.9.0...main
[v3.9.0]: https://github.com/suitenumerique/docs/releases/v3.9.0
[v3.8.2]: https://github.com/suitenumerique/docs/releases/v3.8.2
[v3.8.1]: https://github.com/suitenumerique/docs/releases/v3.8.1
-23
View File
@@ -16,29 +16,6 @@ the following command inside your docker container:
## [Unreleased]
## [4.0.0] - 2025-11-26
- ⚠️ We updated `@gouvfr-lasuite/ui-kit` to `0.18.0`, so if you are customizing Docs with a css layer or with a custom template, you need to update your customization to follow the new design system structure.
More information about the changes in the design system can be found here:
- https://suitenumerique.github.io/cunningham/storybook/?path=/docs/migrating-from-v3-to-v4--docs
- https://github.com/suitenumerique/docs/pull/1605
- https://github.com/suitenumerique/docs/blob/main/docs/theming.md
- If you were using the `THEME_CUSTOMIZATION_FILE_PATH` and have overridden the header logo, you need to update your customization file to follow the new structure of the header, it is now:
```json
{
...,
"header": {
"icon": {
"src": "your_logo_src",
"width": "your_logo_width",
"height": "your_logo_height"
}
}
}
```
## [3.3.0] - 2025-05-22
⚠️ For some advanced features (ex: Export as PDF) Docs relies on XL packages from BlockNote. These are licenced under AGPL-3.0 and are not MIT compatible. You can perfectly use Docs without these packages by setting the environment variable `PUBLISH_AS_MIT` to true. That way you'll build an image of the application without the features that are not MIT compatible. Read the [environment variables documentation](/docs/env.md) for more information.
+103 -107
View File
@@ -6,113 +6,109 @@ Here we describe all environment variables that can be set for the docs applicat
These are the environment variables you can set for the `impress-backend` container.
| Option | Description | default |
|-------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------|
| AI_ALLOW_REACH_FROM | Users that can use AI must be this level. options are "public", "authenticated", "restricted" | authenticated |
| AI_API_KEY | AI key to be used for AI Base url | |
| AI_BASE_URL | OpenAI compatible AI base url | |
| AI_FEATURE_ENABLED | Enable AI options | false |
| AI_MODEL | AI Model to use | |
| ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true |
| API_USERS_LIST_LIMIT | Limit on API users | 5 |
| API_USERS_LIST_THROTTLE_RATE_BURST | Throttle rate for api on burst | 30/minute |
| API_USERS_LIST_THROTTLE_RATE_SUSTAINED | Throttle rate for api | 180/hour |
| AWS_S3_ACCESS_KEY_ID | Access id for s3 endpoint | |
| AWS_S3_ENDPOINT_URL | S3 endpoint | |
| AWS_S3_REGION_NAME | Region name for s3 endpoint | |
| AWS_S3_SECRET_ACCESS_KEY | Access key for s3 endpoint | |
| AWS_STORAGE_BUCKET_NAME | Bucket name for s3 endpoint | impress-media-storage |
| CACHES_DEFAULT_TIMEOUT | Cache default timeout | 30 |
| CACHES_KEY_PREFIX | The prefix used to every cache keys. | docs |
| COLLABORATION_API_URL | Collaboration api host | |
| COLLABORATION_SERVER_SECRET | Collaboration api secret | |
| COLLABORATION_WS_NOT_CONNECTED_READY_ONLY | Users not connected to the collaboration server cannot edit | false |
| COLLABORATION_WS_URL | Collaboration websocket url | |
| CONVERSION_API_CONTENT_FIELD | Conversion api content field | content |
| CONVERSION_API_ENDPOINT | Conversion API endpoint | convert |
| CONVERSION_API_SECURE | Require secure conversion api | false |
| CONVERSION_API_TIMEOUT | Conversion api timeout | 30 |
| CRISP_WEBSITE_ID | Crisp website id for support | |
| DB_ENGINE | Engine to use for database connections | django.db.backends.postgresql_psycopg2 |
| DB_HOST | Host of the database | localhost |
| DB_NAME | Name of the database | impress |
| DB_PASSWORD | Password to authenticate with | pass |
| DB_PORT | Port of the database | 5432 |
| DB_USER | User to authenticate with | dinum |
| DJANGO_ALLOWED_HOSTS | Allowed hosts | [] |
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker transport options | {} |
| DJANGO_CELERY_BROKER_URL | Celery broker url | redis://redis:6379/0 |
| DJANGO_CORS_ALLOWED_ORIGINS | List of origins allowed for CORS | [] |
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | List of origins allowed for CORS using regulair expressions | [] |
| DJANGO_CORS_ALLOW_ALL_ORIGINS | Allow all CORS origins | false |
| DJANGO_CSRF_TRUSTED_ORIGINS | CSRF trusted origins | [] |
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
| DJANGO_EMAIL_BRAND_NAME | Brand name for email | |
| DJANGO_EMAIL_FROM | Email address used as sender | from@example.com |
| DJANGO_EMAIL_HOST | Hostname of email | |
| DJANGO_EMAIL_HOST_PASSWORD | Password to authenticate with on the email host | |
| DJANGO_EMAIL_HOST_USER | User to authenticate with on the email host | |
| DJANGO_EMAIL_LOGO_IMG | Logo for the email | |
| DJANGO_EMAIL_PORT | Port used to connect to email host | |
| DJANGO_EMAIL_USE_SSL | Use ssl for email host connection | false |
| DJANGO_EMAIL_USE_TLS | Use tls for email host connection | false |
| DJANGO_SECRET_KEY | Secret key | |
| DJANGO_SERVER_TO_SERVER_API_TOKENS | | [] |
| DOCUMENT_IMAGE_MAX_SIZE | Maximum size of document in bytes | 10485760 |
| FRONTEND_CSS_URL | To add a external css file to the app | |
| FRONTEND_JS_URL | To add a external js file to the app | |
| FRONTEND_HOMEPAGE_FEATURE_ENABLED | Frontend feature flag to display the homepage | false |
| FRONTEND_THEME | Frontend theme to use | |
| LANGUAGE_CODE | Default language | en-us |
| LASUITE_MARKETING_BACKEND | Backend used when SIGNUP_NEW_USER_TO_MARKETING_EMAIL is True. See https://github.com/suitenumerique/django-lasuite/blob/main/documentation/how-to-use-marketing-backend.md | lasuite.marketing.backends.dummy.DummyBackend |
| LASUITE_MARKETING_PARAMETERS | The parameters to configure LASUITE_MARKETING_BACKEND. See https://github.com/suitenumerique/django-lasuite/blob/main/documentation/how-to-use-marketing-backend.md | {} |
| LOGGING_LEVEL_LOGGERS_APP | Application logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
| LOGGING_LEVEL_LOGGERS_ROOT | Default logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
| LOGIN_REDIRECT_URL | Login redirect url | |
| LOGIN_REDIRECT_URL_FAILURE | Login redirect url on failure | |
| LOGOUT_REDIRECT_URL | Logout redirect url | |
| MALWARE_DETECTION_BACKEND | The malware detection backend use from the django-lasuite package | lasuite.malware_detection.backends.dummy.DummyBackend |
| MALWARE_DETECTION_PARAMETERS | A dict containing all the parameters to initiate the malware detection backend | {"callback_path": "core.malware_detection.malware_detection_callback",} |
| MEDIA_BASE_URL | | |
| NO_WEBSOCKET_CACHE_TIMEOUT | Cache used to store current editor session key when only users without websocket are editing a document | 120 |
| OIDC_ALLOW_DUPLICATE_EMAILS | Allow duplicate emails | false |
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth parameters | {} |
| OIDC_CREATE_USER | Create used on OIDC | false |
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | true |
| OIDC_OP_AUTHORIZATION_ENDPOINT | Authorization endpoint for OIDC | |
| OIDC_OP_JWKS_ENDPOINT | JWKS endpoint for OIDC | |
| OIDC_OP_LOGOUT_ENDPOINT | Logout endpoint for OIDC | |
| OIDC_OP_TOKEN_ENDPOINT | Token endpoint for OIDC | |
| OIDC_OP_USER_ENDPOINT | User endpoint for OIDC | |
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed hosts for OIDC redirect url | [] |
| OIDC_REDIRECT_REQUIRE_HTTPS | Require https for OIDC redirect url | false |
| OIDC_RP_CLIENT_ID | Client id used for OIDC | impress |
| OIDC_RP_CLIENT_SECRET | Client secret used for OIDC | |
| OIDC_RP_SCOPES | Scopes requested for OIDC | openid email |
| OIDC_RP_SIGN_ALGO | verification algorithm used OIDC tokens | RS256 |
| OIDC_STORE_ID_TOKEN | Store OIDC token | true |
| OIDC_USERINFO_FULLNAME_FIELDS | OIDC token claims to create full name | ["first_name", "last_name"] |
| OIDC_USERINFO_SHORTNAME_FIELD | OIDC token claims to create shortname | first_name |
| OIDC_USE_NONCE | Use nonce for OIDC | true |
| POSTHOG_KEY | Posthog key for analytics | |
| REDIS_URL | Cache url | redis://redis:6379/1 |
| SEARCH_INDEXER_BATCH_SIZE | Size of each batch for indexation of all documents | 100000 |
| SEARCH_INDEXER_CLASS | Class of the backend for document indexation & search | |
| SEARCH_INDEXER_COUNTDOWN | Minimum debounce delay of indexation jobs (in seconds) | 1 |
| SEARCH_INDEXER_QUERY_LIMIT | Maximum number of results expected from search endpoint | 50 |
| SEARCH_INDEXER_SECRET | Token for indexation queries | |
| SEARCH_INDEXER_URL | Find application endpoint for indexation | |
| SENTRY_DSN | Sentry host | |
| SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 |
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | Register new user to the marketing onboarding. If True, see env LASUITE_MARKETING_* system | False |
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | | false |
| STORAGES_STATICFILES_BACKEND | | whitenoise.storage.CompressedManifestStaticFilesStorage |
| THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 |
| THEME_CUSTOMIZATION_FILE_PATH | Full path to the file customizing the theme. An example is provided in src/backend/impress/configuration/theme/default.json | BASE_DIR/impress/configuration/theme/default.json |
| TRASHBIN_CUTOFF_DAYS | Trashbin cutoff | 30 |
| USER_OIDC_ESSENTIAL_CLAIMS | Essential claims in OIDC token | [] |
| Y_PROVIDER_API_BASE_URL | Y Provider url | |
| Y_PROVIDER_API_KEY | Y provider API key | |
| Option | Description | default |
|-------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------|
| AI_ALLOW_REACH_FROM | Users that can use AI must be this level. options are "public", "authenticated", "restricted" | authenticated |
| AI_API_KEY | AI key to be used for AI Base url | |
| AI_BASE_URL | OpenAI compatible AI base url | |
| AI_FEATURE_ENABLED | Enable AI options | false |
| AI_MODEL | AI Model to use | |
| ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true |
| API_USERS_LIST_LIMIT | Limit on API users | 5 |
| API_USERS_LIST_THROTTLE_RATE_BURST | Throttle rate for api on burst | 30/minute |
| API_USERS_LIST_THROTTLE_RATE_SUSTAINED | Throttle rate for api | 180/hour |
| AWS_S3_ACCESS_KEY_ID | Access id for s3 endpoint | |
| AWS_S3_ENDPOINT_URL | S3 endpoint | |
| AWS_S3_REGION_NAME | Region name for s3 endpoint | |
| AWS_S3_SECRET_ACCESS_KEY | Access key for s3 endpoint | |
| AWS_STORAGE_BUCKET_NAME | Bucket name for s3 endpoint | impress-media-storage |
| CACHES_DEFAULT_TIMEOUT | Cache default timeout | 30 |
| CACHES_KEY_PREFIX | The prefix used to every cache keys. | docs |
| COLLABORATION_API_URL | Collaboration api host | |
| COLLABORATION_SERVER_SECRET | Collaboration api secret | |
| COLLABORATION_WS_NOT_CONNECTED_READY_ONLY | Users not connected to the collaboration server cannot edit | false |
| COLLABORATION_WS_URL | Collaboration websocket url | |
| CONVERSION_API_CONTENT_FIELD | Conversion api content field | content |
| CONVERSION_API_ENDPOINT | Conversion API endpoint | convert |
| CONVERSION_API_SECURE | Require secure conversion api | false |
| CONVERSION_API_TIMEOUT | Conversion api timeout | 30 |
| CRISP_WEBSITE_ID | Crisp website id for support | |
| DB_ENGINE | Engine to use for database connections | django.db.backends.postgresql_psycopg2 |
| DB_HOST | Host of the database | localhost |
| DB_NAME | Name of the database | impress |
| DB_PASSWORD | Password to authenticate with | pass |
| DB_PORT | Port of the database | 5432 |
| DB_USER | User to authenticate with | dinum |
| DJANGO_ALLOWED_HOSTS | Allowed hosts | [] |
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker transport options | {} |
| DJANGO_CELERY_BROKER_URL | Celery broker url | redis://redis:6379/0 |
| DJANGO_CORS_ALLOW_ALL_ORIGINS | Allow all CORS origins | false |
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | List of origins allowed for CORS using regulair expressions | [] |
| DJANGO_CORS_ALLOWED_ORIGINS | List of origins allowed for CORS | [] |
| DJANGO_CSRF_TRUSTED_ORIGINS | CSRF trusted origins | [] |
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
| DJANGO_EMAIL_BRAND_NAME | Brand name for email | |
| DJANGO_EMAIL_FROM | Email address used as sender | from@example.com |
| DJANGO_EMAIL_HOST | Hostname of email | |
| DJANGO_EMAIL_HOST_PASSWORD | Password to authenticate with on the email host | |
| DJANGO_EMAIL_HOST_USER | User to authenticate with on the email host | |
| DJANGO_EMAIL_LOGO_IMG | Logo for the email | |
| DJANGO_EMAIL_PORT | Port used to connect to email host | |
| DJANGO_EMAIL_USE_SSL | Use ssl for email host connection | false |
| DJANGO_EMAIL_USE_TLS | Use tls for email host connection | false |
| DJANGO_SECRET_KEY | Secret key | |
| DJANGO_SERVER_TO_SERVER_API_TOKENS | | [] |
| DOCUMENT_IMAGE_MAX_SIZE | Maximum size of document in bytes | 10485760 |
| FRONTEND_CSS_URL | To add a external css file to the app | |
| FRONTEND_HOMEPAGE_FEATURE_ENABLED | Frontend feature flag to display the homepage | false |
| FRONTEND_THEME | Frontend theme to use | |
| LANGUAGE_CODE | Default language | en-us |
| LOGGING_LEVEL_LOGGERS_APP | Application logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
| LOGGING_LEVEL_LOGGERS_ROOT | Default logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
| LOGIN_REDIRECT_URL | Login redirect url | |
| LOGIN_REDIRECT_URL_FAILURE | Login redirect url on failure | |
| LOGOUT_REDIRECT_URL | Logout redirect url | |
| MALWARE_DETECTION_BACKEND | The malware detection backend use from the django-lasuite package | lasuite.malware_detection.backends.dummy.DummyBackend |
| MALWARE_DETECTION_PARAMETERS | A dict containing all the parameters to initiate the malware detection backend | {"callback_path": "core.malware_detection.malware_detection_callback",} |
| MEDIA_BASE_URL | | |
| NO_WEBSOCKET_CACHE_TIMEOUT | Cache used to store current editor session key when only users without websocket are editing a document | 120 |
| OIDC_ALLOW_DUPLICATE_EMAILS | Allow duplicate emails | false |
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth parameters | {} |
| OIDC_CREATE_USER | Create used on OIDC | false |
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | true |
| OIDC_OP_AUTHORIZATION_ENDPOINT | Authorization endpoint for OIDC | |
| OIDC_OP_JWKS_ENDPOINT | JWKS endpoint for OIDC | |
| OIDC_OP_LOGOUT_ENDPOINT | Logout endpoint for OIDC | |
| OIDC_OP_TOKEN_ENDPOINT | Token endpoint for OIDC | |
| OIDC_OP_USER_ENDPOINT | User endpoint for OIDC | |
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed hosts for OIDC redirect url | [] |
| OIDC_REDIRECT_REQUIRE_HTTPS | Require https for OIDC redirect url | false |
| OIDC_RP_CLIENT_ID | Client id used for OIDC | impress |
| OIDC_RP_CLIENT_SECRET | Client secret used for OIDC | |
| OIDC_RP_SCOPES | Scopes requested for OIDC | openid email |
| OIDC_RP_SIGN_ALGO | verification algorithm used OIDC tokens | RS256 |
| OIDC_STORE_ID_TOKEN | Store OIDC token | true |
| OIDC_USE_NONCE | Use nonce for OIDC | true |
| OIDC_USERINFO_FULLNAME_FIELDS | OIDC token claims to create full name | ["first_name", "last_name"] |
| 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 |
| STORAGES_STATICFILES_BACKEND | | whitenoise.storage.CompressedManifestStaticFilesStorage |
| THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 |
| THEME_CUSTOMIZATION_FILE_PATH | Full path to the file customizing the theme. An example is provided in src/backend/impress/configuration/theme/default.json | BASE_DIR/impress/configuration/theme/default.json |
| TRASHBIN_CUTOFF_DAYS | Trashbin cutoff | 30 |
| USER_OIDC_ESSENTIAL_CLAIMS | Essential claims in OIDC token | [] |
| Y_PROVIDER_API_BASE_URL | Y Provider url | |
| Y_PROVIDER_API_KEY | Y provider API key | |
## impress-frontend image
+82 -85
View File
@@ -1,12 +1,3 @@
djangoSecretKey: &djangoSecretKey "lkjsdlfkjsldkfjslkdfjslkdjfslkdjf"
djangoSuperUserEmail: admin@example.com
djangoSuperUserPass: admin
aiApiKey: changeme
aiBaseUrl: changeme
oidc:
clientId: impress
clientSecret: ThisIsAnExampleKeyForDevPurposeOnly
image:
repository: lasuite/impress-backend
pullPolicy: Always
@@ -15,98 +6,86 @@ image:
backend:
replicas: 1
envVars:
COLLABORATION_API_URL: https://impress.127.0.0.1.nip.io/collaboration/api/
COLLABORATION_SERVER_SECRET: my-secret
DJANGO_CSRF_TRUSTED_ORIGINS: https://docs.127.0.0.1.nip.io
DJANGO_CSRF_TRUSTED_ORIGINS: https://impress.127.0.0.1.nip.io
DJANGO_CONFIGURATION: Feature
DJANGO_ALLOWED_HOSTS: docs.127.0.0.1.nip.io
DJANGO_ALLOWED_HOSTS: impress.127.0.0.1.nip.io
DJANGO_SERVER_TO_SERVER_API_TOKENS: secret-api-key
DJANGO_SECRET_KEY: *djangoSecretKey
DJANGO_SECRET_KEY: AgoodOrAbadKey
DJANGO_SETTINGS_MODULE: impress.settings
DJANGO_SUPERUSER_PASSWORD: admin
DJANGO_EMAIL_BRAND_NAME: "La Suite Numérique"
DJANGO_EMAIL_HOST: "mailcatcher"
DJANGO_EMAIL_LOGO_IMG: https://docs.127.0.0.1.nip.io/assets/logo-suite-numerique.png
DJANGO_EMAIL_LOGO_IMG: https://impress.127.0.0.1.nip.io/assets/logo-suite-numerique.png
DJANGO_EMAIL_PORT: 1025
DJANGO_EMAIL_USE_SSL: False
LOGGING_LEVEL_HANDLERS_CONSOLE: ERROR
LOGGING_LEVEL_LOGGERS_ROOT: INFO
LOGGING_LEVEL_LOGGERS_APP: INFO
OIDC_USERINFO_SHORTNAME_FIELD: "given_name"
OIDC_USERINFO_FULLNAME_FIELDS: "given_name,usual_name"
OIDC_OP_JWKS_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/docs/protocol/openid-connect/certs
OIDC_OP_AUTHORIZATION_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/docs/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/docs/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/docs/protocol/openid-connect/userinfo
OIDC_OP_LOGOUT_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/docs/protocol/openid-connect/logout
OIDC_RP_CLIENT_ID: docs
OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/certs
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/userinfo
OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/logout
OIDC_RP_CLIENT_ID: impress
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
OIDC_RP_SIGN_ALGO: RS256
OIDC_RP_SCOPES: "openid email"
LOGIN_REDIRECT_URL: https://docs.127.0.0.1.nip.io
LOGIN_REDIRECT_URL_FAILURE: https://docs.127.0.0.1.nip.io
LOGOUT_REDIRECT_URL: https://docs.127.0.0.1.nip.io
DB_HOST: postgresql-dev-backend-postgres
DB_NAME:
secretKeyRef:
name: postgresql-dev-backend-postgres
key: database
DB_USER:
secretKeyRef:
name: postgresql-dev-backend-postgres
key: username
DB_PASSWORD:
secretKeyRef:
name: postgresql-dev-backend-postgres
key: password
OIDC_VERIFY_SSL: False
OIDC_USERINFO_SHORTNAME_FIELD: "given_name"
OIDC_USERINFO_FULLNAME_FIELDS: "given_name,usual_name"
OIDC_REDIRECT_ALLOWED_HOSTS: https://impress.127.0.0.1.nip.io
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
LOGIN_REDIRECT_URL: https://impress.127.0.0.1.nip.io
LOGIN_REDIRECT_URL_FAILURE: https://impress.127.0.0.1.nip.io
LOGOUT_REDIRECT_URL: https://impress.127.0.0.1.nip.io
POSTHOG_KEY: "{'id': 'posthog_key', 'host': 'https://product.impress.127.0.0.1.nip.io'}"
DB_HOST: postgresql
DB_NAME: impress
DB_USER: dinum
DB_PASSWORD: pass
DB_PORT: 5432
REDIS_URL: redis://user:pass@redis-dev-backend-redis:6379/1
DJANGO_CELERY_BROKER_URL: redis://user:pass@redis-dev-backend-redis:6379/1
AWS_S3_ENDPOINT_URL: http://minio-dev-backend-minio.impress.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: dinum
REDIS_URL: redis://default:pass@redis-master:6379/1
AWS_S3_ENDPOINT_URL: http://minio.impress.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: root
AWS_S3_SECRET_ACCESS_KEY: password
AWS_STORAGE_BUCKET_NAME: docs-media-storage
AWS_STORAGE_BUCKET_NAME: impress-media-storage
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
Y_PROVIDER_API_BASE_URL: http://impress-y-provider:443/api/
Y_PROVIDER_API_KEY: my-secret
CACHES_KEY_PREFIX: "{{ now | unixEpoch }}"
migrate:
command:
- "/bin/sh"
- "-c"
- |
while ! python manage.py check --database default > /dev/null 2>&1
do
echo "Database not ready"
sleep 2
done
echo "Database is ready"
python manage.py migrate --no-input
python manage.py migrate --no-input &&
python manage.py create_demo --force
restartPolicy: Never
command:
- "gunicorn"
- "-c"
- "/usr/local/etc/gunicorn/impress.py"
- "impress.wsgi:application"
- "--reload"
createsuperuser:
command:
- "/bin/sh"
- "-c"
- |
while ! python manage.py check --database default > /dev/null 2>&1
do
echo "Database not ready"
sleep 2
done
echo "Database is ready"
python manage.py createsuperuser --email admin@example.com --password admin
restartPolicy: Never
# Extra volume mounts to manage our local custom CA and avoid to set ssl_verify: false
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
extraVolumeMounts:
- name: certs
mountPath: /cert/cacert.pem
subPath: cacert.pem
# Extra volumes to manage our local custom CA and avoid to set ssl_verify: false
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
extraVolumes:
- name: certs
configMap:
@@ -115,7 +94,12 @@ backend:
- key: cacert.pem
path: cacert.pem
frontend:
envVars:
PORT: 8080
NEXT_PUBLIC_API_ORIGIN: https://impress.127.0.0.1.nip.io
replicas: 1
image:
repository: lasuite/impress-frontend
pullPolicy: Always
@@ -130,47 +114,60 @@ yProvider:
tag: "latest"
envVars:
COLLABORATION_BACKEND_BASE_URL: https://docs.127.0.0.1.nip.io
COLLABORATION_LOGGING: true
COLLABORATION_SERVER_ORIGIN: https://docs.127.0.0.1.nip.io
COLLABORATION_SERVER_ORIGIN: https://impress.127.0.0.1.nip.io
COLLABORATION_SERVER_SECRET: my-secret
Y_PROVIDER_API_KEY: my-secret
COLLABORATION_BACKEND_BASE_URL: https://impress.127.0.0.1.nip.io
NODE_EXTRA_CA_CERTS: /usr/local/share/ca-certificates/cacert.pem
ingress:
enabled: true
host: docs.127.0.0.1.nip.io
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: 100m
# Mount the certificate so yProvider can establish tls with the backend
extraVolumeMounts:
- name: certs
mountPath: /usr/local/share/ca-certificates/cacert.pem
subPath: cacert.pem
ingressCollaborationWS:
enabled: true
host: docs.127.0.0.1.nip.io
ingressCollaborationApi:
enabled: true
host: docs.127.0.0.1.nip.io
ingressAdmin:
enabled: true
host: docs.127.0.0.1.nip.io
extraVolumes:
- name: certs
configMap:
name: certifi
items:
- key: cacert.pem
path: cacert.pem
posthog:
ingress:
enabled: false
ingressAssets:
enabled: false
ingress:
enabled: true
host: impress.127.0.0.1.nip.io
ingressCollaborationWS:
enabled: true
host: impress.127.0.0.1.nip.io
ingressCollaborationApi:
enabled: true
host: impress.127.0.0.1.nip.io
ingressAdmin:
enabled: true
host: impress.127.0.0.1.nip.io
ingressMedia:
enabled: true
host: docs.127.0.0.1.nip.io
host: impress.127.0.0.1.nip.io
annotations:
nginx.ingress.kubernetes.io/auth-url: https://docs.127.0.0.1.nip.io/api/v1.0/documents/media-auth/
nginx.ingress.kubernetes.io/auth-url: https://impress.127.0.0.1.nip.io/api/v1.0/documents/media-auth/
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
nginx.ingress.kubernetes.io/upstream-vhost: minio-dev-backend-minio.impress.svc.cluster.local:9000
nginx.ingress.kubernetes.io/rewrite-target: /docs-media-storage/$1
nginx.ingress.kubernetes.io/upstream-vhost: minio.impress.svc.cluster.local:9000
nginx.ingress.kubernetes.io/rewrite-target: /impress-media-storage/$1
serviceMedia:
host: minio-dev-backend-minio.impress.svc.cluster.local
host: minio.impress.svc.cluster.local
port: 9000
File diff suppressed because it is too large Load Diff
+7 -23
View File
@@ -1,24 +1,8 @@
minio:
auth:
rootUser: root
rootPassword: password
provisioning:
enabled: true
image: minio/minio
name: minio
# serviceNameOverride: docs-minio
ingress:
enabled: true
hostname: docs-minio.127.0.0.1.nip.io
tls:
enabled: true
secretName: docs-tls
consoleIngress:
enabled: true
hostname: docs-minio-console.127.0.0.1.nip.io
tls:
enabled: true
secretName: docs-tls
api:
port: 80
username: dinum
password: password
bucket: docs-media-storage
versioning: true
size: 1Gi
buckets:
- name: impress-media-storage
versioning: true
+5 -7
View File
@@ -1,9 +1,7 @@
postgres:
enabled: true
name: postgres
#serviceNameOverride: postgres
image: postgres:16-alpine
auth:
username: dinum
password: pass
database: dinum
size: 1Gi
database: impress
tls:
enabled: true
autoGenerated: true
+4 -7
View File
@@ -1,7 +1,4 @@
redis:
enabled: true
name: redis
#serviceNameOverride: redis
image: redis:8.2-alpine
username: user
password: pass
auth:
password: pass
architecture: standalone
+61 -87
View File
@@ -7,7 +7,7 @@ This document is a step-by-step guide that describes how to install Docs on a k8
- k8s cluster with an nginx-ingress controller
- an OIDC provider (if you don't have one, we provide an example)
- a PostgreSQL server (if you don't have one, we provide an example)
- a Redis server (if you don't have one, we provide an example)
- a Memcached server (if you don't have one, we provide an example)
- a S3 bucket (if you don't have one, we provide an example)
### Test cluster
@@ -100,66 +100,50 @@ When your k8s cluster is ready (the ingress nginx controller is up), you can sta
Please remember that `*.127.0.0.1.nip.io` will always resolve to `127.0.0.1`, except in the k8s cluster where we configure CoreDNS to answer with the ingress-nginx service IP.
The namespace `impress` is already created, you can work in it and configure your kubectl cli to use it by default.
```
$ kubectl config set-context --current --namespace=impress
```
## Preparation
We provide our own helm chart for all development dependencies, it is available here https://github.com/suitenumerique/helm-dev-backend
This provided chart is for development purpose only and is not ready to use in production.
You can install it on your cluster to deploy keycloak, minio, postgresql and redis.
### What do you use to authenticate your users?
Docs uses OIDC, so if you already have an OIDC provider, obtain the necessary information to use it. In the next step, we will see how to configure Django (and thus Docs) to use it. If you do not have a provider, we will show you how to deploy a local Keycloak instance (this is not a production deployment, just a demo).
```
$ helm install --repo https://suitenumerique.github.io/helm-dev-backend -f docs/examples/helm/keycloak.values.yaml keycloak dev-backend
$ kubectl create namespace impress
$ kubectl config set-context --current --namespace=impress
$ helm install keycloak oci://registry-1.docker.io/bitnamicharts/keycloak -f examples/keycloak.values.yaml
$ #wait until
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
keycloak-dev-backend-keycloak-0 1/1 Running 0 20s
keycloak-dev-backend-keycloak-pg-0 1/1 Running 0 20s
$ kubectl get po
NAME READY STATUS RESTARTS AGE
keycloak-0 1/1 Running 0 6m48s
keycloak-postgresql-0 1/1 Running 0 6m48s
```
From here the important information you will need are:
```yaml
OIDC_OP_JWKS_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/certs
OIDC_OP_AUTHORIZATION_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/userinfo
OIDC_OP_LOGOUT_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/logout
OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/certs
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/userinfo
OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/logout
OIDC_RP_CLIENT_ID: impress
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
OIDC_RP_SIGN_ALGO: RS256
OIDC_RP_SCOPES: "openid email"
```
You can find these values in **examples/helm/keycloak.values.yaml**
You can find these values in **examples/keycloak.values.yaml**
### Find redis server connection values
Docs needs a redis so we start by deploying one:
```
$ helm install --repo https://suitenumerique.github.io/helm-dev-backend -f docs/examples/helm/redis.values.yaml redis dev-backend
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
keycloak-dev-backend-keycloak-0 1/1 Running 0 113s
keycloak-dev-backend-keycloak-pg-0 1/1 Running 0 113s
redis-dev-backend-redis-68c9f66786-4dgxj 1/1 Running 0 2s
```
From here the important information you will need are:
```yaml
REDIS_URL: redis://user:pass@redis-dev-backend-redis:6379/1
DJANGO_CELERY_BROKER_URL: redis://user:pass@redis-dev-backend-redis:6379/1
$ helm install redis oci://registry-1.docker.io/bitnamicharts/redis -f examples/redis.values.yaml
$ kubectl get po
NAME READY STATUS RESTARTS AGE
keycloak-0 1/1 Running 0 26m
keycloak-postgresql-0 1/1 Running 0 26m
redis-master-0 1/1 Running 0 35s
```
### Find postgresql connection values
@@ -167,32 +151,22 @@ DJANGO_CELERY_BROKER_URL: redis://user:pass@redis-dev-backend-redis:6379/1
Docs uses a postgresql database as backend, so if you have a provider, obtain the necessary information to use it. If you don't, you can install a postgresql testing environment as follow:
```
$ helm install --repo https://suitenumerique.github.io/helm-dev-backend -f docs/examples/helm/postgresql.values.yaml postgresql dev-backend
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
keycloak-dev-backend-keycloak-0 1/1 Running 0 3m42s
keycloak-dev-backend-keycloak-pg-0 1/1 Running 0 3m42s
postgresql-dev-backend-postgres-0 1/1 Running 0 13s
redis-dev-backend-redis-68c9f66786-4dgxj 1/1 Running 0 111s
$ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/postgresql.values.yaml
$ kubectl get po
NAME READY STATUS RESTARTS AGE
keycloak-0 1/1 Running 0 28m
keycloak-postgresql-0 1/1 Running 0 28m
postgresql-0 1/1 Running 0 14m
redis-master-0 1/1 Running 0 42s
```
From here the important information you will need are:
```yaml
DB_HOST: postgresql-dev-backend-postgres
DB_NAME:
secretKeyRef:
name: postgresql-dev-backend-postgres
key: database
DB_USER:
secretKeyRef:
name: postgresql-dev-backend-postgres
key: username
DB_PASSWORD:
secretKeyRef:
name: postgresql-dev-backend-postgres
key: password
DB_HOST: postgres-postgresql
DB_NAME: impress
DB_USER: dinum
DB_PASSWORD: pass
DB_PORT: 5432
```
@@ -201,15 +175,15 @@ DB_PORT: 5432
Docs uses an s3 bucket to store documents, so if you have a provider obtain the necessary information to use it. If you don't, you can install a local minio testing environment as follow:
```
$ helm install --repo https://suitenumerique.github.io/helm-dev-backend -f docs/examples/helm/minio.values.yaml minio dev-backend
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
keycloak-dev-backend-keycloak-0 1/1 Running 0 6m12s
keycloak-dev-backend-keycloak-pg-0 1/1 Running 0 6m12s
minio-dev-backend-minio-0 1/1 Running 0 10s
postgresql-dev-backend-postgres-0 1/1 Running 0 2m43s
redis-dev-backend-redis-68c9f66786-4dgxj 1/1 Running 0 4m21s
$ helm install minio oci://registry-1.docker.io/bitnamicharts/minio -f examples/minio.values.yaml
$ kubectl get po
NAME READY STATUS RESTARTS AGE
keycloak-0 1/1 Running 0 38m
keycloak-postgresql-0 1/1 Running 0 38m
minio-84f5c66895-bbhsk 1/1 Running 0 42s
minio-provisioning-2b5sq 0/1 Completed 0 42s
postgresql-0 1/1 Running 0 24m
redis-master-0 1/1 Running 0 10m
```
## Deployment
@@ -219,18 +193,20 @@ Now you are ready to deploy Docs without AI. AI requires more dependencies (Open
```
$ helm repo add impress https://suitenumerique.github.io/docs/
$ helm repo update
$ helm install impress impress/docs -f docs/examples/helm/impress.values.yaml
$ helm install impress impress/docs -f examples/impress.values.yaml
$ kubectl get po
NAME READY STATUS RESTARTS AGE
impress-docs-backend-8494fb797d-8k8wt 1/1 Running 0 6m45s
impress-docs-celery-worker-764b5dd98f-9qd6v 1/1 Running 0 6m45s
impress-docs-frontend-5b69b65cc4-s8pps 1/1 Running 0 6m45s
impress-docs-y-provider-5fc7ccd8cc-6ttrf 1/1 Running 0 6m45s
keycloak-dev-backend-keycloak-0 1/1 Running 0 24m
keycloak-dev-backend-keycloak-pg-0 1/1 Running 0 24m
minio-dev-backend-minio-0 1/1 Running 0 8m24s
postgresql-dev-backend-postgres-0 1/1 Running 0 20m
redis-dev-backend-redis-68c9f66786-4dgxj 1/1 Running 0 22m
NAME READY STATUS RESTARTS AGE
impress-docs-backend-96558758d-xtkbp 0/1 Running 0 79s
impress-docs-backend-createsuperuser-r7ltc 0/1 Completed 0 79s
impress-docs-backend-migrate-c949s 0/1 Completed 0 79s
impress-docs-frontend-6749f644f7-p5s42 1/1 Running 0 79s
impress-docs-y-provider-6947fd8f54-78f2l 1/1 Running 0 79s
keycloak-0 1/1 Running 0 48m
keycloak-postgresql-0 1/1 Running 0 48m
minio-84f5c66895-bbhsk 1/1 Running 0 10m
minio-provisioning-2b5sq 0/1 Completed 0 10m
postgresql-0 1/1 Running 0 34m
redis-master-0 1/1 Running 0 20m
```
## Test your deployment
@@ -239,15 +215,13 @@ In order to test your deployment you have to log into your instance. If you excl
```
$ kubectl get ingress
NAME CLASS HOSTS ADDRESS PORTS AGE
impress-docs <none> docs.127.0.0.1.nip.io localhost 80, 443 7m9s
impress-docs-admin <none> docs.127.0.0.1.nip.io localhost 80, 443 7m9s
impress-docs-collaboration-api <none> docs.127.0.0.1.nip.io localhost 80, 443 7m9s
impress-docs-media <none> docs.127.0.0.1.nip.io localhost 80, 443 7m9s
impress-docs-ws <none> docs.127.0.0.1.nip.io localhost 80, 443 7m9s
keycloak-dev-backend-keycloak <none> docs-keycloak.127.0.0.1.nip.io localhost 80, 443 24m
minio-dev-backend-minio-api <none> docs-minio.127.0.0.1.nip.io localhost 80, 443 8m48s
minio-dev-backend-minio-console <none> docs-minio-console.127.0.0.1.nip.io localhost 80, 443 8m48s
NAME CLASS HOSTS ADDRESS PORTS AGE
impress-docs <none> impress.127.0.0.1.nip.io localhost 80, 443 114s
impress-docs-admin <none> impress.127.0.0.1.nip.io localhost 80, 443 114s
impress-docs-collaboration-api <none> impress.127.0.0.1.nip.io localhost 80, 443 114s
impress-docs-media <none> impress.127.0.0.1.nip.io localhost 80, 443 114s
impress-docs-ws <none> impress.127.0.0.1.nip.io localhost 80, 443 114s
keycloak <none> keycloak.127.0.0.1.nip.io localhost 80 49m
```
You can use Docs at https://docs.127.0.0.1.nip.io. The provisionning user in keycloak is docs/docs.
You can use Docs at https://impress.127.0.0.1.nip.io. The provisionning user in keycloak is impress/impress.
-2
View File
@@ -27,8 +27,6 @@ SEARCH_INDEXER_URL="http://find:8000/api/v1.0/documents/index/"
# Search endpoint. Uses the OIDC token for authentication
SEARCH_INDEXER_QUERY_URL="http://find:8000/api/v1.0/documents/search/"
# Maximum number of results expected from the search endpoint
SEARCH_INDEXER_QUERY_LIMIT=50
```
We also need to enable the **OIDC Token** refresh or the authentication will fail quickly.
+5 -60
View File
@@ -8,7 +8,7 @@ To use this feature, simply set the `FRONTEND_CSS_URL` environment variable to t
FRONTEND_CSS_URL=http://anything/custom-style.css
```
Once you've set this variable, Docs will load your custom CSS file and apply the styles to our frontend application.
Once you've set this variable, our application will load your custom CSS file and apply the styles to our frontend application.
### Benefits
@@ -32,64 +32,9 @@ Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom
----
# Runtime JavaScript Injection 🚀
# **Your logo** 📝
### How to Use
To use this feature, simply set the `FRONTEND_JS_URL` environment variable to the URL of your custom JavaScript file. For example:
```javascript
FRONTEND_JS_URL=http://anything/custom-script.js
```
Once you've set this variable, Docs will load your custom JavaScript file and execute it in the browser, allowing you to modify the application's behavior at runtime.
### Benefits
This feature provides several benefits, including:
* **Dynamic customization** 🔄: With this feature, you can dynamically modify the behavior and appearance of our application without requiring any code changes.
* **Flexibility** 🌈: You can add custom functionality, modify existing features, or integrate third-party services.
* **Runtime injection** ⏱️: This feature allows you to inject JavaScript into the application at runtime, without requiring a restart or recompilation.
### Example Use Case
Let's say you want to add a custom menu to the application header. You can create a custom JavaScript file with the following contents:
```javascript
(function() {
'use strict';
function initCustomMenu() {
// Wait for the page to be fully loaded
const header = document.querySelector('header');
if (!header) return false;
// Create and inject your custom menu
const customMenu = document.createElement('div');
customMenu.innerHTML = '<button>Custom Menu</button>';
header.appendChild(customMenu);
console.log('Custom menu added successfully');
return true;
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initCustomMenu);
} else {
initCustomMenu();
}
})();
```
Then, set the `FRONTEND_JS_URL` environment variable to the URL of your custom JavaScript file. Once you've done this, our application will load your custom JavaScript file and execute it, adding your custom menu to the header.
----
# **Your Docs icon** 📝
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 🔧
@@ -99,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.
----
+3 -3
View File
@@ -50,9 +50,9 @@ 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. Needed by search/ endpoint.
# OIDC_STORE_ACCESS_TOKEN = True
# OIDC_STORE_REFRESH_TOKEN = True # Store the encrypted refresh token in the session.
# 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.
+6 -16
View File
@@ -1,18 +1,16 @@
publiccodeYmlVersion: "0.5.0"
publiccodeYmlVersion: "2.4.0"
name: Docs
url: https://github.com/suitenumerique/docs
landingURL: https://github.com/suitenumerique/docs
creationDate: 2023-12-10
logo: https://raw.githubusercontent.com/suitenumerique/docs/main/docs/assets/docs-logo.png
usedBy:
- Direction interministérielle du numérique (DINUM)
- Direction interministériel du numérique (DINUM)
fundedBy:
- name: Direction interministérielle du numérique (DINUM)
uri: https://www.numerique.gouv.fr
- name: Direction interministériel du numérique (DINUM)
url: https://www.numerique.gouv.fr
roadmap: "https://github.com/orgs/suitenumerique/projects/2/views/1"
softwareType: "standalone/other"
platforms:
- "web"
developmentStatus: "stable"
description:
en:
shortDescription: "The open source document editor where your notes can become knowledge through live collaboration"
@@ -20,18 +18,10 @@ description:
shortDescription: "L'éditeur de documents open source où vos notes peuvent devenir des connaissances grâce à la collaboration en direct."
legal:
license: MIT
localisation:
localisationReady: true
availableLanguages:
- de
- en
- es
- fr
- nl
maintenance:
type: internal
contacts:
- name: "Virgile Deville"
email: "virgile.deville@numerique.gouv.fr"
- name: "Samuel Paccoud"
- name: "samuel.paccoud"
email: "samuel.paccoud@numerique.gouv.fr"
-3
View File
@@ -30,11 +30,8 @@
"groupName": "ignored js dependencies",
"matchManagers": ["npm"],
"matchPackageNames": [
"@next/eslint-plugin-next",
"docx",
"eslint-config-next",
"fetch-mock",
"next",
"node",
"node-fetch",
"workbox-webpack-plugin"
-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)
+14 -138
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,127 +891,6 @@ class MoveDocumentSerializer(serializers.Serializer):
)
class ReactionSerializer(serializers.ModelSerializer):
"""Serialize reactions."""
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 {}
class SearchDocumentSerializer(serializers.Serializer):
"""Serializer for fulltext search requests through Find application"""
-30
View File
@@ -1,8 +1,5 @@
"""Throttling modules for the API."""
from django.conf import settings
from lasuite.drf.throttling import MonitoredScopedRateThrottle
from rest_framework.throttling import UserRateThrottle
from sentry_sdk import capture_message
@@ -22,30 +19,3 @@ class UserListThrottleSustained(UserRateThrottle):
"""Throttle for the user list endpoint."""
scope = "user_list_sustained"
class DocumentThrottle(MonitoredScopedRateThrottle):
"""
Throttle for document-related endpoints, with an exception for requests from the
collaboration server.
"""
scope = "document"
def allow_request(self, request, view):
"""
Override to skip throttling for requests from the collaboration server.
Verifies the X-Y-Provider-Key header contains a valid Y_PROVIDER_API_KEY.
Using a custom header instead of Authorization to avoid triggering
authentication middleware.
"""
y_provider_header = request.headers.get("X-Y-Provider-Key", "")
# Check if this is a valid y-provider request and exempt from throttling
y_provider_key = getattr(settings, "Y_PROVIDER_API_KEY", None)
if y_provider_key and y_provider_header == y_provider_key:
return True
return super().allow_request(request, view)
+107 -163
View File
@@ -1,5 +1,4 @@
"""API endpoints"""
# pylint: disable=too-many-lines
import base64
@@ -19,10 +18,9 @@ from django.core.validators import URLValidator
from django.db import connection, transaction
from django.db import models as db
from django.db.models.expressions import RawSQL
from django.db.models.functions import Greatest, Left, Length
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
@@ -40,7 +38,6 @@ from rest_framework import response as drf_response
from rest_framework.permissions import AllowAny
from core import authentication, choices, enums, models
from core.api.filters import remove_accents
from core.services.ai_services import AIService
from core.services.collaboration_services import CollaborationService
from core.services.converter_services import (
@@ -61,11 +58,7 @@ from core.utils import extract_attachments, filter_descendants
from . import permissions, serializers, utils
from .filters import DocumentFilter, ListDocumentFilter, UserSearchFilter
from .throttling import (
DocumentThrottle,
UserListThrottleBurst,
UserListThrottleSustained,
)
from .throttling import UserListThrottleBurst, UserListThrottleSustained
logger = logging.getLogger(__name__)
@@ -200,15 +193,13 @@ class UserViewSet(
queryset = queryset.exclude(documentaccess__document_id=document_id)
filter_data = filterset.form.cleaned_data
query = remove_accents(filter_data["q"])
query = filter_data["q"]
# For emails, match emails by Levenstein distance to prevent typing errors
if "@" in query:
return (
queryset.annotate(
distance=RawSQL(
"levenshtein(unaccent(email::text), %s::text)", (query,)
)
distance=RawSQL("levenshtein(email::text, %s::text)", (query,))
)
.filter(distance__lte=3)
.order_by("distance", "email")[: settings.API_USERS_LIST_LIMIT]
@@ -217,15 +208,11 @@ class UserViewSet(
# Use trigram similarity for non-email-like queries
# For performance reasons we filter first by similarity, which relies on an
# index, then only calculate precise similarity scores for sorting purposes
return (
queryset.annotate(
sim_email=TrigramSimilarity("email", query),
sim_name=TrigramSimilarity("full_name", query),
)
.annotate(similarity=Greatest("sim_email", "sim_name"))
queryset.filter(email__trigram_word_similar=query)
.annotate(similarity=TrigramSimilarity("email", query))
.filter(similarity__gt=0.2)
.order_by("-similarity")[: settings.API_USERS_LIST_LIMIT]
.order_by("-similarity", "email")[: settings.API_USERS_LIST_LIMIT]
)
@drf.decorators.action(
@@ -383,7 +370,6 @@ class DocumentViewSet(
permission_classes = [
permissions.DocumentPermission,
]
throttle_classes = [DocumentThrottle]
throttle_scope = "document"
queryset = models.Document.objects.select_related("creator").all()
serializer_class = serializers.DocumentSerializer
@@ -1119,6 +1105,8 @@ class DocumentViewSet(
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)}
@@ -1929,7 +1917,10 @@ class DocumentAccessViewSet(
class TemplateViewSet(
drf.mixins.CreateModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""Template ViewSet"""
@@ -1985,6 +1976,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,
@@ -2163,18 +2248,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)
@@ -2197,7 +2271,6 @@ class ConfigView(drf.views.APIView):
"ENVIRONMENT",
"FRONTEND_CSS_URL",
"FRONTEND_HOMEPAGE_FEATURE_ENABLED",
"FRONTEND_JS_URL",
"FRONTEND_THEME",
"MEDIA_BASE_URL",
"POSTHOG_KEY",
@@ -2249,132 +2322,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)
@@ -6,7 +6,6 @@ import os
from django.conf import settings
from django.core.exceptions import SuspiciousOperation
from lasuite.marketing.tasks import create_or_update_contact
from lasuite.oidc_login.backends import (
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
)
@@ -58,22 +57,3 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
return self.UserModel.objects.get_user_by_sub_or_email(sub, email)
except DuplicateEmailError as err:
raise SuspiciousOperation(err.message) from err
def post_get_or_create_user(self, user, claims, is_new_user):
"""
Post-processing after user creation or retrieval.
Args:
user (User): The user instance.
claims (dict): The claims dictionary.
is_new_user (bool): Indicates if the user was newly created.
Returns:
- None
"""
if is_new_user and settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL:
create_or_update_contact.delay(
email=user.email, attributes={"DOCS_SOURCE": ["SIGNIN"]}
)
-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)
+1 -13
View File
@@ -17,17 +17,6 @@ class Command(BaseCommand):
help = __doc__
def add_arguments(self, parser):
"""Add argument to require forcing execution when not in debug mode."""
parser.add_argument(
"--batch-size",
action="store",
dest="batch_size",
type=int,
default=50,
help="Indexation query batch size",
)
def handle(self, *args, **options):
"""Launch and log search index generation."""
indexer = get_document_indexer()
@@ -37,10 +26,9 @@ class Command(BaseCommand):
logger.info("Starting to regenerate Find index...")
start = time.perf_counter()
batch_size = options["batch_size"]
try:
count = indexer.index(batch_size=batch_size)
count = indexer.index()
except Exception as err:
raise CommandError("Unable to regenerate index") from err
@@ -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.",
)
],
},
),
]
@@ -1,37 +0,0 @@
# Generated by Django 5.2.8 on 2025-11-20 09:56
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("core", "0026_comments"),
]
operations = [
migrations.RunSQL(
sql="""
CREATE OR REPLACE FUNCTION public.immutable_unaccent(regdictionary, text)
RETURNS text
LANGUAGE c IMMUTABLE PARALLEL SAFE STRICT AS
'$libdir/unaccent', 'unaccent_dict';
CREATE OR REPLACE FUNCTION public.f_unaccent(text)
RETURNS text
LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT
RETURN public.immutable_unaccent(regdictionary 'public.unaccent', $1);
CREATE INDEX IF NOT EXISTS user_email_unaccent_trgm_idx
ON impress_user
USING gin (f_unaccent(email) gin_trgm_ops);
CREATE INDEX IF NOT EXISTS user_full_name_unaccent_trgm_idx
ON impress_user
USING gin (f_unaccent(full_name) gin_trgm_ops);
""",
reverse_sql="""
DROP INDEX IF EXISTS user_email_unaccent_trgm_idx;
DROP INDEX IF EXISTS user_full_name_unaccent_trgm_idx;
""",
),
]
+17 -163
View File
@@ -759,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
@@ -790,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,
@@ -1152,12 +1150,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)
@@ -1216,14 +1209,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,
@@ -1231,7 +1233,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):
@@ -1281,153 +1282,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."""
+18 -20
View File
@@ -102,15 +102,18 @@ class BaseDocumentIndexer(ABC):
`serialize_document()` and `push()` to define backend-specific behavior.
"""
def __init__(self):
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 = 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
self.search_limit = settings.SEARCH_INDEXER_QUERY_LIMIT
if not self.indexer_url:
raise ImproperlyConfigured(
@@ -127,26 +130,19 @@ class BaseDocumentIndexer(ABC):
"SEARCH_INDEXER_QUERY_URL must be set in Django settings."
)
def index(self, queryset=None, batch_size=None):
def index(self, queryset=None):
"""
Fetch documents in batches, serialize them, and push to the search backend.
Args:
queryset (optional): Document queryset
Defaults to all documents without filter.
batch_size (int, optional): Number of documents per batch.
Defaults to settings.SEARCH_INDEXER_BATCH_SIZE.
"""
last_id = 0
count = 0
queryset = queryset or models.Document.objects.all()
batch_size = batch_size or self.batch_size
while True:
documents_batch = list(
queryset.filter(
id__gt=last_id,
).order_by("id")[:batch_size]
).order_by("id")[: self.batch_size]
)
if not documents_batch:
@@ -162,9 +158,8 @@ class BaseDocumentIndexer(ABC):
if document.content or document.title
]
if serialized_batch:
self.push(serialized_batch)
count += len(serialized_batch)
self.push(serialized_batch)
count += len(serialized_batch)
return count
@@ -185,7 +180,7 @@ class BaseDocumentIndexer(ABC):
"""
# pylint: disable-next=too-many-arguments,too-many-positional-arguments
def search(self, text, token, visited=(), nb_results=None):
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
@@ -198,17 +193,20 @@ class BaseDocumentIndexer(ABC):
visited (list, optional):
List of ids of active public documents with LinkTrace
Defaults to settings.SEARCH_INDEXER_BATCH_SIZE.
nb_results (int, optional):
The number of results to return.
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.
"""
nb_results = nb_results or self.search_limit
response = self.search_query(
data={
"q": text,
"visited": visited,
"services": ["docs"],
"nb_results": nb_results,
"page_number": page,
"page_size": page_size,
"order_by": "updated_at",
"order_direction": "desc",
},
+5 -3
View File
@@ -23,9 +23,11 @@ def document_indexer_task(document_id):
"""Celery Task : Sends indexation query for a document."""
indexer = get_document_indexer()
if indexer:
logger.info("Start document %s indexation", document_id)
indexer.index(models.Document.objects.filter(pk=document_id))
if indexer is None:
return
logger.info("Start document %s indexation", document_id)
indexer.index(models.Document.objects.filter(pk=document_id))
def batch_indexer_throttle_acquire(timeout: int = 0, atomic: bool = True):
@@ -2,7 +2,6 @@
import random
import re
from unittest import mock
from django.core.exceptions import SuspiciousOperation
from django.test.utils import override_settings
@@ -13,10 +12,7 @@ from cryptography.fernet import Fernet
from lasuite.oidc_login.backends import get_oidc_refresh_token
from core import models
from core.authentication.backends import (
OIDCAuthenticationBackend,
create_or_update_contact,
)
from core.authentication.backends import OIDCAuthenticationBackend
from core.factories import UserFactory
pytestmark = pytest.mark.django_db
@@ -513,79 +509,3 @@ def test_authentication_session_tokens(
assert user is not None
assert request.session["oidc_access_token"] == "test-access-token"
assert get_oidc_refresh_token(request.session) == "test-refresh-token"
def test_authentication_post_get_or_create_user_new_user_to_marketing_email(settings):
"""
New user and SIGNUP_NEW_USER_TO_MARKETING_EMAIL enabled should create a contact
in the marketing backend.
"""
user = UserFactory()
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
klass = OIDCAuthenticationBackend()
with mock.patch.object(
create_or_update_contact, "delay"
) as mock_create_or_update_contact:
klass.post_get_or_create_user(user, {}, True)
mock_create_or_update_contact.assert_called_once_with(
email=user.email, attributes={"DOCS_SOURCE": ["SIGNIN"]}
)
def test_authentication_post_get_or_create_user_new_user_to_marketing_email_disabled(
settings,
):
"""
New user and SIGNUP_NEW_USER_TO_MARKETING_EMAIL disabled should not create a contact
in the marketing backend.
"""
user = UserFactory()
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = False
klass = OIDCAuthenticationBackend()
with mock.patch.object(
create_or_update_contact, "delay"
) as mock_create_or_update_contact:
klass.post_get_or_create_user(user, {}, True)
mock_create_or_update_contact.assert_not_called()
def test_authentication_post_get_or_create_user_existing_user_to_marketing_email(
settings,
):
"""
Existing user and SIGNUP_NEW_USER_TO_MARKETING_EMAIL enabled should not create a contact
in the marketing backend.
"""
user = UserFactory()
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
klass = OIDCAuthenticationBackend()
with mock.patch.object(
create_or_update_contact, "delay"
) as mock_create_or_update_contact:
klass.post_get_or_create_user(user, {}, False)
mock_create_or_update_contact.assert_not_called()
def test_authentication_post_get_or_create_user_existing_user_to_marketing_email_disabled(
settings,
):
"""
Existing user and SIGNUP_NEW_USER_TO_MARKETING_EMAIL disabled should not create a contact
in the marketing backend.
"""
user = UserFactory()
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = False
klass = OIDCAuthenticationBackend()
with mock.patch.object(
create_or_update_contact, "delay"
) as mock_create_or_update_contact:
klass.post_get_or_create_user(user, {}, False)
mock_create_or_update_contact.assert_not_called()
@@ -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,
@@ -326,7 +326,8 @@ def test_api_documents_search_pagination(
"q": "alpha",
"visited": [],
"services": ["docs"],
"nb_results": 50,
"page_number": 1,
"page_size": 100,
"order_by": "updated_at",
"order_direction": "desc",
}
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
@@ -24,7 +24,6 @@ pytestmark = pytest.mark.django_db
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY=True,
CRISP_WEBSITE_ID="123",
FRONTEND_CSS_URL="http://testcss/",
FRONTEND_JS_URL="http://testjs/",
FRONTEND_THEME="test-theme",
MEDIA_BASE_URL="http://testserver/",
POSTHOG_KEY={"id": "132456", "host": "https://eu.i.posthog-test.com"},
@@ -50,7 +49,6 @@ def test_api_config(is_authenticated):
"ENVIRONMENT": "test",
"FRONTEND_CSS_URL": "http://testcss/",
"FRONTEND_HOMEPAGE_FEATURE_ENABLED": True,
"FRONTEND_JS_URL": "http://testjs/",
"FRONTEND_THEME": "test-theme",
"LANGUAGES": [
["en-us", "English"],
@@ -1,107 +0,0 @@
"""
Test DocumentThrottle for regular throttling and y-provider bypass.
"""
import pytest
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
def test_api_throttling_document_throttle_regular_requests(settings):
"""Test that regular requests are throttled normally."""
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"]
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"] = "3/minute"
settings.Y_PROVIDER_API_KEY = "test-y-provider-key"
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=document, user=user)
# Make 3 requests without the y-provider key
for _i in range(3):
response = client.get(
f"/api/v1.0/documents/{document.id!s}/",
)
assert response.status_code == 200
# 4th request should be throttled
response = client.get(
f"/api/v1.0/documents/{document.id!s}/",
)
assert response.status_code == 429
# A request with the y-provider key should NOT be throttled
response = client.get(
f"/api/v1.0/documents/{document.id!s}/",
HTTP_X_Y_PROVIDER_KEY="test-y-provider-key",
)
assert response.status_code == 200
# Restore original rate
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"] = current_rate
def test_api_throttling_document_throttle_y_provider_exempted(settings):
"""Test that y-provider requests are exempted from throttling."""
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"]
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"] = "3/minute"
settings.Y_PROVIDER_API_KEY = "test-y-provider-key"
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=document, user=user)
# Make many requests with the y-provider API key
for _i in range(10):
response = client.get(
f"/api/v1.0/documents/{document.id!s}/",
HTTP_X_Y_PROVIDER_KEY="test-y-provider-key",
)
assert response.status_code == 200
# Restore original rate
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"] = current_rate
def test_api_throttling_document_throttle_invalid_token(settings):
"""Test that requests with invalid tokens are throttled."""
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"]
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"] = "3/minute"
settings.Y_PROVIDER_API_KEY = "test-y-provider-key"
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=document, user=user)
# Make 3 requests with an invalid token
for _i in range(3):
response = client.get(
f"/api/v1.0/documents/{document.id!s}/",
HTTP_X_Y_PROVIDER_KEY="invalid-token",
)
assert response.status_code == 200
# 4th request should be throttled
response = client.get(
f"/api/v1.0/documents/{document.id!s}/",
HTTP_X_Y_PROVIDER_KEY="invalid-token",
)
assert response.status_code == 429
# Restore original rate
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"] = current_rate
-154
View File
@@ -76,131 +76,6 @@ def test_api_users_list_query_email():
assert user_ids == []
def test_api_users_list_query_email_with_internationalized_domain_names():
"""
Authenticated users should be able to list users and filter by email.
It should work even if the email address contains an internationalized domain name.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
jean = factories.UserFactory(email="jean.martin@éducation.fr")
marie = factories.UserFactory(email="marie.durand@education.fr")
kurokawa = factories.UserFactory(email="contact@黒川.日本")
response = client.get("/api/v1.0/users/?q=jean.martin@education.fr")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(jean.id)]
response = client.get("/api/v1.0/users/?q=jean.martin@éducation.fr")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(jean.id)]
response = client.get("/api/v1.0/users/?q=marie.durand@education.fr")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(marie.id)]
response = client.get("/api/v1.0/users/?q=marie.durand@éducation.fr")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(marie.id)]
response = client.get("/api/v1.0/users/?q=contact@黒川.日本")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(kurokawa.id)]
def test_api_users_list_query_full_name():
"""
Authenticated users should be able to list users and filter by full name.
Only results with a Trigram similarity greater than 0.2 with the query should be returned.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
dave = factories.UserFactory(email="contact@work.com", full_name="David Bowman")
response = client.get(
"/api/v1.0/users/?q=David",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(dave.id)]
response = client.get("/api/v1.0/users/?q=Bowman")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(dave.id)]
response = client.get("/api/v1.0/users/?q=bowman")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(dave.id)]
response = client.get("/api/v1.0/users/?q=BOWMAN")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(dave.id)]
response = client.get("/api/v1.0/users/?q=BoWmAn")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(dave.id)]
response = client.get("/api/v1.0/users/?q=Bovin")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == []
def test_api_users_list_query_accented_full_name():
"""
Authenticated users should be able to list users and filter by full name with accents.
Only results with a Trigram similarity greater than 0.2 with the query should be returned.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
fred = factories.UserFactory(
email="contact@work.com", full_name="Frédérique Lefèvre"
)
response = client.get("/api/v1.0/users/?q=Frédérique")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(fred.id)]
response = client.get("/api/v1.0/users/?q=Frederique")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(fred.id)]
response = client.get("/api/v1.0/users/?q=Lefèvre")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(fred.id)]
response = client.get("/api/v1.0/users/?q=Lefevre")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(fred.id)]
response = client.get("/api/v1.0/users/?q=François Lorfebvre")
assert response.status_code == 200
users = [user["full_name"] for user in response.json()]
assert users == []
def test_api_users_list_limit(settings):
"""
Authenticated users should be able to list users and the number of results
@@ -403,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"],
}
+28 -200
View File
@@ -134,13 +134,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 +165,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 +172,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 +223,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 +232,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 +289,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 +298,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 +344,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 +353,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 +385,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 +394,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 +430,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 +439,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 +485,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 +494,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 +547,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 +556,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 +607,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 +616,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 +1320,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 +1328,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 +1342,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 +1360,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 +1376,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,
},
),
@@ -299,44 +299,6 @@ def test_services_search_indexers_batches_pass_only_batch_accesses(
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_batch_size_argument(mock_push):
"""
Documents indexing should be processed in batches,
batch_size overrides SEARCH_INDEXER_BATCH_SIZE
"""
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(batch_size=2) == 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):
@@ -365,25 +327,6 @@ def test_services_search_indexers_ignore_empty_documents(mock_push):
}
@patch.object(SearchIndexer, "push")
def test_services_search_indexers_skip_empty_batches(mock_push, indexer_settings):
"""
Documents indexing batch can be empty if all the docs are empty.
"""
indexer_settings.SEARCH_INDEXER_BATCH_SIZE = 2
document = factories.DocumentFactory()
# Only empty docs
factories.DocumentFactory.create_batch(5, content="", title="")
assert SearchIndexer().index() == 1
assert mock_push.call_count == 1
results = [doc["id"] for doc in mock_push.call_args[0][0]]
assert results == [str(document.id)]
@patch.object(SearchIndexer, "push")
@pytest.mark.usefixtures("indexer_settings")
def test_services_search_indexers_ancestors_link_reach(mock_push):
@@ -588,48 +531,10 @@ def test_services_search_indexers_search(mock_post, indexer_settings):
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["nb_results"] == 50
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
@patch("requests.post")
def test_services_search_indexers_search_nb_results(mock_post, indexer_settings):
"""
Find API call should have nb_results == SEARCH_INDEXER_QUERY_LIMIT
or the given nb_results argument.
"""
indexer_settings.SEARCH_INDEXER_QUERY_LIMIT = 25
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
assert kwargs.get("json")["nb_results"] == 25
# The argument overrides the setting value
indexer.search("alpha", visited=visited, token="mytoken", nb_results=109)
args, kwargs = mock_post.call_args
assert args[0] == indexer_settings.SEARCH_INDEXER_QUERY_URL
assert kwargs.get("json")["nb_results"] == 109
+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),
),
]
),
@@ -20,8 +20,7 @@ from core import models
from demo import defaults
languages = [x for (x, y) in settings.LANGUAGES]
fake = Faker(languages)
fake = Faker()
logger = logging.getLogger("impress.commands.demo.create_demo")
@@ -141,7 +140,7 @@ def create_demo(stdout):
is_staff=False,
short_name=first_name,
full_name=f"{first_name:s} {random.choice(last_names):s}",
language=random.choice(languages),
language=random.choice(settings.LANGUAGES)[0],
)
)
queue.flush()
@@ -195,7 +194,8 @@ def create_demo(stdout):
is_superuser=False,
is_active=True,
is_staff=False,
language=dev_user["language"] or random.choice(languages),
language=dev_user["language"]
or random.choice(settings.LANGUAGES)[0],
)
)
-31
View File
@@ -120,9 +120,6 @@ class Base(Configuration):
SEARCH_INDEXER_QUERY_URL = values.Value(
default=None, environ_name="SEARCH_INDEXER_QUERY_URL", environ_prefix=None
)
SEARCH_INDEXER_QUERY_LIMIT = values.PositiveIntegerValue(
default=50, environ_name="SEARCH_INDEXER_QUERY_LIMIT", environ_prefix=None
)
# Static files (CSS, JavaScript, Images)
STATIC_URL = "/static/"
@@ -353,7 +350,6 @@ class Base(Configuration):
# OIDC third party
"mozilla_django_oidc",
"lasuite.malware_detection",
"lasuite.marketing",
"csp",
]
@@ -509,9 +505,6 @@ class Base(Configuration):
FRONTEND_CSS_URL = values.Value(
None, environ_name="FRONTEND_CSS_URL", environ_prefix=None
)
FRONTEND_JS_URL = values.Value(
None, environ_name="FRONTEND_JS_URL", environ_prefix=None
)
THEME_CUSTOMIZATION_FILE_PATH = values.Value(
os.path.join(BASE_DIR, "impress/configuration/theme/default.json"),
@@ -837,30 +830,6 @@ class Base(Configuration):
),
}
# Marketing and communication settings
SIGNUP_NEW_USER_TO_MARKETING_EMAIL = values.BooleanValue(
False,
environ_name="SIGNUP_NEW_USER_TO_MARKETING_EMAIL",
environ_prefix=None,
help_text=(
"When enabled, new users are automatically added to mailing list "
"for product updates, marketing communications, and customized emails. "
),
)
LASUITE_MARKETING = {
"BACKEND": values.Value(
"lasuite.marketing.backends.dummy.DummyBackend",
environ_name="LASUITE_MARKETING_BACKEND",
environ_prefix=None,
),
"PARAMETERS": values.DictValue(
default={},
environ_name="LASUITE_MARKETING_PARAMETERS",
environ_prefix=None,
),
}
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):
+59 -97
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"Last-Translator: \n"
"Language-Team: Breton\n"
"Language: br_FR\n"
@@ -50,72 +50,67 @@ msgstr "Kuzhet"
msgid "Favorite"
msgstr "Sinedoù"
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
msgid "A new document was created on your behalf!"
msgstr "Ur restr nevez a zo bet krouet ganeoc'h!"
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
#: build/lib/core/api/serializers.py:500 core/api/serializers.py:500
msgid "You have been granted ownership of a new document:"
msgstr "C'hwi zo bet disklaeriet perc'henn ur restr nevez:"
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
#: build/lib/core/api/serializers.py:536 core/api/serializers.py:536
msgid "This field is required."
msgstr "Ar vaezienn-mañ a zo rekis."
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
#: build/lib/core/api/serializers.py:693 core/api/serializers.py:693
msgid "Body"
msgstr "Korf"
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
#: build/lib/core/api/serializers.py:696 core/api/serializers.py:696
msgid "Body type"
msgstr "Doare korf"
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
#: build/lib/core/api/serializers.py:702 core/api/serializers.py:702
msgid "Format"
msgstr "Stumm"
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#, python-brace-format
msgid "copy of {title}"
msgstr "eilenn {title}"
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
#: core/choices.py:43
#: build/lib/core/choices.py:35 build/lib/core/choices.py:42 core/choices.py:35
#: core/choices.py:42
msgid "Reader"
msgstr "Lenner"
#: build/lib/core/choices.py:36 build/lib/core/choices.py:44 core/choices.py:36
#: core/choices.py:44
msgid "Commenter"
msgstr ""
#: build/lib/core/choices.py:37 build/lib/core/choices.py:45 core/choices.py:37
#: core/choices.py:45
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
#: core/choices.py:43
msgid "Editor"
msgstr "Embanner"
#: build/lib/core/choices.py:46 core/choices.py:46
#: build/lib/core/choices.py:44 core/choices.py:44
msgid "Administrator"
msgstr "Merour"
#: build/lib/core/choices.py:47 core/choices.py:47
#: build/lib/core/choices.py:45 core/choices.py:45
msgid "Owner"
msgstr "Perc'henn"
#: build/lib/core/choices.py:58 core/choices.py:58
#: build/lib/core/choices.py:56 core/choices.py:56
msgid "Restricted"
msgstr "Strishaet"
#: build/lib/core/choices.py:62 core/choices.py:62
#: build/lib/core/choices.py:60 core/choices.py:60
msgid "Authenticated"
msgstr "Anavezet"
#: build/lib/core/choices.py:64 core/choices.py:64
#: build/lib/core/choices.py:62 core/choices.py:62
msgid "Public"
msgstr "Publik"
@@ -239,8 +234,8 @@ msgstr "implijer"
msgid "users"
msgstr "implijerien"
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
msgid "title"
msgstr "titl"
@@ -256,188 +251,155 @@ msgstr "Restr"
msgid "Documents"
msgstr "Restroù"
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
msgid "Untitled Document"
msgstr "Restr hep titl"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:857 core/models.py:857
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} en deus rannet ur restr ganeoc'h!"
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:861 core/models.py:861
#, 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:869 core/models.py:869
#: build/lib/core/models.py:867 core/models.py:867
#, 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:969 core/models.py:969
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document/user link trace"
msgstr "Roud liamm ar restr/an implijer"
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:968 core/models.py:968
msgid "Document/user link traces"
msgstr "Roudoù liamm ar restr/an implijer"
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:974 core/models.py:974
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:999 core/models.py:999
#: build/lib/core/models.py:997 core/models.py:997
msgid "Document favorite"
msgstr "Restr muiañ-karet"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:998 core/models.py:998
msgid "Document favorites"
msgstr "Restroù muiañ-karet"
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1004 core/models.py:1004
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:1028 core/models.py:1028
#: build/lib/core/models.py:1026 core/models.py:1026
msgid "Document/user relation"
msgstr "Liamm restr/implijer"
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1027 core/models.py:1027
msgid "Document/user relations"
msgstr "Liammoù restr/implijer"
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "This user is already in this document."
msgstr "An implijer-mañ a zo dija er restr-mañ."
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This team is already in this document."
msgstr "Ar skipailh-mañ a zo dija en restr-mañ."
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
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:1198 core/models.py:1198
#: build/lib/core/models.py:1191 core/models.py:1191
msgid "Document ask for access"
msgstr "Goulenn tizhout ar restr"
#: build/lib/core/models.py:1199 core/models.py:1199
#: build/lib/core/models.py:1192 core/models.py:1192
msgid "Document ask for accesses"
msgstr "Goulennoù tizhout ar restr"
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1198 core/models.py:1198
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:1262 core/models.py:1262
#: build/lib/core/models.py:1263 core/models.py:1263
#, 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:1266 core/models.py:1266
#: build/lib/core/models.py:1267 core/models.py:1267
#, 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:1272 core/models.py:1272
#: build/lib/core/models.py:1273 core/models.py:1273
#, 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:1314 core/models.py:1314
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1315 core/models.py:1315
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1365 core/models.py:1365
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1366 core/models.py:1366
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1415 core/models.py:1415
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1420 core/models.py:1420
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1431 core/models.py:1431
#: build/lib/core/models.py:1285 core/models.py:1285
msgid "description"
msgstr "deskrivadur"
#: build/lib/core/models.py:1432 core/models.py:1432
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "code"
msgstr "kod"
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1287 core/models.py:1287
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1435 core/models.py:1435
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "public"
msgstr "publik"
#: build/lib/core/models.py:1437 core/models.py:1437
#: build/lib/core/models.py:1291 core/models.py:1291
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:1443 core/models.py:1443
#: build/lib/core/models.py:1297 core/models.py:1297
msgid "Template"
msgstr "Patrom"
#: build/lib/core/models.py:1444 core/models.py:1444
#: build/lib/core/models.py:1298 core/models.py:1298
msgid "Templates"
msgstr "Patromoù"
#: build/lib/core/models.py:1497 core/models.py:1497
#: build/lib/core/models.py:1351 core/models.py:1351
msgid "Template/user relation"
msgstr "Liamm patrom/implijer"
#: build/lib/core/models.py:1498 core/models.py:1498
#: build/lib/core/models.py:1352 core/models.py:1352
msgid "Template/user relations"
msgstr "Liammoù patrom/implijer"
#: build/lib/core/models.py:1504 core/models.py:1504
#: build/lib/core/models.py:1358 core/models.py:1358
msgid "This user is already in this template."
msgstr "An implijer-mañ a zo dija er patrom-mañ."
#: build/lib/core/models.py:1510 core/models.py:1510
#: build/lib/core/models.py:1364 core/models.py:1364
msgid "This team is already in this template."
msgstr "Ar skipailh-mañ a zo dija er patrom-mañ."
#: build/lib/core/models.py:1587 core/models.py:1587
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "email address"
msgstr "postel"
#: build/lib/core/models.py:1606 core/models.py:1606
#: build/lib/core/models.py:1460 core/models.py:1460
msgid "Document invitation"
msgstr "Pedadenn d'ur restr"
#: build/lib/core/models.py:1607 core/models.py:1607
#: build/lib/core/models.py:1461 core/models.py:1461
msgid "Document invitations"
msgstr "Pedadennoù d'ur restr"
#: build/lib/core/models.py:1627 core/models.py:1627
#: build/lib/core/models.py:1481 core/models.py:1481
msgid "This email is already associated to a registered user."
msgstr "Ar postel-mañ a zo liammet ouzh un implijer enskrivet."
+59 -97
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de_DE\n"
@@ -50,72 +50,67 @@ msgstr ""
msgid "Favorite"
msgstr "Favorit"
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
msgid "A new document was created on your behalf!"
msgstr "Ein neues Dokument wurde in Ihrem Namen erstellt!"
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
#: build/lib/core/api/serializers.py:500 core/api/serializers.py:500
msgid "You have been granted ownership of a new document:"
msgstr "Sie sind Besitzer eines neuen Dokuments:"
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
#: build/lib/core/api/serializers.py:536 core/api/serializers.py:536
msgid "This field is required."
msgstr ""
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
#: build/lib/core/api/serializers.py:693 core/api/serializers.py:693
msgid "Body"
msgstr "Inhalt"
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
#: build/lib/core/api/serializers.py:696 core/api/serializers.py:696
msgid "Body type"
msgstr "Typ"
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
#: build/lib/core/api/serializers.py:702 core/api/serializers.py:702
msgid "Format"
msgstr "Format"
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#, python-brace-format
msgid "copy of {title}"
msgstr "Kopie von {title}"
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
#: core/choices.py:43
#: build/lib/core/choices.py:35 build/lib/core/choices.py:42 core/choices.py:35
#: core/choices.py:42
msgid "Reader"
msgstr "Lesen"
#: build/lib/core/choices.py:36 build/lib/core/choices.py:44 core/choices.py:36
#: core/choices.py:44
msgid "Commenter"
msgstr ""
#: build/lib/core/choices.py:37 build/lib/core/choices.py:45 core/choices.py:37
#: core/choices.py:45
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
#: core/choices.py:43
msgid "Editor"
msgstr "Bearbeiten"
#: build/lib/core/choices.py:46 core/choices.py:46
#: build/lib/core/choices.py:44 core/choices.py:44
msgid "Administrator"
msgstr "Administrator"
#: build/lib/core/choices.py:47 core/choices.py:47
#: build/lib/core/choices.py:45 core/choices.py:45
msgid "Owner"
msgstr "Besitzer"
#: build/lib/core/choices.py:58 core/choices.py:58
#: build/lib/core/choices.py:56 core/choices.py:56
msgid "Restricted"
msgstr "Beschränkt"
#: build/lib/core/choices.py:62 core/choices.py:62
#: build/lib/core/choices.py:60 core/choices.py:60
msgid "Authenticated"
msgstr "Authentifiziert"
#: build/lib/core/choices.py:64 core/choices.py:64
#: build/lib/core/choices.py:62 core/choices.py:62
msgid "Public"
msgstr "Öffentlich"
@@ -239,8 +234,8 @@ msgstr "Benutzer"
msgid "users"
msgstr "Benutzer"
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
msgid "title"
msgstr "Titel"
@@ -256,188 +251,155 @@ msgstr "Dokument"
msgid "Documents"
msgstr "Dokumente"
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
msgid "Untitled Document"
msgstr "Unbenanntes Dokument"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:857 core/models.py:857
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} hat ein Dokument mit Ihnen geteilt!"
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:861 core/models.py:861
#, 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:869 core/models.py:869
#: build/lib/core/models.py:867 core/models.py:867
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} hat ein Dokument mit Ihnen geteilt: {title}"
#: build/lib/core/models.py:969 core/models.py:969
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document/user link trace"
msgstr "Dokument/Benutzer Linkverfolgung"
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:968 core/models.py:968
msgid "Document/user link traces"
msgstr "Dokument/Benutzer Linkverfolgung"
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:974 core/models.py:974
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:999 core/models.py:999
#: build/lib/core/models.py:997 core/models.py:997
msgid "Document favorite"
msgstr "Dokumentenfavorit"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:998 core/models.py:998
msgid "Document favorites"
msgstr "Dokumentfavoriten"
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Dieses Dokument ist bereits durch den gleichen Benutzer favorisiert worden."
#: build/lib/core/models.py:1028 core/models.py:1028
#: build/lib/core/models.py:1026 core/models.py:1026
msgid "Document/user relation"
msgstr "Dokument/Benutzerbeziehung"
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1027 core/models.py:1027
msgid "Document/user relations"
msgstr "Dokument/Benutzerbeziehungen"
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "This user is already in this document."
msgstr "Dieser Benutzer befindet sich bereits in diesem Dokument."
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This team is already in this document."
msgstr "Dieses Team befindet sich bereits in diesem Dokument."
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
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:1198 core/models.py:1198
#: build/lib/core/models.py:1191 core/models.py:1191
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1199 core/models.py:1199
#: build/lib/core/models.py:1192 core/models.py:1192
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1262 core/models.py:1262
#: build/lib/core/models.py:1263 core/models.py:1263
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1266 core/models.py:1266
#: build/lib/core/models.py:1267 core/models.py:1267
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1273 core/models.py:1273
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1314 core/models.py:1314
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1315 core/models.py:1315
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1365 core/models.py:1365
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1366 core/models.py:1366
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1415 core/models.py:1415
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1420 core/models.py:1420
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1431 core/models.py:1431
#: build/lib/core/models.py:1285 core/models.py:1285
msgid "description"
msgstr "Beschreibung"
#: build/lib/core/models.py:1432 core/models.py:1432
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "code"
msgstr "Code"
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1287 core/models.py:1287
msgid "css"
msgstr "CSS"
#: build/lib/core/models.py:1435 core/models.py:1435
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "public"
msgstr "öffentlich"
#: build/lib/core/models.py:1437 core/models.py:1437
#: build/lib/core/models.py:1291 core/models.py:1291
msgid "Whether this template is public for anyone to use."
msgstr "Ob diese Vorlage für jedermann öffentlich ist."
#: build/lib/core/models.py:1443 core/models.py:1443
#: build/lib/core/models.py:1297 core/models.py:1297
msgid "Template"
msgstr "Vorlage"
#: build/lib/core/models.py:1444 core/models.py:1444
#: build/lib/core/models.py:1298 core/models.py:1298
msgid "Templates"
msgstr "Vorlagen"
#: build/lib/core/models.py:1497 core/models.py:1497
#: build/lib/core/models.py:1351 core/models.py:1351
msgid "Template/user relation"
msgstr "Vorlage/Benutzer-Beziehung"
#: build/lib/core/models.py:1498 core/models.py:1498
#: build/lib/core/models.py:1352 core/models.py:1352
msgid "Template/user relations"
msgstr "Vorlage/Benutzerbeziehungen"
#: build/lib/core/models.py:1504 core/models.py:1504
#: build/lib/core/models.py:1358 core/models.py:1358
msgid "This user is already in this template."
msgstr "Dieser Benutzer ist bereits in dieser Vorlage."
#: build/lib/core/models.py:1510 core/models.py:1510
#: build/lib/core/models.py:1364 core/models.py:1364
msgid "This team is already in this template."
msgstr "Dieses Team ist bereits in diesem Template."
#: build/lib/core/models.py:1587 core/models.py:1587
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "email address"
msgstr "E-Mail-Adresse"
#: build/lib/core/models.py:1606 core/models.py:1606
#: build/lib/core/models.py:1460 core/models.py:1460
msgid "Document invitation"
msgstr "Einladung zum Dokument"
#: build/lib/core/models.py:1607 core/models.py:1607
#: build/lib/core/models.py:1461 core/models.py:1461
msgid "Document invitations"
msgstr "Dokumenteinladungen"
#: build/lib/core/models.py:1627 core/models.py:1627
#: build/lib/core/models.py:1481 core/models.py:1481
msgid "This email is already associated to a registered user."
msgstr "Diese E-Mail ist bereits einem registrierten Benutzer zugeordnet."
+59 -97
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
@@ -50,72 +50,67 @@ msgstr ""
msgid "Favorite"
msgstr ""
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
msgid "A new document was created on your behalf!"
msgstr ""
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
#: build/lib/core/api/serializers.py:500 core/api/serializers.py:500
msgid "You have been granted ownership of a new document:"
msgstr ""
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
#: build/lib/core/api/serializers.py:536 core/api/serializers.py:536
msgid "This field is required."
msgstr ""
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
#: build/lib/core/api/serializers.py:693 core/api/serializers.py:693
msgid "Body"
msgstr ""
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
#: build/lib/core/api/serializers.py:696 core/api/serializers.py:696
msgid "Body type"
msgstr ""
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
#: build/lib/core/api/serializers.py:702 core/api/serializers.py:702
msgid "Format"
msgstr ""
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#, python-brace-format
msgid "copy of {title}"
msgstr ""
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
#: core/choices.py:43
#: build/lib/core/choices.py:35 build/lib/core/choices.py:42 core/choices.py:35
#: core/choices.py:42
msgid "Reader"
msgstr ""
#: build/lib/core/choices.py:36 build/lib/core/choices.py:44 core/choices.py:36
#: core/choices.py:44
msgid "Commenter"
msgstr ""
#: build/lib/core/choices.py:37 build/lib/core/choices.py:45 core/choices.py:37
#: core/choices.py:45
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
#: core/choices.py:43
msgid "Editor"
msgstr ""
#: build/lib/core/choices.py:46 core/choices.py:46
#: build/lib/core/choices.py:44 core/choices.py:44
msgid "Administrator"
msgstr ""
#: build/lib/core/choices.py:47 core/choices.py:47
#: build/lib/core/choices.py:45 core/choices.py:45
msgid "Owner"
msgstr ""
#: build/lib/core/choices.py:58 core/choices.py:58
#: build/lib/core/choices.py:56 core/choices.py:56
msgid "Restricted"
msgstr ""
#: build/lib/core/choices.py:62 core/choices.py:62
#: build/lib/core/choices.py:60 core/choices.py:60
msgid "Authenticated"
msgstr ""
#: build/lib/core/choices.py:64 core/choices.py:64
#: build/lib/core/choices.py:62 core/choices.py:62
msgid "Public"
msgstr ""
@@ -239,8 +234,8 @@ msgstr ""
msgid "users"
msgstr ""
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
msgid "title"
msgstr ""
@@ -256,188 +251,155 @@ msgstr ""
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:857 core/models.py:857
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:861 core/models.py:861
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:869 core/models.py:869
#: build/lib/core/models.py:867 core/models.py:867
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:969 core/models.py:969
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:968 core/models.py:968
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:974 core/models.py:974
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:999 core/models.py:999
#: build/lib/core/models.py:997 core/models.py:997
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:998 core/models.py:998
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1028 core/models.py:1028
#: build/lib/core/models.py:1026 core/models.py:1026
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1027 core/models.py:1027
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1191 core/models.py:1191
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1199 core/models.py:1199
#: build/lib/core/models.py:1192 core/models.py:1192
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1262 core/models.py:1262
#: build/lib/core/models.py:1263 core/models.py:1263
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1266 core/models.py:1266
#: build/lib/core/models.py:1267 core/models.py:1267
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1273 core/models.py:1273
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1314 core/models.py:1314
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1315 core/models.py:1315
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1365 core/models.py:1365
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1366 core/models.py:1366
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1415 core/models.py:1415
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1420 core/models.py:1420
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1431 core/models.py:1431
#: build/lib/core/models.py:1285 core/models.py:1285
msgid "description"
msgstr ""
#: build/lib/core/models.py:1432 core/models.py:1432
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "code"
msgstr ""
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1287 core/models.py:1287
msgid "css"
msgstr ""
#: build/lib/core/models.py:1435 core/models.py:1435
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "public"
msgstr ""
#: build/lib/core/models.py:1437 core/models.py:1437
#: build/lib/core/models.py:1291 core/models.py:1291
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1443 core/models.py:1443
#: build/lib/core/models.py:1297 core/models.py:1297
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1444 core/models.py:1444
#: build/lib/core/models.py:1298 core/models.py:1298
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1497 core/models.py:1497
#: build/lib/core/models.py:1351 core/models.py:1351
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1498 core/models.py:1498
#: build/lib/core/models.py:1352 core/models.py:1352
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1504 core/models.py:1504
#: build/lib/core/models.py:1358 core/models.py:1358
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1510 core/models.py:1510
#: build/lib/core/models.py:1364 core/models.py:1364
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1587 core/models.py:1587
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1606 core/models.py:1606
#: build/lib/core/models.py:1460 core/models.py:1460
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1607 core/models.py:1607
#: build/lib/core/models.py:1461 core/models.py:1461
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1627 core/models.py:1627
#: build/lib/core/models.py:1481 core/models.py:1481
msgid "This email is already associated to a registered user."
msgstr ""
+59 -97
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"Last-Translator: \n"
"Language-Team: Spanish\n"
"Language: es_ES\n"
@@ -50,72 +50,67 @@ msgstr ""
msgid "Favorite"
msgstr "Favorito"
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
msgid "A new document was created on your behalf!"
msgstr "¡Un nuevo documento se ha creado por ti!"
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
#: build/lib/core/api/serializers.py:500 core/api/serializers.py:500
msgid "You have been granted ownership of a new document:"
msgstr "Se le ha concedido la propiedad de un nuevo documento :"
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
#: build/lib/core/api/serializers.py:536 core/api/serializers.py:536
msgid "This field is required."
msgstr ""
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
#: build/lib/core/api/serializers.py:693 core/api/serializers.py:693
msgid "Body"
msgstr "Cuerpo"
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
#: build/lib/core/api/serializers.py:696 core/api/serializers.py:696
msgid "Body type"
msgstr "Tipo de Cuerpo"
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
#: build/lib/core/api/serializers.py:702 core/api/serializers.py:702
msgid "Format"
msgstr "Formato"
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#, python-brace-format
msgid "copy of {title}"
msgstr "copia de {title}"
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
#: core/choices.py:43
#: build/lib/core/choices.py:35 build/lib/core/choices.py:42 core/choices.py:35
#: core/choices.py:42
msgid "Reader"
msgstr "Lector"
#: build/lib/core/choices.py:36 build/lib/core/choices.py:44 core/choices.py:36
#: core/choices.py:44
msgid "Commenter"
msgstr ""
#: build/lib/core/choices.py:37 build/lib/core/choices.py:45 core/choices.py:37
#: core/choices.py:45
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
#: core/choices.py:43
msgid "Editor"
msgstr "Editor"
#: build/lib/core/choices.py:46 core/choices.py:46
#: build/lib/core/choices.py:44 core/choices.py:44
msgid "Administrator"
msgstr "Administrador"
#: build/lib/core/choices.py:47 core/choices.py:47
#: build/lib/core/choices.py:45 core/choices.py:45
msgid "Owner"
msgstr "Propietario"
#: build/lib/core/choices.py:58 core/choices.py:58
#: build/lib/core/choices.py:56 core/choices.py:56
msgid "Restricted"
msgstr "Restringido"
#: build/lib/core/choices.py:62 core/choices.py:62
#: build/lib/core/choices.py:60 core/choices.py:60
msgid "Authenticated"
msgstr "Autentificado"
#: build/lib/core/choices.py:64 core/choices.py:64
#: build/lib/core/choices.py:62 core/choices.py:62
msgid "Public"
msgstr "Público"
@@ -239,8 +234,8 @@ msgstr "usuario"
msgid "users"
msgstr "usuarios"
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
msgid "title"
msgstr "título"
@@ -256,188 +251,155 @@ msgstr "Documento"
msgid "Documents"
msgstr "Documentos"
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
msgid "Untitled Document"
msgstr "Documento sin título"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:857 core/models.py:857
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "¡{name} ha compartido un documento contigo!"
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:861 core/models.py:861
#, 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:869 core/models.py:869
#: build/lib/core/models.py:867 core/models.py:867
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} ha compartido un documento contigo: {title}"
#: build/lib/core/models.py:969 core/models.py:969
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document/user link trace"
msgstr "Traza del enlace de documento/usuario"
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:968 core/models.py:968
msgid "Document/user link traces"
msgstr "Trazas del enlace de documento/usuario"
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:974 core/models.py:974
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:999 core/models.py:999
#: build/lib/core/models.py:997 core/models.py:997
msgid "Document favorite"
msgstr "Documento favorito"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:998 core/models.py:998
msgid "Document favorites"
msgstr "Documentos favoritos"
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1004 core/models.py:1004
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:1028 core/models.py:1028
#: build/lib/core/models.py:1026 core/models.py:1026
msgid "Document/user relation"
msgstr "Relación documento/usuario"
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1027 core/models.py:1027
msgid "Document/user relations"
msgstr "Relaciones documento/usuario"
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "This user is already in this document."
msgstr "Este usuario ya forma parte del documento."
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This team is already in this document."
msgstr "Este equipo ya forma parte del documento."
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
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:1198 core/models.py:1198
#: build/lib/core/models.py:1191 core/models.py:1191
msgid "Document ask for access"
msgstr "Solicitud de acceso"
#: build/lib/core/models.py:1199 core/models.py:1199
#: build/lib/core/models.py:1192 core/models.py:1192
msgid "Document ask for accesses"
msgstr "Solicitud de accesos"
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1198 core/models.py:1198
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:1262 core/models.py:1262
#: build/lib/core/models.py:1263 core/models.py:1263
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "¡{name} desea acceder a un documento!"
#: build/lib/core/models.py:1266 core/models.py:1266
#: build/lib/core/models.py:1267 core/models.py:1267
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} desea acceso al siguiente documento:"
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1273 core/models.py:1273
#, 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:1314 core/models.py:1314
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1315 core/models.py:1315
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1365 core/models.py:1365
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1366 core/models.py:1366
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1415 core/models.py:1415
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1420 core/models.py:1420
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1431 core/models.py:1431
#: build/lib/core/models.py:1285 core/models.py:1285
msgid "description"
msgstr "descripción"
#: build/lib/core/models.py:1432 core/models.py:1432
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "code"
msgstr "código"
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1287 core/models.py:1287
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1435 core/models.py:1435
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "public"
msgstr "público"
#: build/lib/core/models.py:1437 core/models.py:1437
#: build/lib/core/models.py:1291 core/models.py:1291
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:1443 core/models.py:1443
#: build/lib/core/models.py:1297 core/models.py:1297
msgid "Template"
msgstr "Plantilla"
#: build/lib/core/models.py:1444 core/models.py:1444
#: build/lib/core/models.py:1298 core/models.py:1298
msgid "Templates"
msgstr "Plantillas"
#: build/lib/core/models.py:1497 core/models.py:1497
#: build/lib/core/models.py:1351 core/models.py:1351
msgid "Template/user relation"
msgstr "Relación plantilla/usuario"
#: build/lib/core/models.py:1498 core/models.py:1498
#: build/lib/core/models.py:1352 core/models.py:1352
msgid "Template/user relations"
msgstr "Relaciones plantilla/usuario"
#: build/lib/core/models.py:1504 core/models.py:1504
#: build/lib/core/models.py:1358 core/models.py:1358
msgid "This user is already in this template."
msgstr "Este usuario ya forma parte de la plantilla."
#: build/lib/core/models.py:1510 core/models.py:1510
#: build/lib/core/models.py:1364 core/models.py:1364
msgid "This team is already in this template."
msgstr "Este equipo ya se encuentra en esta plantilla."
#: build/lib/core/models.py:1587 core/models.py:1587
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "email address"
msgstr "dirección de correo electrónico"
#: build/lib/core/models.py:1606 core/models.py:1606
#: build/lib/core/models.py:1460 core/models.py:1460
msgid "Document invitation"
msgstr "Invitación al documento"
#: build/lib/core/models.py:1607 core/models.py:1607
#: build/lib/core/models.py:1461 core/models.py:1461
msgid "Document invitations"
msgstr "Invitaciones a documentos"
#: build/lib/core/models.py:1627 core/models.py:1627
#: build/lib/core/models.py:1481 core/models.py:1481
msgid "This email is already associated to a registered user."
msgstr "Este correo electrónico está asociado a un usuario registrado."
+59 -97
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
@@ -50,72 +50,67 @@ msgstr "Masqué"
msgid "Favorite"
msgstr "Favoris"
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
msgid "A new document was created on your behalf!"
msgstr "Un nouveau document a été créé pour vous !"
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
#: build/lib/core/api/serializers.py:500 core/api/serializers.py:500
msgid "You have been granted ownership of a new document:"
msgstr "Vous avez été déclaré propriétaire d'un nouveau document :"
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
#: build/lib/core/api/serializers.py:536 core/api/serializers.py:536
msgid "This field is required."
msgstr "Ce champ est obligatoire."
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr "La portée du lien '%(link_reach)s' n'est pas autorisée en fonction de la configuration du document parent."
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
#: build/lib/core/api/serializers.py:693 core/api/serializers.py:693
msgid "Body"
msgstr "Corps"
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
#: build/lib/core/api/serializers.py:696 core/api/serializers.py:696
msgid "Body type"
msgstr "Type de corps"
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
#: build/lib/core/api/serializers.py:702 core/api/serializers.py:702
msgid "Format"
msgstr "Format"
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#, python-brace-format
msgid "copy of {title}"
msgstr "copie de {title}"
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
#: core/choices.py:43
#: build/lib/core/choices.py:35 build/lib/core/choices.py:42 core/choices.py:35
#: core/choices.py:42
msgid "Reader"
msgstr "Lecteur"
#: build/lib/core/choices.py:36 build/lib/core/choices.py:44 core/choices.py:36
#: core/choices.py:44
msgid "Commenter"
msgstr "Commentateur"
#: build/lib/core/choices.py:37 build/lib/core/choices.py:45 core/choices.py:37
#: core/choices.py:45
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
#: core/choices.py:43
msgid "Editor"
msgstr "Éditeur"
#: build/lib/core/choices.py:46 core/choices.py:46
#: build/lib/core/choices.py:44 core/choices.py:44
msgid "Administrator"
msgstr "Administrateur"
#: build/lib/core/choices.py:47 core/choices.py:47
#: build/lib/core/choices.py:45 core/choices.py:45
msgid "Owner"
msgstr "Propriétaire"
#: build/lib/core/choices.py:58 core/choices.py:58
#: build/lib/core/choices.py:56 core/choices.py:56
msgid "Restricted"
msgstr "Restreint"
#: build/lib/core/choices.py:62 core/choices.py:62
#: build/lib/core/choices.py:60 core/choices.py:60
msgid "Authenticated"
msgstr "Authentifié"
#: build/lib/core/choices.py:64 core/choices.py:64
#: build/lib/core/choices.py:62 core/choices.py:62
msgid "Public"
msgstr "Public"
@@ -239,8 +234,8 @@ msgstr "utilisateur"
msgid "users"
msgstr "utilisateurs"
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
msgid "title"
msgstr "titre"
@@ -256,188 +251,155 @@ msgstr "Document"
msgid "Documents"
msgstr "Documents"
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
msgid "Untitled Document"
msgstr "Document sans titre"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:857 core/models.py:857
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} a partagé un document avec vous!"
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:861 core/models.py:861
#, 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:869 core/models.py:869
#: build/lib/core/models.py:867 core/models.py:867
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} a partagé un document avec vous : {title}"
#: build/lib/core/models.py:969 core/models.py:969
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document/user link trace"
msgstr "Trace du lien document/utilisateur"
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:968 core/models.py:968
msgid "Document/user link traces"
msgstr "Traces du lien document/utilisateur"
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:974 core/models.py:974
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:999 core/models.py:999
#: build/lib/core/models.py:997 core/models.py:997
msgid "Document favorite"
msgstr "Document favori"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:998 core/models.py:998
msgid "Document favorites"
msgstr "Documents favoris"
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1004 core/models.py:1004
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:1028 core/models.py:1028
#: build/lib/core/models.py:1026 core/models.py:1026
msgid "Document/user relation"
msgstr "Relation document/utilisateur"
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1027 core/models.py:1027
msgid "Document/user relations"
msgstr "Relations document/utilisateur"
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "This user is already in this document."
msgstr "Cet utilisateur est déjà dans ce document."
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This team is already in this document."
msgstr "Cette équipe est déjà dans ce document."
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
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:1198 core/models.py:1198
#: build/lib/core/models.py:1191 core/models.py:1191
msgid "Document ask for access"
msgstr "Demande d'accès au document"
#: build/lib/core/models.py:1199 core/models.py:1199
#: build/lib/core/models.py:1192 core/models.py:1192
msgid "Document ask for accesses"
msgstr "Demande d'accès au document"
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1198 core/models.py:1198
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:1262 core/models.py:1262
#: build/lib/core/models.py:1263 core/models.py:1263
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} souhaiterait accéder au document suivant !"
#: build/lib/core/models.py:1266 core/models.py:1266
#: build/lib/core/models.py:1267 core/models.py:1267
#, 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:1272 core/models.py:1272
#: build/lib/core/models.py:1273 core/models.py:1273
#, 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:1314 core/models.py:1314
msgid "Thread"
msgstr "Conversation"
#: build/lib/core/models.py:1315 core/models.py:1315
msgid "Threads"
msgstr "Conversations"
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
msgid "Anonymous"
msgstr "Anonyme"
#: build/lib/core/models.py:1365 core/models.py:1365
msgid "Comment"
msgstr "Commentaire"
#: build/lib/core/models.py:1366 core/models.py:1366
msgid "Comments"
msgstr "Commentaires"
#: build/lib/core/models.py:1415 core/models.py:1415
msgid "This emoji has already been reacted to this comment."
msgstr "Cet émoji a déjà été réagi à ce commentaire."
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "Reaction"
msgstr "Réaction"
#: build/lib/core/models.py:1420 core/models.py:1420
msgid "Reactions"
msgstr "Réactions"
#: build/lib/core/models.py:1431 core/models.py:1431
#: build/lib/core/models.py:1285 core/models.py:1285
msgid "description"
msgstr "description"
#: build/lib/core/models.py:1432 core/models.py:1432
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "code"
msgstr "code"
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1287 core/models.py:1287
msgid "css"
msgstr "CSS"
#: build/lib/core/models.py:1435 core/models.py:1435
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "public"
msgstr "public"
#: build/lib/core/models.py:1437 core/models.py:1437
#: build/lib/core/models.py:1291 core/models.py:1291
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:1443 core/models.py:1443
#: build/lib/core/models.py:1297 core/models.py:1297
msgid "Template"
msgstr "Modèle"
#: build/lib/core/models.py:1444 core/models.py:1444
#: build/lib/core/models.py:1298 core/models.py:1298
msgid "Templates"
msgstr "Modèles"
#: build/lib/core/models.py:1497 core/models.py:1497
#: build/lib/core/models.py:1351 core/models.py:1351
msgid "Template/user relation"
msgstr "Relation modèle/utilisateur"
#: build/lib/core/models.py:1498 core/models.py:1498
#: build/lib/core/models.py:1352 core/models.py:1352
msgid "Template/user relations"
msgstr "Relations modèle/utilisateur"
#: build/lib/core/models.py:1504 core/models.py:1504
#: build/lib/core/models.py:1358 core/models.py:1358
msgid "This user is already in this template."
msgstr "Cet utilisateur est déjà dans ce modèle."
#: build/lib/core/models.py:1510 core/models.py:1510
#: build/lib/core/models.py:1364 core/models.py:1364
msgid "This team is already in this template."
msgstr "Cette équipe est déjà modèle."
#: build/lib/core/models.py:1587 core/models.py:1587
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "email address"
msgstr "adresse e-mail"
#: build/lib/core/models.py:1606 core/models.py:1606
#: build/lib/core/models.py:1460 core/models.py:1460
msgid "Document invitation"
msgstr "Invitation à un document"
#: build/lib/core/models.py:1607 core/models.py:1607
#: build/lib/core/models.py:1461 core/models.py:1461
msgid "Document invitations"
msgstr "Invitations à un document"
#: build/lib/core/models.py:1627 core/models.py:1627
#: build/lib/core/models.py:1481 core/models.py:1481
msgid "This email is already associated to a registered user."
msgstr "Cette adresse email est déjà associée à un utilisateur inscrit."
+59 -97
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"Last-Translator: \n"
"Language-Team: Italian\n"
"Language: it_IT\n"
@@ -50,72 +50,67 @@ msgstr ""
msgid "Favorite"
msgstr "Preferiti"
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
msgid "A new document was created on your behalf!"
msgstr "Un nuovo documento è stato creato a tuo nome!"
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
#: build/lib/core/api/serializers.py:500 core/api/serializers.py:500
msgid "You have been granted ownership of a new document:"
msgstr "Sei ora proprietario di un nuovo documento:"
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
#: build/lib/core/api/serializers.py:536 core/api/serializers.py:536
msgid "This field is required."
msgstr ""
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
#: build/lib/core/api/serializers.py:693 core/api/serializers.py:693
msgid "Body"
msgstr "Corpo"
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
#: build/lib/core/api/serializers.py:696 core/api/serializers.py:696
msgid "Body type"
msgstr ""
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
#: build/lib/core/api/serializers.py:702 core/api/serializers.py:702
msgid "Format"
msgstr "Formato"
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#, python-brace-format
msgid "copy of {title}"
msgstr "copia di {title}"
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
#: core/choices.py:43
#: build/lib/core/choices.py:35 build/lib/core/choices.py:42 core/choices.py:35
#: core/choices.py:42
msgid "Reader"
msgstr "Lettore"
#: build/lib/core/choices.py:36 build/lib/core/choices.py:44 core/choices.py:36
#: core/choices.py:44
msgid "Commenter"
msgstr ""
#: build/lib/core/choices.py:37 build/lib/core/choices.py:45 core/choices.py:37
#: core/choices.py:45
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
#: core/choices.py:43
msgid "Editor"
msgstr "Editor"
#: build/lib/core/choices.py:46 core/choices.py:46
#: build/lib/core/choices.py:44 core/choices.py:44
msgid "Administrator"
msgstr "Amministratore"
#: build/lib/core/choices.py:47 core/choices.py:47
#: build/lib/core/choices.py:45 core/choices.py:45
msgid "Owner"
msgstr "Proprietario"
#: build/lib/core/choices.py:58 core/choices.py:58
#: build/lib/core/choices.py:56 core/choices.py:56
msgid "Restricted"
msgstr "Limitato"
#: build/lib/core/choices.py:62 core/choices.py:62
#: build/lib/core/choices.py:60 core/choices.py:60
msgid "Authenticated"
msgstr "Autenticato"
#: build/lib/core/choices.py:64 core/choices.py:64
#: build/lib/core/choices.py:62 core/choices.py:62
msgid "Public"
msgstr "Pubblico"
@@ -239,8 +234,8 @@ msgstr "utente"
msgid "users"
msgstr "utenti"
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
msgid "title"
msgstr "titolo"
@@ -256,188 +251,155 @@ msgstr "Documento"
msgid "Documents"
msgstr "Documenti"
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
msgid "Untitled Document"
msgstr "Documento senza titolo"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:857 core/models.py:857
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} ha condiviso un documento con te!"
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:861 core/models.py:861
#, 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:869 core/models.py:869
#: build/lib/core/models.py:867 core/models.py:867
#, 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:969 core/models.py:969
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:968 core/models.py:968
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:974 core/models.py:974
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:999 core/models.py:999
#: build/lib/core/models.py:997 core/models.py:997
msgid "Document favorite"
msgstr "Documento preferito"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:998 core/models.py:998
msgid "Document favorites"
msgstr "Documenti preferiti"
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1028 core/models.py:1028
#: build/lib/core/models.py:1026 core/models.py:1026
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1027 core/models.py:1027
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "This user is already in this document."
msgstr "Questo utente è già presente in questo documento."
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This team is already in this document."
msgstr "Questo team è già presente in questo documento."
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1191 core/models.py:1191
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1199 core/models.py:1199
#: build/lib/core/models.py:1192 core/models.py:1192
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1262 core/models.py:1262
#: build/lib/core/models.py:1263 core/models.py:1263
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1266 core/models.py:1266
#: build/lib/core/models.py:1267 core/models.py:1267
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1273 core/models.py:1273
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1314 core/models.py:1314
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1315 core/models.py:1315
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1365 core/models.py:1365
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1366 core/models.py:1366
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1415 core/models.py:1415
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1420 core/models.py:1420
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1431 core/models.py:1431
#: build/lib/core/models.py:1285 core/models.py:1285
msgid "description"
msgstr "descrizione"
#: build/lib/core/models.py:1432 core/models.py:1432
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "code"
msgstr "code"
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1287 core/models.py:1287
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1435 core/models.py:1435
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "public"
msgstr "pubblico"
#: build/lib/core/models.py:1437 core/models.py:1437
#: build/lib/core/models.py:1291 core/models.py:1291
msgid "Whether this template is public for anyone to use."
msgstr "Indica se questo modello è pubblico per chiunque."
#: build/lib/core/models.py:1443 core/models.py:1443
#: build/lib/core/models.py:1297 core/models.py:1297
msgid "Template"
msgstr "Modello"
#: build/lib/core/models.py:1444 core/models.py:1444
#: build/lib/core/models.py:1298 core/models.py:1298
msgid "Templates"
msgstr "Modelli"
#: build/lib/core/models.py:1497 core/models.py:1497
#: build/lib/core/models.py:1351 core/models.py:1351
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1498 core/models.py:1498
#: build/lib/core/models.py:1352 core/models.py:1352
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1504 core/models.py:1504
#: build/lib/core/models.py:1358 core/models.py:1358
msgid "This user is already in this template."
msgstr "Questo utente è già in questo modello."
#: build/lib/core/models.py:1510 core/models.py:1510
#: build/lib/core/models.py:1364 core/models.py:1364
msgid "This team is already in this template."
msgstr "Questo team è già in questo modello."
#: build/lib/core/models.py:1587 core/models.py:1587
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "email address"
msgstr "indirizzo e-mail"
#: build/lib/core/models.py:1606 core/models.py:1606
#: build/lib/core/models.py:1460 core/models.py:1460
msgid "Document invitation"
msgstr "Invito al documento"
#: build/lib/core/models.py:1607 core/models.py:1607
#: build/lib/core/models.py:1461 core/models.py:1461
msgid "Document invitations"
msgstr "Inviti al documento"
#: build/lib/core/models.py:1627 core/models.py:1627
#: build/lib/core/models.py:1481 core/models.py:1481
msgid "This email is already associated to a registered user."
msgstr "Questa email è già associata a un utente registrato."
+59 -97
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"Last-Translator: \n"
"Language-Team: Dutch\n"
"Language: nl_NL\n"
@@ -50,72 +50,67 @@ msgstr "Gemaskeerd"
msgid "Favorite"
msgstr "Favoriet"
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
#: 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!"
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
#: 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:"
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
#: build/lib/core/api/serializers.py:536 core/api/serializers.py:536
msgid "This field is required."
msgstr "Dit veld is verplicht."
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr "Link bereik '%(link_reach)s' is niet toegestaan op basis van bovenliggende documentconfiguratie."
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
#: build/lib/core/api/serializers.py:693 core/api/serializers.py:693
msgid "Body"
msgstr "Text"
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
#: build/lib/core/api/serializers.py:696 core/api/serializers.py:696
msgid "Body type"
msgstr "Text type"
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
#: build/lib/core/api/serializers.py:702 core/api/serializers.py:702
msgid "Format"
msgstr "Formaat"
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#, python-brace-format
msgid "copy of {title}"
msgstr "kopie van {title}"
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
#: core/choices.py:43
#: build/lib/core/choices.py:35 build/lib/core/choices.py:42 core/choices.py:35
#: core/choices.py:42
msgid "Reader"
msgstr "Lezer"
#: build/lib/core/choices.py:36 build/lib/core/choices.py:44 core/choices.py:36
#: core/choices.py:44
msgid "Commenter"
msgstr "Commentator"
#: build/lib/core/choices.py:37 build/lib/core/choices.py:45 core/choices.py:37
#: core/choices.py:45
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
#: core/choices.py:43
msgid "Editor"
msgstr "Redacteur"
#: build/lib/core/choices.py:46 core/choices.py:46
#: build/lib/core/choices.py:44 core/choices.py:44
msgid "Administrator"
msgstr "Beheerder"
#: build/lib/core/choices.py:47 core/choices.py:47
#: build/lib/core/choices.py:45 core/choices.py:45
msgid "Owner"
msgstr "Eigenaar"
#: build/lib/core/choices.py:58 core/choices.py:58
#: build/lib/core/choices.py:56 core/choices.py:56
msgid "Restricted"
msgstr "Beperkt"
#: build/lib/core/choices.py:62 core/choices.py:62
#: build/lib/core/choices.py:60 core/choices.py:60
msgid "Authenticated"
msgstr "Geauthenticeerd"
#: build/lib/core/choices.py:64 core/choices.py:64
#: build/lib/core/choices.py:62 core/choices.py:62
msgid "Public"
msgstr "Publiek"
@@ -239,8 +234,8 @@ msgstr "gebruiker"
msgid "users"
msgstr "gebruikers"
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
msgid "title"
msgstr "titel"
@@ -256,188 +251,155 @@ msgstr "Document"
msgid "Documents"
msgstr "Documenten"
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
msgid "Untitled Document"
msgstr "Naamloos Document"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:857 core/models.py:857
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} heeft een document met u gedeeld!"
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:861 core/models.py:861
#, 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:869 core/models.py:869
#: build/lib/core/models.py:867 core/models.py:867
#, 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:969 core/models.py:969
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document/user link trace"
msgstr "Document/gebruiker link"
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:968 core/models.py:968
msgid "Document/user link traces"
msgstr "Document/gebruiker link"
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:974 core/models.py:974
msgid "A link trace already exists for this document/user."
msgstr "Een link bestaat al voor dit document/deze gebruiker."
#: build/lib/core/models.py:999 core/models.py:999
#: build/lib/core/models.py:997 core/models.py:997
msgid "Document favorite"
msgstr "Document favoriet"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:998 core/models.py:998
msgid "Document favorites"
msgstr "Document favorieten"
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1004 core/models.py:1004
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."
#: build/lib/core/models.py:1028 core/models.py:1028
#: build/lib/core/models.py:1026 core/models.py:1026
msgid "Document/user relation"
msgstr "Document/gebruiker relatie"
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1027 core/models.py:1027
msgid "Document/user relations"
msgstr "Document/gebruiker relaties"
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "This user is already in this document."
msgstr "De gebruiker bestaat al in dit document."
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This team is already in this document."
msgstr "Dit team bestaat al in dit document."
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
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:1198 core/models.py:1198
#: build/lib/core/models.py:1191 core/models.py:1191
msgid "Document ask for access"
msgstr "Document verzoekt om toegang"
#: build/lib/core/models.py:1199 core/models.py:1199
#: build/lib/core/models.py:1192 core/models.py:1192
msgid "Document ask for accesses"
msgstr "Document verzoekt om toegangen"
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1198 core/models.py:1198
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:1262 core/models.py:1262
#: build/lib/core/models.py:1263 core/models.py:1263
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} verzoekt toegang tot een document!"
#: build/lib/core/models.py:1266 core/models.py:1266
#: build/lib/core/models.py:1267 core/models.py:1267
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} verzoekt toegang tot het volgende document:"
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1273 core/models.py:1273
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} verzoekt toegang tot het document: {title}"
#: build/lib/core/models.py:1314 core/models.py:1314
msgid "Thread"
msgstr "Kanaal"
#: build/lib/core/models.py:1315 core/models.py:1315
msgid "Threads"
msgstr "Kanalen"
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
msgid "Anonymous"
msgstr "Anoniem"
#: build/lib/core/models.py:1365 core/models.py:1365
msgid "Comment"
msgstr "Reactie"
#: build/lib/core/models.py:1366 core/models.py:1366
msgid "Comments"
msgstr "Reacties"
#: build/lib/core/models.py:1415 core/models.py:1415
msgid "This emoji has already been reacted to this comment."
msgstr "Deze emoji is al op deze opmerking gereageerd."
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "Reaction"
msgstr "Reactie"
#: build/lib/core/models.py:1420 core/models.py:1420
msgid "Reactions"
msgstr "Reacties"
#: build/lib/core/models.py:1431 core/models.py:1431
#: build/lib/core/models.py:1285 core/models.py:1285
msgid "description"
msgstr "omschrijving"
#: build/lib/core/models.py:1432 core/models.py:1432
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "code"
msgstr "code"
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1287 core/models.py:1287
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1435 core/models.py:1435
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "public"
msgstr "publiek"
#: build/lib/core/models.py:1437 core/models.py:1437
#: build/lib/core/models.py:1291 core/models.py:1291
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:1443 core/models.py:1443
#: build/lib/core/models.py:1297 core/models.py:1297
msgid "Template"
msgstr "Sjabloon"
#: build/lib/core/models.py:1444 core/models.py:1444
#: build/lib/core/models.py:1298 core/models.py:1298
msgid "Templates"
msgstr "Sjabloon"
#: build/lib/core/models.py:1497 core/models.py:1497
#: build/lib/core/models.py:1351 core/models.py:1351
msgid "Template/user relation"
msgstr "Sjabloon/gebruiker relatie"
#: build/lib/core/models.py:1498 core/models.py:1498
#: build/lib/core/models.py:1352 core/models.py:1352
msgid "Template/user relations"
msgstr "Sjabloon/gebruiker relaties"
#: build/lib/core/models.py:1504 core/models.py:1504
#: build/lib/core/models.py:1358 core/models.py:1358
msgid "This user is already in this template."
msgstr "De gebruiker bestaat al in dit sjabloon."
#: build/lib/core/models.py:1510 core/models.py:1510
#: build/lib/core/models.py:1364 core/models.py:1364
msgid "This team is already in this template."
msgstr "Het team bestaat al in dit sjabloon."
#: build/lib/core/models.py:1587 core/models.py:1587
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "email address"
msgstr "e-mailadres"
#: build/lib/core/models.py:1606 core/models.py:1606
#: build/lib/core/models.py:1460 core/models.py:1460
msgid "Document invitation"
msgstr "Document uitnodiging"
#: build/lib/core/models.py:1607 core/models.py:1607
#: build/lib/core/models.py:1461 core/models.py:1461
msgid "Document invitations"
msgstr "Document uitnodigingen"
#: build/lib/core/models.py:1627 core/models.py:1627
#: build/lib/core/models.py:1481 core/models.py:1481
msgid "This email is already associated to a registered user."
msgstr "Deze email is al geassocieerd met een geregistreerde gebruiker."
+59 -97
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"Last-Translator: \n"
"Language-Team: Portuguese\n"
"Language: pt_PT\n"
@@ -50,72 +50,67 @@ msgstr ""
msgid "Favorite"
msgstr "Favorito"
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
msgid "A new document was created on your behalf!"
msgstr "Um novo documento foi criado em seu nome!"
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
#: build/lib/core/api/serializers.py:500 core/api/serializers.py:500
msgid "You have been granted ownership of a new document:"
msgstr "A propriedade de um novo documento foi concedida a você:"
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
#: build/lib/core/api/serializers.py:536 core/api/serializers.py:536
msgid "This field is required."
msgstr ""
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
#: build/lib/core/api/serializers.py:693 core/api/serializers.py:693
msgid "Body"
msgstr "Corpo"
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
#: build/lib/core/api/serializers.py:696 core/api/serializers.py:696
msgid "Body type"
msgstr "Tipo de corpo"
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
#: build/lib/core/api/serializers.py:702 core/api/serializers.py:702
msgid "Format"
msgstr "Formato"
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#, python-brace-format
msgid "copy of {title}"
msgstr "cópia de {title}"
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
#: core/choices.py:43
#: build/lib/core/choices.py:35 build/lib/core/choices.py:42 core/choices.py:35
#: core/choices.py:42
msgid "Reader"
msgstr "Leitor"
#: build/lib/core/choices.py:36 build/lib/core/choices.py:44 core/choices.py:36
#: core/choices.py:44
msgid "Commenter"
msgstr ""
#: build/lib/core/choices.py:37 build/lib/core/choices.py:45 core/choices.py:37
#: core/choices.py:45
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
#: core/choices.py:43
msgid "Editor"
msgstr "Editor"
#: build/lib/core/choices.py:46 core/choices.py:46
#: build/lib/core/choices.py:44 core/choices.py:44
msgid "Administrator"
msgstr "Administrador"
#: build/lib/core/choices.py:47 core/choices.py:47
#: build/lib/core/choices.py:45 core/choices.py:45
msgid "Owner"
msgstr "Dono"
#: build/lib/core/choices.py:58 core/choices.py:58
#: build/lib/core/choices.py:56 core/choices.py:56
msgid "Restricted"
msgstr "Restrito"
#: build/lib/core/choices.py:62 core/choices.py:62
#: build/lib/core/choices.py:60 core/choices.py:60
msgid "Authenticated"
msgstr "Autenticado"
#: build/lib/core/choices.py:64 core/choices.py:64
#: build/lib/core/choices.py:62 core/choices.py:62
msgid "Public"
msgstr "Público"
@@ -239,8 +234,8 @@ msgstr ""
msgid "users"
msgstr ""
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
msgid "title"
msgstr ""
@@ -256,188 +251,155 @@ msgstr ""
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:857 core/models.py:857
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:861 core/models.py:861
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:869 core/models.py:869
#: build/lib/core/models.py:867 core/models.py:867
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:969 core/models.py:969
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:968 core/models.py:968
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:974 core/models.py:974
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:999 core/models.py:999
#: build/lib/core/models.py:997 core/models.py:997
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:998 core/models.py:998
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1028 core/models.py:1028
#: build/lib/core/models.py:1026 core/models.py:1026
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1027 core/models.py:1027
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1191 core/models.py:1191
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1199 core/models.py:1199
#: build/lib/core/models.py:1192 core/models.py:1192
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1262 core/models.py:1262
#: build/lib/core/models.py:1263 core/models.py:1263
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1266 core/models.py:1266
#: build/lib/core/models.py:1267 core/models.py:1267
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1273 core/models.py:1273
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1314 core/models.py:1314
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1315 core/models.py:1315
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1365 core/models.py:1365
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1366 core/models.py:1366
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1415 core/models.py:1415
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1420 core/models.py:1420
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1431 core/models.py:1431
#: build/lib/core/models.py:1285 core/models.py:1285
msgid "description"
msgstr ""
#: build/lib/core/models.py:1432 core/models.py:1432
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "code"
msgstr ""
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1287 core/models.py:1287
msgid "css"
msgstr ""
#: build/lib/core/models.py:1435 core/models.py:1435
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "public"
msgstr ""
#: build/lib/core/models.py:1437 core/models.py:1437
#: build/lib/core/models.py:1291 core/models.py:1291
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1443 core/models.py:1443
#: build/lib/core/models.py:1297 core/models.py:1297
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1444 core/models.py:1444
#: build/lib/core/models.py:1298 core/models.py:1298
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1497 core/models.py:1497
#: build/lib/core/models.py:1351 core/models.py:1351
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1498 core/models.py:1498
#: build/lib/core/models.py:1352 core/models.py:1352
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1504 core/models.py:1504
#: build/lib/core/models.py:1358 core/models.py:1358
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1510 core/models.py:1510
#: build/lib/core/models.py:1364 core/models.py:1364
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1587 core/models.py:1587
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1606 core/models.py:1606
#: build/lib/core/models.py:1460 core/models.py:1460
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1607 core/models.py:1607
#: build/lib/core/models.py:1461 core/models.py:1461
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1627 core/models.py:1627
#: build/lib/core/models.py:1481 core/models.py:1481
msgid "This email is already associated to a registered user."
msgstr ""
+59 -97
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"Last-Translator: \n"
"Language-Team: Russian\n"
"Language: ru_RU\n"
@@ -50,72 +50,67 @@ msgstr "Скрытый"
msgid "Favorite"
msgstr "Избранное"
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
msgid "A new document was created on your behalf!"
msgstr "Новый документ был создан от вашего имени!"
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
#: build/lib/core/api/serializers.py:500 core/api/serializers.py:500
msgid "You have been granted ownership of a new document:"
msgstr "Вы назначены владельцем для нового документа:"
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
#: build/lib/core/api/serializers.py:536 core/api/serializers.py:536
msgid "This field is required."
msgstr "Это поле обязательное."
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr "Доступ по ссылке '%(link_reach)s' запрещён в соответствии с настройками родительского документа."
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
#: build/lib/core/api/serializers.py:693 core/api/serializers.py:693
msgid "Body"
msgstr "Текст сообщения"
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
#: build/lib/core/api/serializers.py:696 core/api/serializers.py:696
msgid "Body type"
msgstr "Тип сообщения"
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
#: build/lib/core/api/serializers.py:702 core/api/serializers.py:702
msgid "Format"
msgstr "Формат"
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#, python-brace-format
msgid "copy of {title}"
msgstr "копия {title}"
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
#: core/choices.py:43
#: build/lib/core/choices.py:35 build/lib/core/choices.py:42 core/choices.py:35
#: core/choices.py:42
msgid "Reader"
msgstr "Читатель"
#: build/lib/core/choices.py:36 build/lib/core/choices.py:44 core/choices.py:36
#: core/choices.py:44
msgid "Commenter"
msgstr "Комментатор"
#: build/lib/core/choices.py:37 build/lib/core/choices.py:45 core/choices.py:37
#: core/choices.py:45
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
#: core/choices.py:43
msgid "Editor"
msgstr "Редактор"
#: build/lib/core/choices.py:46 core/choices.py:46
#: build/lib/core/choices.py:44 core/choices.py:44
msgid "Administrator"
msgstr "Администратор"
#: build/lib/core/choices.py:47 core/choices.py:47
#: build/lib/core/choices.py:45 core/choices.py:45
msgid "Owner"
msgstr "Владелец"
#: build/lib/core/choices.py:58 core/choices.py:58
#: build/lib/core/choices.py:56 core/choices.py:56
msgid "Restricted"
msgstr "Доступ ограничен"
#: build/lib/core/choices.py:62 core/choices.py:62
#: build/lib/core/choices.py:60 core/choices.py:60
msgid "Authenticated"
msgstr "Аутентификация выполнена"
#: build/lib/core/choices.py:64 core/choices.py:64
#: build/lib/core/choices.py:62 core/choices.py:62
msgid "Public"
msgstr "Доступно всем"
@@ -239,8 +234,8 @@ msgstr "пользователь"
msgid "users"
msgstr "пользователи"
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
msgid "title"
msgstr "заголовок"
@@ -256,188 +251,155 @@ msgstr "Документ"
msgid "Documents"
msgstr "Документы"
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
msgid "Untitled Document"
msgstr "Безымянный документ"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:857 core/models.py:857
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} делится с вами документом!"
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:861 core/models.py:861
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} приглашает вас присоединиться к следующему документу с ролью \"{role}\":"
#: build/lib/core/models.py:869 core/models.py:869
#: build/lib/core/models.py:867 core/models.py:867
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} делится с вами документом: {title}"
#: build/lib/core/models.py:969 core/models.py:969
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document/user link trace"
msgstr "Трассировка связи документ/пользователь"
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:968 core/models.py:968
msgid "Document/user link traces"
msgstr "Трассировка связей документ/пользователь"
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:974 core/models.py:974
msgid "A link trace already exists for this document/user."
msgstr "Для этого документа/пользователя уже существует трассировка ссылки."
#: build/lib/core/models.py:999 core/models.py:999
#: build/lib/core/models.py:997 core/models.py:997
msgid "Document favorite"
msgstr "Избранный документ"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:998 core/models.py:998
msgid "Document favorites"
msgstr "Избранные документы"
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Этот документ уже помечен как избранный для этого пользователя."
#: build/lib/core/models.py:1028 core/models.py:1028
#: build/lib/core/models.py:1026 core/models.py:1026
msgid "Document/user relation"
msgstr "Отношение документ/пользователь"
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1027 core/models.py:1027
msgid "Document/user relations"
msgstr "Отношения документ/пользователь"
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "This user is already in this document."
msgstr "Этот пользователь уже имеет доступ к этому документу."
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This team is already in this document."
msgstr "Эта команда уже имеет доступ к этому документу."
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
msgid "Either user or team must be set, not both."
msgstr "Может быть выбран либо пользователь, либо команда, но не оба варианта сразу."
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1191 core/models.py:1191
msgid "Document ask for access"
msgstr "Документ запрашивает доступ"
#: build/lib/core/models.py:1199 core/models.py:1199
#: build/lib/core/models.py:1192 core/models.py:1192
msgid "Document ask for accesses"
msgstr "Документ запрашивает доступы"
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "This user has already asked for access to this document."
msgstr "Этот пользователь уже запросил доступ к этому документу."
#: build/lib/core/models.py:1262 core/models.py:1262
#: build/lib/core/models.py:1263 core/models.py:1263
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} хочет получить доступ к документу!"
#: build/lib/core/models.py:1266 core/models.py:1266
#: build/lib/core/models.py:1267 core/models.py:1267
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} хочет получить доступ к следующему документу:"
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1273 core/models.py:1273
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} запрашивает доступ к документу: {title}"
#: build/lib/core/models.py:1314 core/models.py:1314
msgid "Thread"
msgstr "Обсуждение"
#: build/lib/core/models.py:1315 core/models.py:1315
msgid "Threads"
msgstr "Обсуждения"
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
msgid "Anonymous"
msgstr "Аноним"
#: build/lib/core/models.py:1365 core/models.py:1365
msgid "Comment"
msgstr "Комментарий"
#: build/lib/core/models.py:1366 core/models.py:1366
msgid "Comments"
msgstr "Комментарии"
#: build/lib/core/models.py:1415 core/models.py:1415
msgid "This emoji has already been reacted to this comment."
msgstr "Этот эмодзи уже использован в этом комментарии."
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "Reaction"
msgstr "Реакция"
#: build/lib/core/models.py:1420 core/models.py:1420
msgid "Reactions"
msgstr "Реакции"
#: build/lib/core/models.py:1431 core/models.py:1431
#: build/lib/core/models.py:1285 core/models.py:1285
msgid "description"
msgstr "описание"
#: build/lib/core/models.py:1432 core/models.py:1432
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "code"
msgstr "код"
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1287 core/models.py:1287
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1435 core/models.py:1435
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "public"
msgstr "доступно всем"
#: build/lib/core/models.py:1437 core/models.py:1437
#: build/lib/core/models.py:1291 core/models.py:1291
msgid "Whether this template is public for anyone to use."
msgstr "Этот шаблон доступен всем пользователям."
#: build/lib/core/models.py:1443 core/models.py:1443
#: build/lib/core/models.py:1297 core/models.py:1297
msgid "Template"
msgstr "Шаблон"
#: build/lib/core/models.py:1444 core/models.py:1444
#: build/lib/core/models.py:1298 core/models.py:1298
msgid "Templates"
msgstr "Шаблоны"
#: build/lib/core/models.py:1497 core/models.py:1497
#: build/lib/core/models.py:1351 core/models.py:1351
msgid "Template/user relation"
msgstr "Отношение шаблон/пользователь"
#: build/lib/core/models.py:1498 core/models.py:1498
#: build/lib/core/models.py:1352 core/models.py:1352
msgid "Template/user relations"
msgstr "Отношения шаблон/пользователь"
#: build/lib/core/models.py:1504 core/models.py:1504
#: build/lib/core/models.py:1358 core/models.py:1358
msgid "This user is already in this template."
msgstr "Этот пользователь уже указан в этом шаблоне."
#: build/lib/core/models.py:1510 core/models.py:1510
#: build/lib/core/models.py:1364 core/models.py:1364
msgid "This team is already in this template."
msgstr "Эта команда уже указана в этом шаблоне."
#: build/lib/core/models.py:1587 core/models.py:1587
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "email address"
msgstr "адрес электронной почты"
#: build/lib/core/models.py:1606 core/models.py:1606
#: build/lib/core/models.py:1460 core/models.py:1460
msgid "Document invitation"
msgstr "Приглашение для документа"
#: build/lib/core/models.py:1607 core/models.py:1607
#: build/lib/core/models.py:1461 core/models.py:1461
msgid "Document invitations"
msgstr "Приглашения для документов"
#: build/lib/core/models.py:1627 core/models.py:1627
#: build/lib/core/models.py:1481 core/models.py:1481
msgid "This email is already associated to a registered user."
msgstr "Этот адрес уже связан с зарегистрированным пользователем."
+59 -97
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"Last-Translator: \n"
"Language-Team: Slovenian\n"
"Language: sl_SI\n"
@@ -50,72 +50,67 @@ msgstr ""
msgid "Favorite"
msgstr "Priljubljena"
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
msgid "A new document was created on your behalf!"
msgstr "Nov dokument je bil ustvarjen v vašem imenu!"
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
#: build/lib/core/api/serializers.py:500 core/api/serializers.py:500
msgid "You have been granted ownership of a new document:"
msgstr "Dodeljeno vam je bilo lastništvo nad novim dokumentom:"
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
#: build/lib/core/api/serializers.py:536 core/api/serializers.py:536
msgid "This field is required."
msgstr ""
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
#: build/lib/core/api/serializers.py:693 core/api/serializers.py:693
msgid "Body"
msgstr "Telo"
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
#: build/lib/core/api/serializers.py:696 core/api/serializers.py:696
msgid "Body type"
msgstr "Vrsta telesa"
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
#: build/lib/core/api/serializers.py:702 core/api/serializers.py:702
msgid "Format"
msgstr "Oblika"
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#, python-brace-format
msgid "copy of {title}"
msgstr ""
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
#: core/choices.py:43
#: build/lib/core/choices.py:35 build/lib/core/choices.py:42 core/choices.py:35
#: core/choices.py:42
msgid "Reader"
msgstr "Bralec"
#: build/lib/core/choices.py:36 build/lib/core/choices.py:44 core/choices.py:36
#: core/choices.py:44
msgid "Commenter"
msgstr ""
#: build/lib/core/choices.py:37 build/lib/core/choices.py:45 core/choices.py:37
#: core/choices.py:45
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
#: core/choices.py:43
msgid "Editor"
msgstr "Urednik"
#: build/lib/core/choices.py:46 core/choices.py:46
#: build/lib/core/choices.py:44 core/choices.py:44
msgid "Administrator"
msgstr "Skrbnik"
#: build/lib/core/choices.py:47 core/choices.py:47
#: build/lib/core/choices.py:45 core/choices.py:45
msgid "Owner"
msgstr "Lastnik"
#: build/lib/core/choices.py:58 core/choices.py:58
#: build/lib/core/choices.py:56 core/choices.py:56
msgid "Restricted"
msgstr "Omejeno"
#: build/lib/core/choices.py:62 core/choices.py:62
#: build/lib/core/choices.py:60 core/choices.py:60
msgid "Authenticated"
msgstr "Preverjeno"
#: build/lib/core/choices.py:64 core/choices.py:64
#: build/lib/core/choices.py:62 core/choices.py:62
msgid "Public"
msgstr "Javno"
@@ -239,8 +234,8 @@ msgstr "uporabnik"
msgid "users"
msgstr "uporabniki"
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
msgid "title"
msgstr "naslov"
@@ -256,188 +251,155 @@ msgstr "Dokument"
msgid "Documents"
msgstr "Dokumenti"
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
msgid "Untitled Document"
msgstr "Dokument brez naslova"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:857 core/models.py:857
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} je delil dokument z vami!"
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:861 core/models.py:861
#, 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:869 core/models.py:869
#: build/lib/core/models.py:867 core/models.py:867
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} je delil dokument z vami: {title}"
#: build/lib/core/models.py:969 core/models.py:969
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document/user link trace"
msgstr "Dokument/sled povezave uporabnika"
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:968 core/models.py:968
msgid "Document/user link traces"
msgstr "Sledi povezav dokumenta/uporabnika"
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:974 core/models.py:974
msgid "A link trace already exists for this document/user."
msgstr "Za ta dokument/uporabnika že obstaja sled povezave."
#: build/lib/core/models.py:999 core/models.py:999
#: build/lib/core/models.py:997 core/models.py:997
msgid "Document favorite"
msgstr "Priljubljeni dokument"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:998 core/models.py:998
msgid "Document favorites"
msgstr "Priljubljeni dokumenti"
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1004 core/models.py:1004
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:1028 core/models.py:1028
#: build/lib/core/models.py:1026 core/models.py:1026
msgid "Document/user relation"
msgstr "Odnos dokument/uporabnik"
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1027 core/models.py:1027
msgid "Document/user relations"
msgstr "Odnosi dokument/uporabnik"
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "This user is already in this document."
msgstr "Ta uporabnik je že v tem dokumentu."
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This team is already in this document."
msgstr "Ta ekipa je že v tem dokumentu."
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
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:1198 core/models.py:1198
#: build/lib/core/models.py:1191 core/models.py:1191
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1199 core/models.py:1199
#: build/lib/core/models.py:1192 core/models.py:1192
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1262 core/models.py:1262
#: build/lib/core/models.py:1263 core/models.py:1263
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1266 core/models.py:1266
#: build/lib/core/models.py:1267 core/models.py:1267
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1273 core/models.py:1273
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1314 core/models.py:1314
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1315 core/models.py:1315
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1365 core/models.py:1365
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1366 core/models.py:1366
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1415 core/models.py:1415
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1420 core/models.py:1420
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1431 core/models.py:1431
#: build/lib/core/models.py:1285 core/models.py:1285
msgid "description"
msgstr "opis"
#: build/lib/core/models.py:1432 core/models.py:1432
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "code"
msgstr "koda"
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1287 core/models.py:1287
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1435 core/models.py:1435
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "public"
msgstr "javno"
#: build/lib/core/models.py:1437 core/models.py:1437
#: build/lib/core/models.py:1291 core/models.py:1291
msgid "Whether this template is public for anyone to use."
msgstr "Ali je ta predloga javna za uporabo."
#: build/lib/core/models.py:1443 core/models.py:1443
#: build/lib/core/models.py:1297 core/models.py:1297
msgid "Template"
msgstr "Predloga"
#: build/lib/core/models.py:1444 core/models.py:1444
#: build/lib/core/models.py:1298 core/models.py:1298
msgid "Templates"
msgstr "Predloge"
#: build/lib/core/models.py:1497 core/models.py:1497
#: build/lib/core/models.py:1351 core/models.py:1351
msgid "Template/user relation"
msgstr "Odnos predloga/uporabnik"
#: build/lib/core/models.py:1498 core/models.py:1498
#: build/lib/core/models.py:1352 core/models.py:1352
msgid "Template/user relations"
msgstr "Odnosi med predlogo in uporabnikom"
#: build/lib/core/models.py:1504 core/models.py:1504
#: build/lib/core/models.py:1358 core/models.py:1358
msgid "This user is already in this template."
msgstr "Ta uporabnik je že v tej predlogi."
#: build/lib/core/models.py:1510 core/models.py:1510
#: build/lib/core/models.py:1364 core/models.py:1364
msgid "This team is already in this template."
msgstr "Ta ekipa je že v tej predlogi."
#: build/lib/core/models.py:1587 core/models.py:1587
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "email address"
msgstr "elektronski naslov"
#: build/lib/core/models.py:1606 core/models.py:1606
#: build/lib/core/models.py:1460 core/models.py:1460
msgid "Document invitation"
msgstr "Vabilo na dokument"
#: build/lib/core/models.py:1607 core/models.py:1607
#: build/lib/core/models.py:1461 core/models.py:1461
msgid "Document invitations"
msgstr "Vabila na dokument"
#: build/lib/core/models.py:1627 core/models.py:1627
#: build/lib/core/models.py:1481 core/models.py:1481
msgid "This email is already associated to a registered user."
msgstr "Ta e-poštni naslov je že povezan z registriranim uporabnikom."
+59 -97
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"Last-Translator: \n"
"Language-Team: Swedish\n"
"Language: sv_SE\n"
@@ -50,72 +50,67 @@ msgstr ""
msgid "Favorite"
msgstr "Favoriter"
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
msgid "A new document was created on your behalf!"
msgstr "Ett nytt dokument skapades åt dig!"
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
#: build/lib/core/api/serializers.py:500 core/api/serializers.py:500
msgid "You have been granted ownership of a new document:"
msgstr "Du har beviljats äganderätt till ett nytt dokument:"
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
#: build/lib/core/api/serializers.py:536 core/api/serializers.py:536
msgid "This field is required."
msgstr ""
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
#: build/lib/core/api/serializers.py:693 core/api/serializers.py:693
msgid "Body"
msgstr ""
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
#: build/lib/core/api/serializers.py:696 core/api/serializers.py:696
msgid "Body type"
msgstr ""
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
#: build/lib/core/api/serializers.py:702 core/api/serializers.py:702
msgid "Format"
msgstr "Format"
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#, python-brace-format
msgid "copy of {title}"
msgstr ""
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
#: core/choices.py:43
#: build/lib/core/choices.py:35 build/lib/core/choices.py:42 core/choices.py:35
#: core/choices.py:42
msgid "Reader"
msgstr ""
#: build/lib/core/choices.py:36 build/lib/core/choices.py:44 core/choices.py:36
#: core/choices.py:44
msgid "Commenter"
msgstr ""
#: build/lib/core/choices.py:37 build/lib/core/choices.py:45 core/choices.py:37
#: core/choices.py:45
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
#: core/choices.py:43
msgid "Editor"
msgstr ""
#: build/lib/core/choices.py:46 core/choices.py:46
#: build/lib/core/choices.py:44 core/choices.py:44
msgid "Administrator"
msgstr "Administratör"
#: build/lib/core/choices.py:47 core/choices.py:47
#: build/lib/core/choices.py:45 core/choices.py:45
msgid "Owner"
msgstr ""
#: build/lib/core/choices.py:58 core/choices.py:58
#: build/lib/core/choices.py:56 core/choices.py:56
msgid "Restricted"
msgstr ""
#: build/lib/core/choices.py:62 core/choices.py:62
#: build/lib/core/choices.py:60 core/choices.py:60
msgid "Authenticated"
msgstr ""
#: build/lib/core/choices.py:64 core/choices.py:64
#: build/lib/core/choices.py:62 core/choices.py:62
msgid "Public"
msgstr "Publik"
@@ -239,8 +234,8 @@ msgstr ""
msgid "users"
msgstr ""
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
msgid "title"
msgstr ""
@@ -256,188 +251,155 @@ msgstr ""
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:857 core/models.py:857
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:861 core/models.py:861
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:869 core/models.py:869
#: build/lib/core/models.py:867 core/models.py:867
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:969 core/models.py:969
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:968 core/models.py:968
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:974 core/models.py:974
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:999 core/models.py:999
#: build/lib/core/models.py:997 core/models.py:997
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:998 core/models.py:998
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1028 core/models.py:1028
#: build/lib/core/models.py:1026 core/models.py:1026
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1027 core/models.py:1027
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1191 core/models.py:1191
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1199 core/models.py:1199
#: build/lib/core/models.py:1192 core/models.py:1192
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1262 core/models.py:1262
#: build/lib/core/models.py:1263 core/models.py:1263
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1266 core/models.py:1266
#: build/lib/core/models.py:1267 core/models.py:1267
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1273 core/models.py:1273
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1314 core/models.py:1314
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1315 core/models.py:1315
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1365 core/models.py:1365
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1366 core/models.py:1366
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1415 core/models.py:1415
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1420 core/models.py:1420
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1431 core/models.py:1431
#: build/lib/core/models.py:1285 core/models.py:1285
msgid "description"
msgstr ""
#: build/lib/core/models.py:1432 core/models.py:1432
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "code"
msgstr ""
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1287 core/models.py:1287
msgid "css"
msgstr ""
#: build/lib/core/models.py:1435 core/models.py:1435
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "public"
msgstr ""
#: build/lib/core/models.py:1437 core/models.py:1437
#: build/lib/core/models.py:1291 core/models.py:1291
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1443 core/models.py:1443
#: build/lib/core/models.py:1297 core/models.py:1297
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1444 core/models.py:1444
#: build/lib/core/models.py:1298 core/models.py:1298
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1497 core/models.py:1497
#: build/lib/core/models.py:1351 core/models.py:1351
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1498 core/models.py:1498
#: build/lib/core/models.py:1352 core/models.py:1352
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1504 core/models.py:1504
#: build/lib/core/models.py:1358 core/models.py:1358
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1510 core/models.py:1510
#: build/lib/core/models.py:1364 core/models.py:1364
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1587 core/models.py:1587
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "email address"
msgstr "e-postadress"
#: build/lib/core/models.py:1606 core/models.py:1606
#: build/lib/core/models.py:1460 core/models.py:1460
msgid "Document invitation"
msgstr "Bjud in dokument"
#: build/lib/core/models.py:1607 core/models.py:1607
#: build/lib/core/models.py:1461 core/models.py:1461
msgid "Document invitations"
msgstr "Inbjudningar dokument"
#: build/lib/core/models.py:1627 core/models.py:1627
#: build/lib/core/models.py:1481 core/models.py:1481
msgid "This email is already associated to a registered user."
msgstr "Denna e-postadress är redan associerad med en registrerad användare."
+59 -97
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"Last-Translator: \n"
"Language-Team: Turkish\n"
"Language: tr_TR\n"
@@ -50,72 +50,67 @@ msgstr ""
msgid "Favorite"
msgstr ""
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
msgid "A new document was created on your behalf!"
msgstr ""
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
#: build/lib/core/api/serializers.py:500 core/api/serializers.py:500
msgid "You have been granted ownership of a new document:"
msgstr ""
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
#: build/lib/core/api/serializers.py:536 core/api/serializers.py:536
msgid "This field is required."
msgstr ""
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
#: build/lib/core/api/serializers.py:693 core/api/serializers.py:693
msgid "Body"
msgstr ""
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
#: build/lib/core/api/serializers.py:696 core/api/serializers.py:696
msgid "Body type"
msgstr ""
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
#: build/lib/core/api/serializers.py:702 core/api/serializers.py:702
msgid "Format"
msgstr ""
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#, python-brace-format
msgid "copy of {title}"
msgstr ""
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
#: core/choices.py:43
#: build/lib/core/choices.py:35 build/lib/core/choices.py:42 core/choices.py:35
#: core/choices.py:42
msgid "Reader"
msgstr ""
#: build/lib/core/choices.py:36 build/lib/core/choices.py:44 core/choices.py:36
#: core/choices.py:44
msgid "Commenter"
msgstr ""
#: build/lib/core/choices.py:37 build/lib/core/choices.py:45 core/choices.py:37
#: core/choices.py:45
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
#: core/choices.py:43
msgid "Editor"
msgstr ""
#: build/lib/core/choices.py:46 core/choices.py:46
#: build/lib/core/choices.py:44 core/choices.py:44
msgid "Administrator"
msgstr ""
#: build/lib/core/choices.py:47 core/choices.py:47
#: build/lib/core/choices.py:45 core/choices.py:45
msgid "Owner"
msgstr ""
#: build/lib/core/choices.py:58 core/choices.py:58
#: build/lib/core/choices.py:56 core/choices.py:56
msgid "Restricted"
msgstr ""
#: build/lib/core/choices.py:62 core/choices.py:62
#: build/lib/core/choices.py:60 core/choices.py:60
msgid "Authenticated"
msgstr ""
#: build/lib/core/choices.py:64 core/choices.py:64
#: build/lib/core/choices.py:62 core/choices.py:62
msgid "Public"
msgstr ""
@@ -239,8 +234,8 @@ msgstr ""
msgid "users"
msgstr ""
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
msgid "title"
msgstr ""
@@ -256,188 +251,155 @@ msgstr ""
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:857 core/models.py:857
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:861 core/models.py:861
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:869 core/models.py:869
#: build/lib/core/models.py:867 core/models.py:867
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:969 core/models.py:969
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:968 core/models.py:968
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:974 core/models.py:974
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:999 core/models.py:999
#: build/lib/core/models.py:997 core/models.py:997
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:998 core/models.py:998
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1028 core/models.py:1028
#: build/lib/core/models.py:1026 core/models.py:1026
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1027 core/models.py:1027
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1191 core/models.py:1191
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1199 core/models.py:1199
#: build/lib/core/models.py:1192 core/models.py:1192
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1262 core/models.py:1262
#: build/lib/core/models.py:1263 core/models.py:1263
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1266 core/models.py:1266
#: build/lib/core/models.py:1267 core/models.py:1267
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1273 core/models.py:1273
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1314 core/models.py:1314
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1315 core/models.py:1315
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1365 core/models.py:1365
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1366 core/models.py:1366
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1415 core/models.py:1415
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1420 core/models.py:1420
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1431 core/models.py:1431
#: build/lib/core/models.py:1285 core/models.py:1285
msgid "description"
msgstr ""
#: build/lib/core/models.py:1432 core/models.py:1432
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "code"
msgstr ""
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1287 core/models.py:1287
msgid "css"
msgstr ""
#: build/lib/core/models.py:1435 core/models.py:1435
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "public"
msgstr ""
#: build/lib/core/models.py:1437 core/models.py:1437
#: build/lib/core/models.py:1291 core/models.py:1291
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1443 core/models.py:1443
#: build/lib/core/models.py:1297 core/models.py:1297
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1444 core/models.py:1444
#: build/lib/core/models.py:1298 core/models.py:1298
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1497 core/models.py:1497
#: build/lib/core/models.py:1351 core/models.py:1351
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1498 core/models.py:1498
#: build/lib/core/models.py:1352 core/models.py:1352
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1504 core/models.py:1504
#: build/lib/core/models.py:1358 core/models.py:1358
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1510 core/models.py:1510
#: build/lib/core/models.py:1364 core/models.py:1364
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1587 core/models.py:1587
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1606 core/models.py:1606
#: build/lib/core/models.py:1460 core/models.py:1460
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1607 core/models.py:1607
#: build/lib/core/models.py:1461 core/models.py:1461
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1627 core/models.py:1627
#: build/lib/core/models.py:1481 core/models.py:1481
msgid "This email is already associated to a registered user."
msgstr ""
+59 -97
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"Last-Translator: \n"
"Language-Team: Ukrainian\n"
"Language: uk_UA\n"
@@ -50,72 +50,67 @@ msgstr "Приховано"
msgid "Favorite"
msgstr "Обране"
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
msgid "A new document was created on your behalf!"
msgstr "Новий документ був створений від вашого імені!"
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
#: build/lib/core/api/serializers.py:500 core/api/serializers.py:500
msgid "You have been granted ownership of a new document:"
msgstr "Ви тепер є власником нового документа:"
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
#: build/lib/core/api/serializers.py:536 core/api/serializers.py:536
msgid "This field is required."
msgstr "Це поле є обов’язковим."
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr "Доступ до посилання '%(link_reach)s' заборонено на основі конфігурації батьківського документа."
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
#: build/lib/core/api/serializers.py:693 core/api/serializers.py:693
msgid "Body"
msgstr "Вміст"
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
#: build/lib/core/api/serializers.py:696 core/api/serializers.py:696
msgid "Body type"
msgstr "Тип вмісту"
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
#: build/lib/core/api/serializers.py:702 core/api/serializers.py:702
msgid "Format"
msgstr "Формат"
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#, python-brace-format
msgid "copy of {title}"
msgstr "копія {title}"
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
#: core/choices.py:43
#: build/lib/core/choices.py:35 build/lib/core/choices.py:42 core/choices.py:35
#: core/choices.py:42
msgid "Reader"
msgstr "Читач"
#: build/lib/core/choices.py:36 build/lib/core/choices.py:44 core/choices.py:36
#: core/choices.py:44
msgid "Commenter"
msgstr "Комментар"
#: build/lib/core/choices.py:37 build/lib/core/choices.py:45 core/choices.py:37
#: core/choices.py:45
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
#: core/choices.py:43
msgid "Editor"
msgstr "Редактор"
#: build/lib/core/choices.py:46 core/choices.py:46
#: build/lib/core/choices.py:44 core/choices.py:44
msgid "Administrator"
msgstr "Адміністратор"
#: build/lib/core/choices.py:47 core/choices.py:47
#: build/lib/core/choices.py:45 core/choices.py:45
msgid "Owner"
msgstr "Власник"
#: build/lib/core/choices.py:58 core/choices.py:58
#: build/lib/core/choices.py:56 core/choices.py:56
msgid "Restricted"
msgstr "Обмежено"
#: build/lib/core/choices.py:62 core/choices.py:62
#: build/lib/core/choices.py:60 core/choices.py:60
msgid "Authenticated"
msgstr "Підтверджено"
#: build/lib/core/choices.py:64 core/choices.py:64
#: build/lib/core/choices.py:62 core/choices.py:62
msgid "Public"
msgstr "Публічне"
@@ -239,8 +234,8 @@ msgstr "користувач"
msgid "users"
msgstr "користувачі"
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
msgid "title"
msgstr "заголовок"
@@ -256,188 +251,155 @@ msgstr "Документ"
msgid "Documents"
msgstr "Документи"
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
msgid "Untitled Document"
msgstr "Документ без назви"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:857 core/models.py:857
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} ділиться з вами документом!"
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:861 core/models.py:861
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} запрошує вас для роботи з документом із роллю \"{role}\":"
#: build/lib/core/models.py:869 core/models.py:869
#: build/lib/core/models.py:867 core/models.py:867
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} ділиться з вами документом: {title}"
#: build/lib/core/models.py:969 core/models.py:969
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document/user link trace"
msgstr "Трасування посилання Документ/користувач"
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:968 core/models.py:968
msgid "Document/user link traces"
msgstr "Трасування посилань Документ/користувач"
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:974 core/models.py:974
msgid "A link trace already exists for this document/user."
msgstr "Відстеження вже існуючих посилань для цього документа/користувача."
#: build/lib/core/models.py:999 core/models.py:999
#: build/lib/core/models.py:997 core/models.py:997
msgid "Document favorite"
msgstr "Обраний документ"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:998 core/models.py:998
msgid "Document favorites"
msgstr "Обрані документи"
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Цей документ вже вказаний як обраний для одного користувача."
#: build/lib/core/models.py:1028 core/models.py:1028
#: build/lib/core/models.py:1026 core/models.py:1026
msgid "Document/user relation"
msgstr "Відносини документ/користувач"
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1027 core/models.py:1027
msgid "Document/user relations"
msgstr "Відносини документ/користувач"
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "This user is already in this document."
msgstr "Цей користувач вже має доступ до цього документу."
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This team is already in this document."
msgstr "Ця команда вже має доступ до цього документа."
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
msgid "Either user or team must be set, not both."
msgstr "Вкажіть користувача або команду, а не обох."
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1191 core/models.py:1191
msgid "Document ask for access"
msgstr "Запит доступу до документа"
#: build/lib/core/models.py:1199 core/models.py:1199
#: build/lib/core/models.py:1192 core/models.py:1192
msgid "Document ask for accesses"
msgstr "Запит доступу для документа"
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "This user has already asked for access to this document."
msgstr "Цей користувач вже попросив доступ до цього документа."
#: build/lib/core/models.py:1262 core/models.py:1262
#: build/lib/core/models.py:1263 core/models.py:1263
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} хоче отримати доступ до документа!"
#: build/lib/core/models.py:1266 core/models.py:1266
#: build/lib/core/models.py:1267 core/models.py:1267
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} бажає отримати доступ до наступного документа:"
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1273 core/models.py:1273
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} запитує доступ до документа: {title}"
#: build/lib/core/models.py:1314 core/models.py:1314
msgid "Thread"
msgstr "Обговорення"
#: build/lib/core/models.py:1315 core/models.py:1315
msgid "Threads"
msgstr "Обговорення"
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
msgid "Anonymous"
msgstr "Анонім"
#: build/lib/core/models.py:1365 core/models.py:1365
msgid "Comment"
msgstr "Коментар"
#: build/lib/core/models.py:1366 core/models.py:1366
msgid "Comments"
msgstr "Коментарі"
#: build/lib/core/models.py:1415 core/models.py:1415
msgid "This emoji has already been reacted to this comment."
msgstr "Цим емодзі вже відреагували на цей коментар."
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "Reaction"
msgstr "Реакція"
#: build/lib/core/models.py:1420 core/models.py:1420
msgid "Reactions"
msgstr "Реакції"
#: build/lib/core/models.py:1431 core/models.py:1431
#: build/lib/core/models.py:1285 core/models.py:1285
msgid "description"
msgstr "опис"
#: build/lib/core/models.py:1432 core/models.py:1432
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "code"
msgstr "код"
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1287 core/models.py:1287
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1435 core/models.py:1435
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "public"
msgstr "публічне"
#: build/lib/core/models.py:1437 core/models.py:1437
#: build/lib/core/models.py:1291 core/models.py:1291
msgid "Whether this template is public for anyone to use."
msgstr "Чи є цей шаблон публічним для будь-кого користувача."
#: build/lib/core/models.py:1443 core/models.py:1443
#: build/lib/core/models.py:1297 core/models.py:1297
msgid "Template"
msgstr "Шаблон"
#: build/lib/core/models.py:1444 core/models.py:1444
#: build/lib/core/models.py:1298 core/models.py:1298
msgid "Templates"
msgstr "Шаблони"
#: build/lib/core/models.py:1497 core/models.py:1497
#: build/lib/core/models.py:1351 core/models.py:1351
msgid "Template/user relation"
msgstr "Відношення шаблон/користувач"
#: build/lib/core/models.py:1498 core/models.py:1498
#: build/lib/core/models.py:1352 core/models.py:1352
msgid "Template/user relations"
msgstr "Відношення шаблон/користувач"
#: build/lib/core/models.py:1504 core/models.py:1504
#: build/lib/core/models.py:1358 core/models.py:1358
msgid "This user is already in this template."
msgstr "Цей користувач вже має доступ до цього шаблону."
#: build/lib/core/models.py:1510 core/models.py:1510
#: build/lib/core/models.py:1364 core/models.py:1364
msgid "This team is already in this template."
msgstr "Ця команда вже має доступ до цього шаблону."
#: build/lib/core/models.py:1587 core/models.py:1587
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "email address"
msgstr "електронна адреса"
#: build/lib/core/models.py:1606 core/models.py:1606
#: build/lib/core/models.py:1460 core/models.py:1460
msgid "Document invitation"
msgstr "Запрошення до редагування документа"
#: build/lib/core/models.py:1607 core/models.py:1607
#: build/lib/core/models.py:1461 core/models.py:1461
msgid "Document invitations"
msgstr "Запрошення до редагування документів"
#: build/lib/core/models.py:1627 core/models.py:1627
#: build/lib/core/models.py:1481 core/models.py:1481
msgid "This email is already associated to a registered user."
msgstr "Ця електронна пошта вже пов'язана з зареєстрованим користувачем."
+59 -97
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"Last-Translator: \n"
"Language-Team: Chinese Simplified\n"
"Language: zh_CN\n"
@@ -50,72 +50,67 @@ msgstr "已屏蔽"
msgid "Favorite"
msgstr "收藏"
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
msgid "A new document was created on your behalf!"
msgstr "已为您创建了一份新文档!"
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
#: build/lib/core/api/serializers.py:500 core/api/serializers.py:500
msgid "You have been granted ownership of a new document:"
msgstr "您已被授予新文档的所有权:"
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
#: build/lib/core/api/serializers.py:536 core/api/serializers.py:536
msgid "This field is required."
msgstr "必填字段。"
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
#: build/lib/core/api/serializers.py:693 core/api/serializers.py:693
msgid "Body"
msgstr "正文"
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
#: build/lib/core/api/serializers.py:696 core/api/serializers.py:696
msgid "Body type"
msgstr "正文类型"
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
#: build/lib/core/api/serializers.py:702 core/api/serializers.py:702
msgid "Format"
msgstr "格式"
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#, python-brace-format
msgid "copy of {title}"
msgstr "{title} 的副本"
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
#: core/choices.py:43
#: build/lib/core/choices.py:35 build/lib/core/choices.py:42 core/choices.py:35
#: core/choices.py:42
msgid "Reader"
msgstr "阅读者"
#: build/lib/core/choices.py:36 build/lib/core/choices.py:44 core/choices.py:36
#: core/choices.py:44
msgid "Commenter"
msgstr ""
#: build/lib/core/choices.py:37 build/lib/core/choices.py:45 core/choices.py:37
#: core/choices.py:45
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
#: core/choices.py:43
msgid "Editor"
msgstr "编辑者"
#: build/lib/core/choices.py:46 core/choices.py:46
#: build/lib/core/choices.py:44 core/choices.py:44
msgid "Administrator"
msgstr "超级管理员"
#: build/lib/core/choices.py:47 core/choices.py:47
#: build/lib/core/choices.py:45 core/choices.py:45
msgid "Owner"
msgstr "所有者"
#: build/lib/core/choices.py:58 core/choices.py:58
#: build/lib/core/choices.py:56 core/choices.py:56
msgid "Restricted"
msgstr "受限的"
#: build/lib/core/choices.py:62 core/choices.py:62
#: build/lib/core/choices.py:60 core/choices.py:60
msgid "Authenticated"
msgstr "已验证"
#: build/lib/core/choices.py:64 core/choices.py:64
#: build/lib/core/choices.py:62 core/choices.py:62
msgid "Public"
msgstr "公开"
@@ -239,8 +234,8 @@ msgstr "用户"
msgid "users"
msgstr "个用户"
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
msgid "title"
msgstr "标题"
@@ -256,188 +251,155 @@ msgstr "文档"
msgid "Documents"
msgstr "个文档"
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
msgid "Untitled Document"
msgstr "未命名文档"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:857 core/models.py:857
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} 与您共享了一个文档!"
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:861 core/models.py:861
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} 邀请您以“{role}”角色访问以下文档:"
#: build/lib/core/models.py:869 core/models.py:869
#: build/lib/core/models.py:867 core/models.py:867
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} 与您共享了一个文档:{title}"
#: build/lib/core/models.py:969 core/models.py:969
#: build/lib/core/models.py:967 core/models.py:967
msgid "Document/user link trace"
msgstr "文档/用户链接跟踪"
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:968 core/models.py:968
msgid "Document/user link traces"
msgstr "个文档/用户链接跟踪"
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:974 core/models.py:974
msgid "A link trace already exists for this document/user."
msgstr "此文档/用户的链接跟踪已存在。"
#: build/lib/core/models.py:999 core/models.py:999
#: build/lib/core/models.py:997 core/models.py:997
msgid "Document favorite"
msgstr "文档收藏"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:998 core/models.py:998
msgid "Document favorites"
msgstr "文档收藏夹"
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "该文档已被同一用户的收藏关系实例关联。"
#: build/lib/core/models.py:1028 core/models.py:1028
#: build/lib/core/models.py:1026 core/models.py:1026
msgid "Document/user relation"
msgstr "文档/用户关系"
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1027 core/models.py:1027
msgid "Document/user relations"
msgstr "文档/用户关系集"
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "This user is already in this document."
msgstr "该用户已在此文档中。"
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This team is already in this document."
msgstr "该团队已在此文档中。"
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
msgid "Either user or team must be set, not both."
msgstr "必须设置用户或团队之一,不能同时设置两者。"
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1191 core/models.py:1191
msgid "Document ask for access"
msgstr "文档需要访问权限"
#: build/lib/core/models.py:1199 core/models.py:1199
#: build/lib/core/models.py:1192 core/models.py:1192
msgid "Document ask for accesses"
msgstr "文档需要访问权限"
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1198 core/models.py:1198
msgid "This user has already asked for access to this document."
msgstr "用户已申请该文档的访问权限。"
#: build/lib/core/models.py:1262 core/models.py:1262
#: build/lib/core/models.py:1263 core/models.py:1263
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} 申请访问文档!"
#: build/lib/core/models.py:1266 core/models.py:1266
#: build/lib/core/models.py:1267 core/models.py:1267
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} 申请访问以下文档:"
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1273 core/models.py:1273
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name}申请文档:{title}的访问权限"
#: build/lib/core/models.py:1314 core/models.py:1314
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1315 core/models.py:1315
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1365 core/models.py:1365
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1366 core/models.py:1366
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1415 core/models.py:1415
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1420 core/models.py:1420
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1431 core/models.py:1431
#: build/lib/core/models.py:1285 core/models.py:1285
msgid "description"
msgstr "说明"
#: build/lib/core/models.py:1432 core/models.py:1432
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "code"
msgstr "代码"
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1287 core/models.py:1287
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1435 core/models.py:1435
#: build/lib/core/models.py:1289 core/models.py:1289
msgid "public"
msgstr "公开"
#: build/lib/core/models.py:1437 core/models.py:1437
#: build/lib/core/models.py:1291 core/models.py:1291
msgid "Whether this template is public for anyone to use."
msgstr "该模板是否公开供任何人使用。"
#: build/lib/core/models.py:1443 core/models.py:1443
#: build/lib/core/models.py:1297 core/models.py:1297
msgid "Template"
msgstr "模板"
#: build/lib/core/models.py:1444 core/models.py:1444
#: build/lib/core/models.py:1298 core/models.py:1298
msgid "Templates"
msgstr "模板"
#: build/lib/core/models.py:1497 core/models.py:1497
#: build/lib/core/models.py:1351 core/models.py:1351
msgid "Template/user relation"
msgstr "模板/用户关系"
#: build/lib/core/models.py:1498 core/models.py:1498
#: build/lib/core/models.py:1352 core/models.py:1352
msgid "Template/user relations"
msgstr "模板/用户关系集"
#: build/lib/core/models.py:1504 core/models.py:1504
#: build/lib/core/models.py:1358 core/models.py:1358
msgid "This user is already in this template."
msgstr "该用户已在此模板中。"
#: build/lib/core/models.py:1510 core/models.py:1510
#: build/lib/core/models.py:1364 core/models.py:1364
msgid "This team is already in this template."
msgstr "该团队已在此模板中。"
#: build/lib/core/models.py:1587 core/models.py:1587
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "email address"
msgstr "电子邮件地址"
#: build/lib/core/models.py:1606 core/models.py:1606
#: build/lib/core/models.py:1460 core/models.py:1460
msgid "Document invitation"
msgstr "文档邀请"
#: build/lib/core/models.py:1607 core/models.py:1607
#: build/lib/core/models.py:1461 core/models.py:1461
msgid "Document invitations"
msgstr "文档邀请"
#: build/lib/core/models.py:1627 core/models.py:1627
#: build/lib/core/models.py:1481 core/models.py:1481
msgid "This email is already associated to a registered user."
msgstr "此电子邮件已经与现有注册用户关联。"
+14 -14
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "4.2.0"
version = "3.9.0"
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",
"boto3==1.40.59",
"Brotli==1.2.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.22",
"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.9",
"django==5.2.8",
"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",
]
@@ -126,20 +126,6 @@ test.describe('Config', () => {
).toBeAttached();
});
test('it checks FRONTEND_JS_URL config', async ({ page }) => {
await overrideConfig(page, {
FRONTEND_JS_URL: 'http://localhost:123465/js/script.js',
});
await page.goto('/');
await expect(
page
.locator('script[src="http://localhost:123465/js/script.js"]')
.first(),
).toBeAttached();
});
test('it checks theme_customization.translations config', async ({
page,
}) => {
@@ -159,6 +145,10 @@ test.describe('Config', () => {
await expect(page.getByText('MyCustomDocs')).toBeAttached();
});
});
test.describe('Config: Not logged', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('it checks the config api is called', async ({ page }) => {
const responsePromise = page.waitForResponse(
@@ -178,10 +168,6 @@ test.describe('Config', () => {
expect(configApi).toStrictEqual(CONFIG_LEFT);
});
});
test.describe('Config: Not logged', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('it checks that theme is configured from config endpoint', async ({
page,
@@ -1,403 +0,0 @@
import { expect, test } from '@playwright/test';
import {
closeHeaderMenu,
createDoc,
getOtherBrowserName,
verifyDocName,
} from './utils-common';
import { getEditor, 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.first().click();
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 = await writeInEditor({ page, text: 'Hello' });
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.first().click();
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.locator('button[data-test="save"]').first();
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 saveBtn.click();
await expect(saveBtn).toBeHidden();
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)',
);
/* Delete the last comment remove the thread */
await editor.getByText('Hello').selectText();
await page.getByRole('button', { name: 'Comment' }).click();
await thread.getByRole('paragraph').first().fill('This is a new comment');
await thread.locator('[data-test="save"]').click();
await expect(editor.getByText('Hello')).toHaveCSS(
'background-color',
'rgba(237, 180, 0, 0.4)',
);
await editor.first().click();
await editor.getByText('Hello').click();
await thread.getByText('This is a new comment').first().hover();
await thread.locator('[data-test="moreactions"]').first().click();
await thread.getByRole('menuitem', { name: 'Delete comment' }).click();
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);
const editor = await getEditor({ page });
// 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(
editor.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();
});
test('it checks comments pasting from another document', async ({
page,
browserName,
}) => {
await createDoc(page, 'comment-doc-1', browserName, 1);
// We add a comment in the first document
const editor1 = await writeInEditor({ page, text: 'Document One' });
await editor1.getByText('Document One').selectText();
await page.getByRole('button', { name: 'Comment' }).click();
const thread1 = page.locator('.bn-thread');
await thread1.getByRole('paragraph').first().fill('Comment in Doc One');
await thread1.locator('[data-test="save"]').click();
await expect(thread1.getByText('Comment in Doc One').first()).toBeHidden();
await expect(editor1.getByText('Document One')).toHaveCSS(
'background-color',
'rgba(237, 180, 0, 0.4)',
);
await editor1.getByText('Document One').click();
// We copy the content including the comment from the first document
await editor1.getByText('Document One').selectText();
await page.keyboard.press('Control+C');
// We create a second document
await createDoc(page, 'comment-doc-2', browserName, 1);
// We paste the content into the second document
const editor2 = await writeInEditor({ page, text: '' });
await editor2.click();
await page.keyboard.press('Control+V');
await expect(editor2.getByText('Document One')).toHaveCSS(
'background-color',
'rgba(0, 0, 0, 0)',
);
await editor2.getByText('Document One').click();
await expect(page.locator('.bn-thread')).toBeHidden();
});
});
test.describe('Doc Comments mobile', () => {
test.use({ viewport: { width: 500, height: 1200 } });
test('Can comments on mobile', async ({ page, browserName }) => {
const [title] = await createDoc(
page,
'comment-mobile',
browserName,
1,
true,
);
await closeHeaderMenu(page);
await verifyDocName(page, title);
// Checks add react reaction
const editor = await writeInEditor({ page, text: 'Hello' });
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.first().click();
await editor.getByText('Hello').click();
await expect(thread.getByText('This is a comment').first()).toBeVisible();
});
});
@@ -7,7 +7,6 @@ import {
randomName,
verifyDocName,
} from './utils-common';
import { connectOtherUserToDoc } from './utils-share';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -74,82 +73,6 @@ test.describe('Doc Create', () => {
page.locator('.c__tree-view--row-content').getByText('Untitled document'),
).toBeVisible();
});
test('it creates a doc with link "/doc/new/', async ({
page,
browserName,
}) => {
test.slow();
// Private doc creation
await page.goto('/docs/new/?title=My+private+doc+from+url');
await verifyDocName(page, 'My private doc from url');
await page.getByRole('button', { name: 'Share' }).click();
await expect(
page.getByTestId('doc-visibility').getByText('Private').first(),
).toBeVisible();
// Public editing doc creation
await page.goto(
'/docs/new/?title=My+public+doc+from+url&link-reach=public&link-role=editor',
);
await verifyDocName(page, 'My public doc from url');
await page.getByRole('button', { name: 'Share' }).click();
await expect(
page.getByTestId('doc-visibility').getByText('Public').first(),
).toBeVisible();
await expect(
page.getByTestId('doc-access-mode').getByText('Editing').first(),
).toBeVisible();
// Authenticated reading doc creation
await page.goto(
'/docs/new/?title=My+authenticated+doc+from+url&link-reach=authenticated&link-role=reader',
);
await verifyDocName(page, 'My authenticated doc from url');
await page.getByRole('button', { name: 'Share' }).click();
await expect(
page.getByTestId('doc-visibility').getByText('Connected').first(),
).toBeVisible();
await expect(
page.getByTestId('doc-access-mode').getByText('Reading').first(),
).toBeVisible();
const { cleanup, otherPage, otherBrowserName } =
await connectOtherUserToDoc({
docUrl:
'/docs/new/?title=From+unlogged+doc+from+url&link-reach=authenticated&link-role=reader',
browserName,
withoutSignIn: true,
});
await keyCloakSignIn(otherPage, otherBrowserName, false);
await verifyDocName(otherPage, 'From unlogged doc from url');
await otherPage.getByRole('button', { name: 'Share' }).click();
await expect(
otherPage.getByTestId('doc-visibility').getByText('Connected').first(),
).toBeVisible();
await expect(
otherPage.getByTestId('doc-access-mode').getByText('Reading').first(),
).toBeVisible();
await cleanup();
});
});
test.describe('Doc Create: Not logged', () => {
@@ -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 }) => {
@@ -802,8 +708,6 @@ test.describe('Doc Editor', () => {
await page.getByText('Symbols').scrollIntoViewIfNeeded();
await expect(page.getByRole('button', { name: '🛃' })).toBeVisible();
await page.keyboard.press('Escape');
await page.locator('.bn-side-menu > button').last().click();
await page.locator('.mantine-Menu-dropdown > button').last().click();
await page.locator('.bn-color-picker-dropdown > button').last().click();
@@ -880,15 +784,14 @@ test.describe('Doc Editor', () => {
// Wait for the interlink to be created and rendered
const editor = await getEditor({ page });
const interlinkChild = editor
.locator('.--docs--interlinking-link-inline-content')
.first();
const interlinkChild2 = editor.getByRole('button', {
name: docChild2,
});
await expect(interlinkChild).toBeVisible({ timeout: 10000 });
await expect(interlinkChild).toContainText('😀');
await expect(interlinkChild).toContainText(docChild2);
await expect(interlinkChild.locator('svg').first()).toBeHidden();
await interlinkChild.click();
await expect(interlinkChild2).toBeVisible({ timeout: 10000 });
await expect(interlinkChild2).toContainText('😀');
await expect(interlinkChild2.locator('svg').first()).toBeHidden();
await interlinkChild2.click();
await verifyDocName(page, docChild2);
@@ -898,9 +801,11 @@ test.describe('Doc Editor', () => {
await input.fill(docChild1);
await searchContainer.getByText(docChild1).click();
await expect(interlinkChild).toContainText(docChild1);
await expect(interlinkChild).toBeVisible({ timeout: 10000 });
await expect(interlinkChild.locator('svg').first()).toBeVisible();
const interlinkChild1 = editor.getByRole('button', {
name: docChild1,
});
await expect(interlinkChild1).toBeVisible({ timeout: 10000 });
await expect(interlinkChild1.locator('svg').first()).toBeVisible();
await page.keyboard.press('@');
@@ -960,35 +865,13 @@ test.describe('Doc Editor', () => {
test('it embeds PDF', async ({ page, browserName }) => {
await createDoc(page, 'doc-toolbar', browserName, 1);
await page.getByRole('button', { name: 'Share' }).click();
await updateShareLink(page, 'Public', 'Reading');
await page.getByRole('button', { name: 'Close the share modal' }).click();
await openSuggestionMenu({ page });
await page.getByText('Embed a PDF file').click();
const pdfBlock = page.locator('div[data-content-type="pdf"]').last();
const pdfBlock = page.locator('div[data-content-type="pdf"]').first();
await expect(pdfBlock).toBeVisible();
// Try with invalid PDF first
await page.getByText(/Add (PDF|file)/).click();
await page.locator('[data-test="embed-tab"]').click();
await page
.locator('[data-test="embed-input"]')
.fill('https://example.test/test.test');
await page.locator('[data-test="embed-input-button"]').click();
await expect(page.getByText('Invalid or missing PDF file')).toBeVisible();
await openSuggestionMenu({ page });
await page.getByText('Embed a PDF file').click();
// Now with a valid PDF
await page.getByText(/Add (PDF|file)/).click();
const fileChooserPromise = page.waitForEvent('filechooser');
const downloadPromise = page.waitForEvent('download');
@@ -1013,50 +896,10 @@ test.describe('Doc Editor', () => {
await expect(pdfEmbed).toHaveAttribute('role', 'presentation');
// Check download with original filename
await pdfBlock.click();
await page.locator('.bn-block-content[data-content-type="pdf"]').click();
await page.locator('[data-test="downloadfile"]').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('test-pdf.pdf');
});
test('it preserves text when switching between mobile and desktop views', async ({
page,
browserName,
}) => {
const [docTitle] = await createDoc(
page,
'doc-viewport-test',
browserName,
1,
);
await verifyDocName(page, docTitle);
const editor = await writeInEditor({
page,
text: 'Hello World - Desktop Text',
});
await expect(editor.getByText('Hello World - Desktop Text')).toBeVisible();
await page.waitForTimeout(500);
// Switch to mobile viewport
await page.setViewportSize({ width: 500, height: 1200 });
await page.waitForTimeout(500);
await expect(editor.getByText('Hello World - Desktop Text')).toBeVisible();
await writeInEditor({
page,
text: 'Mobile Text',
});
await page.waitForTimeout(500);
// Switch back to desktop viewport
await page.setViewportSize({ width: 1280, height: 720 });
await page.waitForTimeout(500);
await expect(editor.getByText('Mobile Text')).toBeVisible();
});
});
@@ -2,7 +2,6 @@ import path from 'path';
import { expect, test } from '@playwright/test';
import cs from 'convert-stream';
import JSZip from 'jszip';
import { PDFParse } from 'pdf-parse';
import {
@@ -32,7 +31,7 @@ test.describe('Doc Export', () => {
await expect(page.getByTestId('modal-export-title')).toBeVisible();
await expect(
page.getByText(/Download your document in a \.docx, \.odt.*format\./i),
page.getByText('Download your document in a .docx or .pdf format.'),
).toBeVisible();
await expect(
page.getByRole('combobox', { name: 'Template' }),
@@ -143,134 +142,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`);
});
test('it exports the doc to html zip', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(
page,
'doc-editor-html-zip',
browserName,
1,
);
await verifyDocName(page, randomDoc);
// Add some content and at least one image so that the ZIP contains media files.
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello HTML ZIP');
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();
// Wait for the image to be attached and have a valid src (aria-hidden prevents toBeVisible on Chromium)
await expect(image).toBeAttached({ timeout: 10000 });
await expect(image).toHaveAttribute('src', /.*\.svg/);
// Give some time for the image to be fully processed
await page.waitForTimeout(1000);
await page
.getByRole('button', {
name: 'Export the document',
})
.click();
await page.getByRole('combobox', { name: 'Format' }).click();
await page.getByRole('option', { name: 'HTML' }).click();
await expect(page.getByTestId('doc-export-download-button')).toBeVisible();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.zip`);
});
void page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDoc}.zip`);
const zipBuffer = await cs.toBuffer(await download.createReadStream());
// Unzip and inspect contents
const zip = await JSZip.loadAsync(zipBuffer);
// Check that index.html exists
const indexHtml = zip.file('index.html');
expect(indexHtml).not.toBeNull();
// Read and verify HTML content
const htmlContent = await indexHtml!.async('string');
expect(htmlContent).toContain('Hello HTML ZIP');
expect(htmlContent).toContain('href="styles.css"');
// Check for media files (they are at the root of the ZIP, not in a media/ folder)
// Media files are named like "1-test.svg" or "media-1.png" by deriveMediaFilename
const allFiles = Object.keys(zip.files);
const mediaFiles = allFiles.filter(
(name) => name !== 'index.html' && !name.endsWith('/'),
);
expect(mediaFiles.length).toBeGreaterThan(0);
// Verify the SVG image is included
const svgFile = mediaFiles.find((name) => name.endsWith('.svg'));
expect(svgFile).toBeDefined();
const styleFile = mediaFiles.find((name) => name === 'styles.css');
expect(styleFile).toBeDefined();
});
/**
* 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
@@ -527,7 +398,7 @@ test.describe('Doc Export', () => {
await verifyDocName(page, docChild);
const editor = await openSuggestionMenu({ page });
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Link a doc').first().click();
const input = page.locator(
@@ -544,11 +415,12 @@ test.describe('Doc Export', () => {
await searchContainer.getByText(randomDoc).click();
// Search the interlinking link in the editor (not in the document tree)
const interlink = editor
.locator('.--docs--interlinking-link-inline-content')
.first();
const editor = page.locator('.ProseMirror.bn-editor');
const interlink = editor.getByRole('button', {
name: randomDoc,
});
await expect(interlink).toContainText(randomDoc);
await expect(interlink).toBeVisible();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${docChild}.pdf`);
@@ -570,67 +442,4 @@ test.describe('Doc Export', () => {
const pdfText = await pdfParse.getText();
expect(pdfText.text).toContain(randomDoc);
});
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);
const editor = await openSuggestionMenu({ page });
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 interlink = editor
.locator('.--docs--interlinking-link-inline-content')
.first();
await expect(interlink).toContainText(randomDoc);
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`);
});
});
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { createDoc, mockedListDocs, toggleHeaderMenu } from './utils-common';
import { createDoc, mockedListDocs } from './utils-common';
import { createRootSubPage } from './utils-sub-pages';
test.describe('Doc grid dnd', () => {
@@ -185,7 +185,10 @@ test.describe('Doc grid dnd mobile', () => {
true,
);
await toggleHeaderMenu(page);
await page
.getByRole('button', { name: 'Open the header menu' })
.getByText('menu')
.click();
await expect(page.locator('.--docs-sub-page-item').first()).toHaveAttribute(
'draggable',
@@ -208,7 +208,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 +217,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(),
@@ -408,6 +408,40 @@ test.describe('Doc Header', () => {
expect(clipboardContent.trim()).toBe('# Hello World');
});
test('It checks the copy as HTML button', async ({ page, browserName }) => {
test.skip(
browserName === 'webkit',
'navigator.clipboard is not working with webkit and playwright',
);
// create page and navigate to it
await page
.getByRole('button', {
name: 'New doc',
})
.click();
// Add dummy content to the doc
const editor = page.locator('.ProseMirror');
const docFirstBlock = editor.locator('.bn-block-content').first();
await docFirstBlock.click();
await page.keyboard.type('# Hello World', { delay: 100 });
const docFirstBlockContent = docFirstBlock.locator('h1');
await expect(docFirstBlockContent).toHaveText('Hello World');
// Copy content to clipboard
await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Copy as HTML' }).click();
await expect(page.getByText('Copied to clipboard')).toBeVisible();
// Test that clipboard is in HTML format
const handle = await page.evaluateHandle(() =>
navigator.clipboard.readText(),
);
const clipboardContent = await handle.jsonValue();
expect(clipboardContent.trim()).toBe(`<h1>Hello World</h1><p></p>`);
});
test('it checks the copy link button', async ({ page, browserName }) => {
test.skip(
browserName === 'webkit',
@@ -548,12 +582,8 @@ test.describe('Doc Header', () => {
await child.hover();
await child.getByText(`more_horiz`).click();
const currentUrl = page.url();
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
await expect(page).not.toHaveURL(new RegExp(currentUrl));
await verifyDocName(page, duplicateTitle);
await expect(
@@ -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();
@@ -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();
@@ -19,8 +19,6 @@ test.describe('Doc Table Content', () => {
await page.locator('.ProseMirror').click();
await expect(page.getByRole('button', { name: 'Summary' })).toBeHidden();
await page.keyboard.type('# Level 1\n## Level 2\n### Level 3');
const summaryContainer = page.locator('#summaryContainer');
@@ -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();
@@ -303,40 +303,6 @@ test.describe('Doc Tree', () => {
await expect(docTree.getByText(docChild)).toBeVisible();
});
test('keyboard navigation with F2 focuses root actions button', async ({
page,
browserName,
}) => {
// Create a parent document to initialize the tree
const [docParent] = await createDoc(
page,
'doc-tree-keyboard-f2-root',
browserName,
1,
);
await verifyDocName(page, docParent);
const docTree = page.getByTestId('doc-tree');
await expect(docTree).toBeVisible();
const rootItem = page.getByTestId('doc-tree-root-item');
await expect(rootItem).toBeVisible();
// Focus the root item
await rootItem.focus();
await expect(rootItem).toBeFocused();
// Press F2 → focus should move to the root actions \"More options\" button
await page.keyboard.press('F2');
const rootActions = rootItem.locator('.doc-tree-root-item-actions');
const rootMoreOptionsButton = rootActions.getByRole('button', {
name: /more options/i,
});
await expect(rootMoreOptionsButton).toBeFocused();
});
test('it updates the child icon from the tree', async ({
page,
browserName,
@@ -6,7 +6,6 @@ import {
mockedDocument,
verifyDocName,
} from './utils-common';
import { openSuggestionMenu, writeInEditor } from './utils-editor';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -18,7 +17,6 @@ test.describe('Doc Version', () => {
await verifyDocName(page, randomDoc);
// Initially, there is no version
await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Version history' }).click();
await expect(page.getByText('History', { exact: true })).toBeVisible();
@@ -28,49 +26,31 @@ test.describe('Doc Version', () => {
await expect(panel).toBeVisible();
await expect(modal.getByText('No versions')).toBeVisible();
const editor = page.locator('.ProseMirror');
await modal.getByRole('button', { name: 'close' }).click();
await editor.click();
await page.keyboard.type('# Hello World');
await writeInEditor({ page, text: 'Hello World' });
// It will trigger a save, no version created yet (initial version is not counted)
await goToGridDoc(page, {
title: randomDoc,
});
await expect(page.getByText('Hello World')).toBeVisible();
// Write more
await writeInEditor({ page, text: 'It will create a version' });
await openSuggestionMenu({ page });
await page.getByText('Add a callout block').click();
const calloutBlock = page
.locator('div[data-content-type="callout"]')
.first();
await expect(calloutBlock).toBeVisible();
// It will trigger a save and create a version this time
await goToGridDoc(page, {
title: randomDoc,
});
await expect(page.getByText('Hello World')).toBeHidden();
await expect(page.getByText('It will create a version')).toBeVisible();
await expect(calloutBlock).toBeVisible();
// Write more
await writeInEditor({ page, text: 'It will create a second version' });
// It will trigger a save and create a second version
await goToGridDoc(page, {
title: randomDoc,
});
await expect(
page.getByText('It will create a second version'),
page.getByRole('heading', { name: 'Hello World' }),
).toBeVisible();
await page
.locator('.ProseMirror .bn-block')
.getByRole('heading', { name: 'Hello World' })
.fill('It will create a version');
await goToGridDoc(page, {
title: randomDoc,
});
await expect(page.getByText('Hello World')).toBeHidden();
await expect(
page.getByRole('heading', { name: 'It will create a version' }),
).toBeVisible();
await page.getByLabel('Open the document options').click();
@@ -80,33 +60,11 @@ test.describe('Doc Version', () => {
await expect(page.getByText('History', { exact: true })).toBeVisible();
await expect(page.getByRole('status')).toBeHidden();
const items = await panel.locator('.version-item').all();
expect(items.length).toBe(2);
await items[1].click();
await expect(modal.getByText('Hello World')).toBeVisible();
await expect(modal.getByText('It will create a version')).toBeHidden();
await expect(
modal.locator('div[data-content-type="callout"]').first(),
).toBeHidden();
expect(items.length).toBe(1);
await items[0].click();
await expect(modal.getByText('Hello World')).toBeVisible();
await expect(modal.getByText('It will create a version')).toBeVisible();
await expect(
modal.locator('div[data-content-type="callout"]').first(),
).toBeVisible();
await expect(
modal.getByText('It will create a second version'),
).toBeHidden();
await items[1].click();
await expect(modal.getByText('Hello World')).toBeVisible();
await expect(modal.getByText('It will create a version')).toBeHidden();
await expect(
modal.locator('div[data-content-type="callout"]').first(),
).toBeHidden();
});
test('it does not display the doc versions if not allowed', async ({
@@ -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: '',
},
},
@@ -177,27 +176,3 @@ test.describe('Header: Override configuration', () => {
await expect(logoImage).toHaveAttribute('alt', '');
});
});
test.describe('Header: Skip to Content', () => {
test('it displays skip link on first TAB and focuses main content on click', async ({
page,
}) => {
await page.goto('/');
// Wait for skip button to be mounted (client-side only component)
const skipButton = page.getByRole('button', { name: 'Go to content' });
await skipButton.waitFor({ state: 'attached' });
// First TAB shows the skip button
await page.keyboard.press('Tab');
// The skip button should be visible and focused
await expect(skipButton).toBeFocused();
await expect(skipButton).toBeVisible();
// Clicking moves focus to the main content
await skipButton.click();
const mainContent = page.locator('main#mainContent');
await expect(mainContent).toBeFocused();
});
});
@@ -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();
@@ -66,7 +66,6 @@ test.describe('Language', () => {
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await page.keyboard.press('Enter');
@@ -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();
@@ -10,7 +10,6 @@ export const CONFIG = {
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY: true,
ENVIRONMENT: 'development',
FRONTEND_CSS_URL: null,
FRONTEND_JS_URL: null,
FRONTEND_HOMEPAGE_FEATURE_ENABLED: true,
FRONTEND_THEME: null,
MEDIA_BASE_URL: 'http://localhost:8083',
@@ -71,47 +70,11 @@ 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}`;
});
export const openHeaderMenu = async (page: Page) => {
const toggleButton = page.getByTestId('header-menu-toggle');
await expect(toggleButton).toBeVisible();
const isExpanded =
(await toggleButton.getAttribute('aria-expanded')) === 'true';
if (!isExpanded) {
await toggleButton.click();
}
};
export const closeHeaderMenu = async (page: Page) => {
const toggleButton = page.getByTestId('header-menu-toggle');
await expect(toggleButton).toBeVisible();
const isExpanded =
(await toggleButton.getAttribute('aria-expanded')) === 'true';
if (isExpanded) {
await toggleButton.click();
}
};
export const toggleHeaderMenu = async (page: Page) => {
const toggleButton = page.getByTestId('header-menu-toggle');
await expect(toggleButton).toBeVisible();
await toggleButton.click();
};
export const createDoc = async (
page: Page,
docName: string,
@@ -123,7 +86,10 @@ export const createDoc = async (
for (let i = 0; i < randomDocs.length; i++) {
if (isMobile) {
await openHeaderMenu(page);
await page
.getByRole('button', { name: 'Open the header menu' })
.getByText('menu')
.click();
}
await page
@@ -159,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();
}
@@ -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({
@@ -2,8 +2,6 @@ import { Page, expect } from '@playwright/test';
import {
BrowserName,
closeHeaderMenu,
openHeaderMenu,
randomName,
updateDocTitle,
verifyDocName,
@@ -17,7 +15,10 @@ export const createRootSubPage = async (
isMobile = false,
) => {
if (isMobile) {
await openHeaderMenu(page);
await page
.getByRole('button', { name: 'Open the header menu' })
.getByText('menu')
.click();
}
// Get response
@@ -28,7 +29,10 @@ export const createRootSubPage = async (
const subPageJson = (await response.json()) as { id: string };
if (isMobile) {
await openHeaderMenu(page);
await page
.getByRole('button', { name: 'Open the header menu' })
.getByText('menu')
.click();
}
// Get doc tree
@@ -40,9 +44,13 @@ export const createRootSubPage = async (
.getByTestId(`doc-sub-page-item-${subPageJson.id}`)
.first();
await expect(subPageItem).toBeVisible();
await subPageItem.click();
if (isMobile) {
await closeHeaderMenu(page);
await page
.getByRole('button', { name: 'Open the header menu' })
.getByText('close')
.click();
}
// Update sub page name
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "4.2.0",
"version": "3.9.0",
"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.57.0",
"@playwright/test": "1.56.1",
"@types/node": "*",
"@types/pdf-parse": "1.1.5",
"eslint-plugin-docs": "*",
+1 -1
View File
@@ -6,7 +6,7 @@ server {
root /usr/share/nginx/html;
location / {
try_files $uri index.html $uri/index.html =404;
try_files $uri index.html $uri/ =404;
add_header X-Frame-Options DENY always;
}
+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)',
},
},
};
+31 -32
View File
@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "4.2.0",
"version": "3.9.0",
"repository": "https://github.com/suitenumerique/docs",
"author": "DINUM",
"license": "MIT",
@@ -19,31 +19,30 @@
},
"dependencies": {
"@ag-media/react-pdf-table": "2.0.3",
"@blocknote/code-block": "0.45.0",
"@blocknote/core": "0.45.0",
"@blocknote/mantine": "0.45.0",
"@blocknote/react": "0.45.0",
"@blocknote/xl-docx-exporter": "0.45.0",
"@blocknote/xl-multi-column": "0.45.0",
"@blocknote/xl-odt-exporter": "0.45.0",
"@blocknote/xl-pdf-exporter": "0.45.0",
"@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.30",
"@fontsource-variable/material-symbols-outlined": "5.2.28",
"@fontsource/material-icons": "5.2.7",
"@gouvfr-lasuite/integration": "1.0.3",
"@gouvfr-lasuite/ui-kit": "0.18.4",
"@hocuspocus/provider": "3.4.3",
"@mantine/core": "8.3.10",
"@mantine/hooks": "8.3.10",
"@openfun/cunningham-react": "4.0.0",
"@gouvfr-lasuite/ui-kit": "0.16.2",
"@hocuspocus/provider": "3.4.0",
"@mantine/core": "8.3.6",
"@mantine/hooks": "8.3.6",
"@openfun/cunningham-react": "3.2.3",
"@react-pdf/renderer": "4.3.1",
"@sentry/nextjs": "10.30.0",
"@tanstack/react-query": "5.90.12",
"@tiptap/extensions": "*",
"@sentry/nextjs": "10.22.0",
"@tanstack/react-query": "5.90.6",
"@tiptap/extensions": "3.10.1",
"canvg": "4.0.3",
"clsx": "2.1.1",
"cmdk": "1.1.1",
@@ -52,17 +51,17 @@
"emoji-datasource-apple": "16.0.0",
"emoji-mart": "5.6.0",
"emoji-regex": "10.6.0",
"i18next": "25.7.2",
"i18next": "25.6.0",
"i18next-browser-languagedetector": "8.2.0",
"idb": "8.0.3",
"lodash": "4.17.21",
"luxon": "3.7.2",
"next": "15.5.9",
"posthog-js": "1.306.1",
"next": "15.5.4",
"posthog-js": "1.284.0",
"react": "*",
"react-aria-components": "1.13.0",
"react-dom": "*",
"react-i18next": "16.5.0",
"react-i18next": "16.2.3",
"react-intersection-observer": "10.0.0",
"react-resizable-panels": "3.0.6",
"react-select": "5.10.2",
@@ -70,36 +69,36 @@
"use-debounce": "10.0.6",
"y-protocols": "1.0.6",
"yjs": "*",
"zustand": "5.0.9"
"zustand": "5.0.8"
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.91.1",
"@tanstack/react-query-devtools": "5.90.2",
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.0",
"@testing-library/user-event": "14.6.1",
"@types/lodash": "4.17.21",
"@types/lodash": "4.17.20",
"@types/luxon": "3.7.1",
"@types/node": "*",
"@types/react": "*",
"@types/react-dom": "*",
"@vitejs/plugin-react": "5.1.2",
"@vitejs/plugin-react": "5.1.0",
"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.3.0",
"jsdom": "27.1.0",
"node-fetch": "2.7.0",
"prettier": "3.7.4",
"stylelint": "16.26.1",
"prettier": "3.6.2",
"stylelint": "16.25.0",
"stylelint-config-standard": "39.0.1",
"stylelint-prettier": "5.0.3",
"typescript": "*",
"vite-tsconfig-paths": "6.0.1",
"vitest": "4.0.15",
"webpack": "5.103.0",
"vite-tsconfig-paths": "5.1.4",
"vitest": "4.0.6",
"webpack": "5.102.1",
"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.

Before

Width:  |  Height:  |  Size: 932 B

After

Width:  |  Height:  |  Size: 992 B

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

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