mirror of
https://github.com/suitenumerique/docs.git
synced 2026-04-26 01:25:05 +02:00
Compare commits
84 Commits
feat/e2e-e
...
sbl-public
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
270b374a17 | ||
|
|
ea3a4a6da3 | ||
|
|
b78ad27a71 | ||
|
|
e4b8ffb304 | ||
|
|
78c7ab247b | ||
|
|
b0bd6e2c01 | ||
|
|
37527416f2 | ||
|
|
30bc959340 | ||
|
|
a73d9c1c78 | ||
|
|
a920daf05b | ||
|
|
ff88465398 | ||
|
|
3617e4f7b8 | ||
|
|
efaec45bfd | ||
|
|
715d88ba3c | ||
|
|
7d64d79eeb | ||
|
|
2e66b87dab | ||
|
|
fb368ef86f | ||
|
|
e340463d35 | ||
|
|
344e9a83e4 | ||
|
|
48aa4971ec | ||
|
|
d47b5e6a90 | ||
|
|
c24f46067b | ||
|
|
f5a9ef2643 | ||
|
|
780bcb360a | ||
|
|
65d572ccd6 | ||
|
|
4644bb4f47 | ||
|
|
de3dfbb0c7 | ||
|
|
b0e7a511cb | ||
|
|
044c1495a9 | ||
|
|
6f282ec5d6 | ||
|
|
580d25b79f | ||
|
|
a48f61e583 | ||
|
|
331a94ad2f | ||
|
|
01c31ddd74 | ||
|
|
bf978b5376 | ||
|
|
24460ffc3a | ||
|
|
d721b97f68 | ||
|
|
3228f65092 | ||
|
|
6ba473f858 | ||
|
|
72238c1ab6 | ||
|
|
1d9c2a8118 | ||
|
|
f4bdde7e59 | ||
|
|
4dc3322b0d | ||
|
|
23216d549e | ||
|
|
2f612dbc2f | ||
|
|
bbf834fb6e | ||
|
|
4cf0e15406 | ||
|
|
31bd475418 | ||
|
|
08fb191e6b | ||
|
|
a49f3b6b32 | ||
|
|
bd9a3334db | ||
|
|
96299f4b7f | ||
|
|
52bd31c0d5 | ||
|
|
35be4be158 | ||
|
|
05aa225aed | ||
|
|
d65d0d1450 | ||
|
|
b11d3acd01 | ||
|
|
8091cbca23 | ||
|
|
12cc79b640 | ||
|
|
af15e77713 | ||
|
|
99131dc917 | ||
|
|
90651a8ea6 | ||
|
|
9c575e397c | ||
|
|
a6b472aa51 | ||
|
|
9fcc221b33 | ||
|
|
acdde81a3d | ||
|
|
9b03754f88 | ||
|
|
0805216cc6 | ||
|
|
5e398e8e79 | ||
|
|
00ae7fdd60 | ||
|
|
8036f16cc3 | ||
|
|
54fe70d662 | ||
|
|
1e37007be9 | ||
|
|
77df9783b7 | ||
|
|
350fe17918 | ||
|
|
a0ddc6ba0c | ||
|
|
92d3f634cb | ||
|
|
c06bc6fd21 | ||
|
|
80ee409da4 | ||
|
|
7475b7c3bc | ||
|
|
c13f0e97bb | ||
|
|
f11543094a | ||
|
|
b1fb400d70 | ||
|
|
50848b3410 |
24
.github/actions/free-disk-space/action.yml
vendored
Normal file
24
.github/actions/free-disk-space/action.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
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
.github/workflows/docker-hub.yml
vendored
18
.github/workflows/docker-hub.yml
vendored
@@ -52,6 +52,12 @@ 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
|
||||
@@ -91,6 +97,12 @@ 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
|
||||
@@ -125,6 +137,12 @@ 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:
|
||||
|
||||
6
.github/workflows/impress-frontend.yml
vendored
6
.github/workflows/impress-frontend.yml
vendored
@@ -85,6 +85,9 @@ 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'
|
||||
|
||||
@@ -124,6 +127,9 @@ 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'
|
||||
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -43,6 +43,10 @@ venv.bak/
|
||||
env.d/development/*.local
|
||||
env.d/terraform
|
||||
|
||||
# Docker
|
||||
compose.override.yml
|
||||
docker/auth/*.local
|
||||
|
||||
# npm
|
||||
node_modules
|
||||
|
||||
|
||||
78
CHANGELOG.md
78
CHANGELOG.md
@@ -8,20 +8,85 @@ and this project adheres to
|
||||
|
||||
### 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
|
||||
- 💥(frontend) upgrade to ui-kit v2 #1605
|
||||
- ⚡️(frontend) improve perf on upload and table of contents #1662
|
||||
|
||||
### Fixed
|
||||
|
||||
- ♿(frontend) improve accessibility:
|
||||
- ♿(frontend) improve share modal button accessibility #1626
|
||||
- ♿(frontend) improve screen reader support in DocShare modal #1628
|
||||
|
||||
### 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
|
||||
@@ -897,7 +962,10 @@ and this project adheres to
|
||||
- ✨(frontend) Coming Soon page (#67)
|
||||
- 🚀 Impress, project to manage your documents easily and collaboratively.
|
||||
|
||||
[unreleased]: https://github.com/suitenumerique/docs/compare/v3.10.0...main
|
||||
[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
|
||||
[v3.9.0]: https://github.com/suitenumerique/docs/releases/v3.9.0
|
||||
[v3.8.2]: https://github.com/suitenumerique/docs/releases/v3.8.2
|
||||
|
||||
4
Makefile
4
Makefile
@@ -247,6 +247,10 @@ demo: ## flush db then create a demo for load testing purpose
|
||||
@$(MANAGE) create_demo
|
||||
.PHONY: demo
|
||||
|
||||
index: ## index all documents to remote search
|
||||
@$(MANAGE) index
|
||||
.PHONY: index
|
||||
|
||||
# Nota bene: Black should come after isort just in case they don't agree...
|
||||
lint: ## lint back-end python sources
|
||||
lint: \
|
||||
|
||||
23
UPGRADE.md
23
UPGRADE.md
@@ -16,6 +16,29 @@ 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.
|
||||
|
||||
6
bin/fernetkey
Executable file
6
bin/fernetkey
Executable file
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# shellcheck source=bin/_config.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
_dc_run app-dev python -c 'from cryptography.fernet import Fernet;import sys; sys.stdout.write("\n" + Fernet.generate_key().decode() + "\n");'
|
||||
18
compose.yml
18
compose.yml
@@ -72,6 +72,11 @@ services:
|
||||
- env.d/development/postgresql.local
|
||||
ports:
|
||||
- "8071:8000"
|
||||
networks:
|
||||
default: {}
|
||||
lasuite:
|
||||
aliases:
|
||||
- impress
|
||||
volumes:
|
||||
- ./src/backend:/app
|
||||
- ./data/static:/data/static
|
||||
@@ -92,6 +97,9 @@ services:
|
||||
command: ["celery", "-A", "impress.celery_app", "worker", "-l", "DEBUG"]
|
||||
environment:
|
||||
- DJANGO_CONFIGURATION=Development
|
||||
networks:
|
||||
- default
|
||||
- lasuite
|
||||
env_file:
|
||||
- env.d/development/common
|
||||
- env.d/development/common.local
|
||||
@@ -107,6 +115,11 @@ services:
|
||||
image: nginx:1.25
|
||||
ports:
|
||||
- "8083:8083"
|
||||
networks:
|
||||
default: {}
|
||||
lasuite:
|
||||
aliases:
|
||||
- nginx
|
||||
volumes:
|
||||
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
depends_on:
|
||||
@@ -217,3 +230,8 @@ services:
|
||||
kc_postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
|
||||
networks:
|
||||
lasuite:
|
||||
name: lasuite-network
|
||||
driver: bridge
|
||||
|
||||
@@ -12,6 +12,7 @@ flowchart TD
|
||||
Back --> DB("Database (PostgreSQL)")
|
||||
Back <--> Celery --> DB
|
||||
Back ----> S3("Minio (S3)")
|
||||
Back -- REST API --> Find
|
||||
```
|
||||
|
||||
### Architecture decision records
|
||||
|
||||
204
docs/env.md
204
docs/env.md
@@ -6,103 +6,113 @@ 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_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 |
|
||||
| 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 | |
|
||||
| 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 | |
|
||||
|
||||
|
||||
## impress-frontend image
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
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
|
||||
@@ -6,86 +15,98 @@ 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://impress.127.0.0.1.nip.io
|
||||
DJANGO_CSRF_TRUSTED_ORIGINS: https://docs.127.0.0.1.nip.io
|
||||
DJANGO_CONFIGURATION: Feature
|
||||
DJANGO_ALLOWED_HOSTS: impress.127.0.0.1.nip.io
|
||||
DJANGO_ALLOWED_HOSTS: docs.127.0.0.1.nip.io
|
||||
DJANGO_SERVER_TO_SERVER_API_TOKENS: secret-api-key
|
||||
DJANGO_SECRET_KEY: AgoodOrAbadKey
|
||||
DJANGO_SECRET_KEY: *djangoSecretKey
|
||||
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://impress.127.0.0.1.nip.io/assets/logo-suite-numerique.png
|
||||
DJANGO_EMAIL_LOGO_IMG: https://docs.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_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_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_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
|
||||
OIDC_RP_SIGN_ALGO: RS256
|
||||
OIDC_RP_SCOPES: "openid email"
|
||||
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
|
||||
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
|
||||
DB_PORT: 5432
|
||||
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
|
||||
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
|
||||
AWS_S3_SECRET_ACCESS_KEY: password
|
||||
AWS_STORAGE_BUCKET_NAME: impress-media-storage
|
||||
AWS_STORAGE_BUCKET_NAME: docs-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"
|
||||
- |
|
||||
python manage.py migrate --no-input &&
|
||||
python manage.py create_demo --force
|
||||
restartPolicy: Never
|
||||
while ! python manage.py check --database default > /dev/null 2>&1
|
||||
do
|
||||
echo "Database not ready"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
command:
|
||||
- "gunicorn"
|
||||
- "-c"
|
||||
- "/usr/local/etc/gunicorn/impress.py"
|
||||
- "impress.wsgi:application"
|
||||
- "--reload"
|
||||
echo "Database is ready"
|
||||
|
||||
python manage.py migrate --no-input
|
||||
restartPolicy: Never
|
||||
|
||||
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 to manage our local custom CA and avoid to set ssl_verify: false
|
||||
# Extra volume mounts to manage our local custom CA and avoid to set ssl_verify: false
|
||||
extraVolumeMounts:
|
||||
- name: certs
|
||||
mountPath: /cert/cacert.pem
|
||||
subPath: cacert.pem
|
||||
|
||||
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
|
||||
# Extra volumes to manage our local custom CA and avoid to set ssl_verify: false
|
||||
extraVolumes:
|
||||
- name: certs
|
||||
configMap:
|
||||
@@ -94,12 +115,7 @@ 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
|
||||
@@ -114,60 +130,47 @@ yProvider:
|
||||
tag: "latest"
|
||||
|
||||
envVars:
|
||||
COLLABORATION_BACKEND_BASE_URL: https://docs.127.0.0.1.nip.io
|
||||
COLLABORATION_LOGGING: true
|
||||
COLLABORATION_SERVER_ORIGIN: https://impress.127.0.0.1.nip.io
|
||||
COLLABORATION_SERVER_ORIGIN: https://docs.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
|
||||
|
||||
# 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
|
||||
ingress:
|
||||
enabled: true
|
||||
host: docs.127.0.0.1.nip.io
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: 100m
|
||||
|
||||
extraVolumes:
|
||||
- name: certs
|
||||
configMap:
|
||||
name: certifi
|
||||
items:
|
||||
- key: cacert.pem
|
||||
path: 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
|
||||
|
||||
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: impress.127.0.0.1.nip.io
|
||||
host: docs.127.0.0.1.nip.io
|
||||
|
||||
annotations:
|
||||
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-url: https://docs.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.impress.svc.cluster.local:9000
|
||||
nginx.ingress.kubernetes.io/rewrite-target: /impress-media-storage/$1
|
||||
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
|
||||
|
||||
serviceMedia:
|
||||
host: minio.impress.svc.cluster.local
|
||||
host: minio-dev-backend-minio.impress.svc.cluster.local
|
||||
port: 9000
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,24 @@
|
||||
auth:
|
||||
rootUser: root
|
||||
rootPassword: password
|
||||
provisioning:
|
||||
minio:
|
||||
enabled: true
|
||||
buckets:
|
||||
- name: impress-media-storage
|
||||
versioning: 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
|
||||
@@ -1,7 +1,9 @@
|
||||
auth:
|
||||
postgres:
|
||||
enabled: true
|
||||
name: postgres
|
||||
#serviceNameOverride: postgres
|
||||
image: postgres:16-alpine
|
||||
username: dinum
|
||||
password: pass
|
||||
database: impress
|
||||
tls:
|
||||
enabled: true
|
||||
autoGenerated: true
|
||||
database: dinum
|
||||
size: 1Gi
|
||||
@@ -1,4 +1,7 @@
|
||||
auth:
|
||||
password: pass
|
||||
architecture: standalone
|
||||
|
||||
redis:
|
||||
enabled: true
|
||||
name: redis
|
||||
#serviceNameOverride: redis
|
||||
image: redis:8.2-alpine
|
||||
username: user
|
||||
password: pass
|
||||
@@ -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 Memcached 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 S3 bucket (if you don't have one, we provide an example)
|
||||
|
||||
### Test cluster
|
||||
@@ -100,50 +100,66 @@ 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).
|
||||
|
||||
```
|
||||
$ 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
|
||||
$ helm install --repo https://suitenumerique.github.io/helm-dev-backend -f docs/examples/helm/keycloak.values.yaml keycloak dev-backend
|
||||
$ #wait until
|
||||
$ kubectl get po
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
keycloak-0 1/1 Running 0 6m48s
|
||||
keycloak-postgresql-0 1/1 Running 0 6m48s
|
||||
$ 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
|
||||
```
|
||||
|
||||
From here the important information you will need are:
|
||||
|
||||
```yaml
|
||||
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_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_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/keycloak.values.yaml**
|
||||
You can find these values in **examples/helm/keycloak.values.yaml**
|
||||
|
||||
### Find redis server connection values
|
||||
|
||||
Docs needs a redis so we start by deploying one:
|
||||
|
||||
```
|
||||
$ 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
|
||||
$ 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
|
||||
```
|
||||
|
||||
### Find postgresql connection values
|
||||
@@ -151,22 +167,32 @@ redis-master-0 1/1 Running 0 35s
|
||||
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 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
|
||||
$ 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
|
||||
|
||||
```
|
||||
|
||||
From here the important information you will need are:
|
||||
|
||||
```yaml
|
||||
DB_HOST: postgres-postgresql
|
||||
DB_NAME: impress
|
||||
DB_USER: dinum
|
||||
DB_PASSWORD: pass
|
||||
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_PORT: 5432
|
||||
```
|
||||
|
||||
@@ -175,15 +201,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 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
|
||||
$ 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
|
||||
|
||||
```
|
||||
|
||||
## Deployment
|
||||
@@ -193,20 +219,18 @@ 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 examples/impress.values.yaml
|
||||
$ helm install impress impress/docs -f docs/examples/helm/impress.values.yaml
|
||||
$ kubectl get po
|
||||
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
|
||||
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
|
||||
```
|
||||
|
||||
## Test your deployment
|
||||
@@ -215,13 +239,15 @@ 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> 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
|
||||
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
|
||||
```
|
||||
|
||||
You can use Docs at https://impress.127.0.0.1.nip.io. The provisionning user in keycloak is impress/impress.
|
||||
You can use Docs at https://docs.127.0.0.1.nip.io. The provisionning user in keycloak is docs/docs.
|
||||
|
||||
41
docs/search.md
Normal file
41
docs/search.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Setup the Find search for Impress
|
||||
|
||||
This configuration will enable the fulltext search feature for Docs :
|
||||
- Each save on **core.Document** or **core.DocumentAccess** will trigger the indexer
|
||||
- The `api/v1.0/documents/search/` will work as a proxy with the Find API for fulltext search.
|
||||
|
||||
## Create an index service for Docs
|
||||
|
||||
Configure a **Service** for Docs application with these settings
|
||||
|
||||
- **Name**: `docs`<br>_request.auth.name of the Docs application._
|
||||
- **Client id**: `impress`<br>_Name of the token audience or client_id of the Docs application._
|
||||
|
||||
See [how-to-use-indexer.md](how-to-use-indexer.md) for details.
|
||||
|
||||
## Configure settings of Docs
|
||||
|
||||
Add those Django settings the Docs application to enable the feature.
|
||||
|
||||
```shell
|
||||
SEARCH_INDEXER_CLASS="core.services.search_indexers.FindDocumentIndexer"
|
||||
SEARCH_INDEXER_COUNTDOWN=10 # Debounce delay in seconds for the indexer calls.
|
||||
|
||||
# The token from service "docs" of Find application (development).
|
||||
SEARCH_INDEXER_SECRET="find-api-key-for-docs-with-exactly-50-chars-length"
|
||||
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.
|
||||
|
||||
```shell
|
||||
# Store OIDC tokens in the session
|
||||
OIDC_STORE_ACCESS_TOKEN = True # Store the access token in the session
|
||||
OIDC_STORE_REFRESH_TOKEN = True # Store the encrypted refresh token in the session
|
||||
OIDC_STORE_REFRESH_TOKEN_KEY = "your-32-byte-encryption-key==" # Must be a valid Fernet key (32 url-safe base64-encoded bytes)
|
||||
```
|
||||
@@ -97,6 +97,17 @@ Production deployments differ significantly from development environments. The t
|
||||
| 5433 | PostgreSQL (Keycloak) |
|
||||
| 1081 | MailCatcher |
|
||||
|
||||
**With fulltext search service**
|
||||
|
||||
| Port | Service |
|
||||
| --------- | --------------------- |
|
||||
| 8081 | Find (Django) |
|
||||
| 9200 | Opensearch |
|
||||
| 9600 | Opensearch admin |
|
||||
| 5601 | Opensearch dashboard |
|
||||
| 25432 | PostgreSQL (Find) |
|
||||
|
||||
|
||||
## 6. Sizing Guidelines
|
||||
|
||||
**RAM** – start at 8 GB dev / 16 GB staging / 32 GB prod. Postgres and Keycloak are the first to OOM; scale them first.
|
||||
|
||||
@@ -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, our application will load your custom CSS file and apply the styles to our frontend application.
|
||||
Once you've set this variable, Docs will load your custom CSS file and apply the styles to our frontend application.
|
||||
|
||||
### Benefits
|
||||
|
||||
@@ -32,6 +32,61 @@ Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom
|
||||
|
||||
----
|
||||
|
||||
# Runtime JavaScript Injection 🚀
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -36,6 +36,7 @@ OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/c
|
||||
OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8083/realms/impress/protocol/openid-connect/auth
|
||||
OIDC_OP_TOKEN_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/token
|
||||
OIDC_OP_USER_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/userinfo
|
||||
OIDC_OP_INTROSPECTION_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/token/introspect
|
||||
|
||||
OIDC_RP_CLIENT_ID=impress
|
||||
OIDC_RP_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
|
||||
@@ -49,6 +50,14 @@ LOGOUT_REDIRECT_URL=http://localhost:3000
|
||||
OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
|
||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
||||
|
||||
# Store OIDC tokens in the session. Needed by search/ endpoint.
|
||||
# OIDC_STORE_ACCESS_TOKEN = True
|
||||
# OIDC_STORE_REFRESH_TOKEN = True # Store the encrypted refresh token in the session.
|
||||
|
||||
# Must be a valid Fernet key (32 url-safe base64-encoded bytes)
|
||||
# To create one, use the bin/fernetkey command.
|
||||
# OIDC_STORE_REFRESH_TOKEN_KEY="your-32-byte-encryption-key=="
|
||||
|
||||
# AI
|
||||
AI_FEATURE_ENABLED=true
|
||||
AI_BASE_URL=https://openaiendpoint.com
|
||||
@@ -68,4 +77,10 @@ Y_PROVIDER_API_BASE_URL=http://y-provider-development:4444/api/
|
||||
Y_PROVIDER_API_KEY=yprovider-api-key
|
||||
|
||||
# Theme customization
|
||||
THEME_CUSTOMIZATION_CACHE_TIMEOUT=15
|
||||
THEME_CUSTOMIZATION_CACHE_TIMEOUT=15
|
||||
|
||||
# Indexer (disabled)
|
||||
# SEARCH_INDEXER_CLASS="core.services.search_indexers.SearchIndexer"
|
||||
SEARCH_INDEXER_SECRET=find-api-key-for-docs-with-exactly-50-chars-length # Key generated by create_demo in Find app.
|
||||
SEARCH_INDEXER_URL="http://find:8000/api/v1.0/documents/index/"
|
||||
SEARCH_INDEXER_QUERY_URL="http://find:8000/api/v1.0/documents/search/"
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
publiccodeYmlVersion: "2.4.0"
|
||||
publiccodeYmlVersion: "0.5.0"
|
||||
name: Docs
|
||||
url: https://github.com/suitenumerique/docs
|
||||
landingURL: https://github.com/suitenumerique/docs
|
||||
creationDate: 2023-12-10
|
||||
logo: https://raw.githubusercontent.com/suitenumerique/docs/main/docs/assets/docs-logo.png
|
||||
usedBy:
|
||||
- Direction interministériel du numérique (DINUM)
|
||||
- Direction interministérielle du numérique (DINUM)
|
||||
fundedBy:
|
||||
- name: Direction interministériel du numérique (DINUM)
|
||||
url: https://www.numerique.gouv.fr
|
||||
- name: Direction interministérielle du numérique (DINUM)
|
||||
uri: 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"
|
||||
@@ -18,10 +20,18 @@ 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"
|
||||
|
||||
@@ -30,8 +30,11 @@
|
||||
"groupName": "ignored js dependencies",
|
||||
"matchManagers": ["npm"],
|
||||
"matchPackageNames": [
|
||||
"@next/eslint-plugin-next",
|
||||
"docx",
|
||||
"eslint-config-next",
|
||||
"fetch-mock",
|
||||
"next",
|
||||
"node",
|
||||
"node-fetch",
|
||||
"workbox-webpack-plugin"
|
||||
|
||||
@@ -1013,3 +1013,13 @@ class ThreadSerializer(serializers.ModelSerializer):
|
||||
if request:
|
||||
return thread.get_abilities(request.user)
|
||||
return {}
|
||||
|
||||
|
||||
class SearchDocumentSerializer(serializers.Serializer):
|
||||
"""Serializer for fulltext search requests through Find application"""
|
||||
|
||||
q = serializers.CharField(required=True, allow_blank=False, trim_whitespace=True)
|
||||
page_size = serializers.IntegerField(
|
||||
required=False, min_value=1, max_value=50, default=20
|
||||
)
|
||||
page = serializers.IntegerField(required=False, min_value=1, default=1)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"""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
|
||||
|
||||
@@ -19,3 +22,30 @@ 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)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""API endpoints"""
|
||||
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import base64
|
||||
@@ -18,10 +19,11 @@ 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 Left, Length
|
||||
from django.db.models.functions import Greatest, Left, Length
|
||||
from django.http import Http404, StreamingHttpResponse
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.text import capfirst, slugify
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -32,11 +34,13 @@ from botocore.exceptions import ClientError
|
||||
from csp.constants import NONE
|
||||
from csp.decorators import csp_update
|
||||
from lasuite.malware_detection import malware_detection
|
||||
from lasuite.oidc_login.decorators import refresh_oidc_access_token
|
||||
from rest_framework import filters, status, viewsets
|
||||
from rest_framework import response as drf_response
|
||||
from rest_framework.permissions import AllowAny
|
||||
|
||||
from 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 (
|
||||
@@ -48,12 +52,20 @@ from core.services.converter_services import (
|
||||
from core.services.converter_services import (
|
||||
YdocConverter,
|
||||
)
|
||||
from core.services.search_indexers import (
|
||||
get_document_indexer,
|
||||
get_visited_document_ids_of,
|
||||
)
|
||||
from core.tasks.mail import send_ask_for_access_mail
|
||||
from core.utils import extract_attachments, filter_descendants
|
||||
|
||||
from . import permissions, serializers, utils
|
||||
from .filters import DocumentFilter, ListDocumentFilter, UserSearchFilter
|
||||
from .throttling import UserListThrottleBurst, UserListThrottleSustained
|
||||
from .throttling import (
|
||||
DocumentThrottle,
|
||||
UserListThrottleBurst,
|
||||
UserListThrottleSustained,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -188,13 +200,15 @@ class UserViewSet(
|
||||
queryset = queryset.exclude(documentaccess__document_id=document_id)
|
||||
|
||||
filter_data = filterset.form.cleaned_data
|
||||
query = filter_data["q"]
|
||||
query = remove_accents(filter_data["q"])
|
||||
|
||||
# For emails, match emails by Levenstein distance to prevent typing errors
|
||||
if "@" in query:
|
||||
return (
|
||||
queryset.annotate(
|
||||
distance=RawSQL("levenshtein(email::text, %s::text)", (query,))
|
||||
distance=RawSQL(
|
||||
"levenshtein(unaccent(email::text), %s::text)", (query,)
|
||||
)
|
||||
)
|
||||
.filter(distance__lte=3)
|
||||
.order_by("distance", "email")[: settings.API_USERS_LIST_LIMIT]
|
||||
@@ -203,11 +217,15 @@ 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.filter(email__trigram_word_similar=query)
|
||||
.annotate(similarity=TrigramSimilarity("email", query))
|
||||
queryset.annotate(
|
||||
sim_email=TrigramSimilarity("email", query),
|
||||
sim_name=TrigramSimilarity("full_name", query),
|
||||
)
|
||||
.annotate(similarity=Greatest("sim_email", "sim_name"))
|
||||
.filter(similarity__gt=0.2)
|
||||
.order_by("-similarity", "email")[: settings.API_USERS_LIST_LIMIT]
|
||||
.order_by("-similarity")[: settings.API_USERS_LIST_LIMIT]
|
||||
)
|
||||
|
||||
@drf.decorators.action(
|
||||
@@ -365,6 +383,7 @@ class DocumentViewSet(
|
||||
permission_classes = [
|
||||
permissions.DocumentPermission,
|
||||
]
|
||||
throttle_classes = [DocumentThrottle]
|
||||
throttle_scope = "document"
|
||||
queryset = models.Document.objects.select_related("creator").all()
|
||||
serializer_class = serializers.DocumentSerializer
|
||||
@@ -374,6 +393,7 @@ class DocumentViewSet(
|
||||
list_serializer_class = serializers.ListDocumentSerializer
|
||||
trashbin_serializer_class = serializers.ListDocumentSerializer
|
||||
tree_serializer_class = serializers.ListDocumentSerializer
|
||||
search_serializer_class = serializers.ListDocumentSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get queryset performing all annotation and filtering on the document tree structure."""
|
||||
@@ -1065,6 +1085,83 @@ class DocumentViewSet(
|
||||
{"id": str(duplicated_document.id)}, status=status.HTTP_201_CREATED
|
||||
)
|
||||
|
||||
def _search_simple(self, request, text):
|
||||
"""
|
||||
Returns a queryset filtered by the content of the document title
|
||||
"""
|
||||
# As the 'list' view we get a prefiltered queryset (deleted docs are excluded)
|
||||
queryset = self.get_queryset()
|
||||
filterset = DocumentFilter({"title": text}, queryset=queryset)
|
||||
|
||||
if not filterset.is_valid():
|
||||
raise drf.exceptions.ValidationError(filterset.errors)
|
||||
|
||||
queryset = filterset.filter_queryset(queryset)
|
||||
|
||||
return self.get_response_for_queryset(
|
||||
queryset.order_by("-updated_at"),
|
||||
context={
|
||||
"request": request,
|
||||
},
|
||||
)
|
||||
|
||||
def _search_fulltext(self, indexer, request, params):
|
||||
"""
|
||||
Returns a queryset from the results the fulltext search of Find
|
||||
"""
|
||||
access_token = request.session.get("oidc_access_token")
|
||||
user = request.user
|
||||
text = params.validated_data["q"]
|
||||
queryset = models.Document.objects.all()
|
||||
|
||||
# Retrieve the documents ids from Find.
|
||||
results = indexer.search(
|
||||
text=text,
|
||||
token=access_token,
|
||||
visited=get_visited_document_ids_of(queryset, user),
|
||||
)
|
||||
|
||||
docs_by_uuid = {str(d.pk): d for d in queryset.filter(pk__in=results)}
|
||||
ordered_docs = [docs_by_uuid[id] for id in results]
|
||||
|
||||
page = self.paginate_queryset(ordered_docs)
|
||||
|
||||
serializer = self.get_serializer(
|
||||
page if page else ordered_docs,
|
||||
many=True,
|
||||
context={
|
||||
"request": request,
|
||||
},
|
||||
)
|
||||
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
@drf.decorators.action(detail=False, methods=["get"], url_path="search")
|
||||
@method_decorator(refresh_oidc_access_token)
|
||||
def search(self, request, *args, **kwargs):
|
||||
"""
|
||||
Returns a DRF response containing the filtered, annotated and ordered document list.
|
||||
|
||||
Applies filtering based on request parameter 'q' from `SearchDocumentSerializer`.
|
||||
Depending of the configuration it can be:
|
||||
- A fulltext search through the opensearch indexation app "find" if the backend is
|
||||
enabled (see SEARCH_INDEXER_CLASS)
|
||||
- A filtering by the model field 'title'.
|
||||
|
||||
The ordering is always by the most recent first.
|
||||
"""
|
||||
params = serializers.SearchDocumentSerializer(data=request.query_params)
|
||||
params.is_valid(raise_exception=True)
|
||||
|
||||
indexer = get_document_indexer()
|
||||
|
||||
if indexer:
|
||||
return self._search_fulltext(indexer, request, params=params)
|
||||
|
||||
# The indexer is not configured, we fallback on a simple icontains filter by the
|
||||
# model field 'title'.
|
||||
return self._search_simple(request, text=params.validated_data["q"])
|
||||
|
||||
@drf.decorators.action(detail=True, methods=["get"], url_path="versions")
|
||||
def versions_list(self, request, *args, **kwargs):
|
||||
"""
|
||||
@@ -2100,6 +2197,7 @@ class ConfigView(drf.views.APIView):
|
||||
"ENVIRONMENT",
|
||||
"FRONTEND_CSS_URL",
|
||||
"FRONTEND_HOMEPAGE_FEATURE_ENABLED",
|
||||
"FRONTEND_JS_URL",
|
||||
"FRONTEND_THEME",
|
||||
"MEDIA_BASE_URL",
|
||||
"POSTHOG_KEY",
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
"""Impress Core application"""
|
||||
# from django.apps import AppConfig
|
||||
# from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
# class CoreConfig(AppConfig):
|
||||
# """Configuration class for the impress core app."""
|
||||
class CoreConfig(AppConfig):
|
||||
"""Configuration class for the impress core app."""
|
||||
|
||||
# name = "core"
|
||||
# app_label = "core"
|
||||
# verbose_name = _("impress core application")
|
||||
name = "core"
|
||||
app_label = "core"
|
||||
verbose_name = _("Impress core application")
|
||||
|
||||
def ready(self):
|
||||
"""
|
||||
Import signals when the app is ready.
|
||||
"""
|
||||
# pylint: disable=import-outside-toplevel, unused-import
|
||||
from . import signals # noqa: PLC0415
|
||||
|
||||
@@ -6,6 +6,7 @@ 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,
|
||||
)
|
||||
@@ -57,3 +58,22 @@ 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"]}
|
||||
)
|
||||
|
||||
52
src/backend/core/management/commands/index.py
Normal file
52
src/backend/core/management/commands/index.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
Handle search setup that needs to be done at bootstrap time.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from core.services.search_indexers import get_document_indexer
|
||||
|
||||
logger = logging.getLogger("docs.search.bootstrap_search")
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Index all documents to remote search service"""
|
||||
|
||||
help = __doc__
|
||||
|
||||
def 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()
|
||||
|
||||
if not indexer:
|
||||
raise CommandError("The indexer is not enabled or properly configured.")
|
||||
|
||||
logger.info("Starting to regenerate Find index...")
|
||||
start = time.perf_counter()
|
||||
batch_size = options["batch_size"]
|
||||
|
||||
try:
|
||||
count = indexer.index(batch_size=batch_size)
|
||||
except Exception as err:
|
||||
raise CommandError("Unable to regenerate index") from err
|
||||
|
||||
duration = time.perf_counter() - start
|
||||
logger.info(
|
||||
"Search index regenerated from %d document(s) in %.2f seconds.",
|
||||
count,
|
||||
duration,
|
||||
)
|
||||
37
src/backend/core/migrations/0027_auto_20251120_0956.py
Normal file
37
src/backend/core/migrations/0027_auto_20251120_0956.py
Normal file
@@ -0,0 +1,37 @@
|
||||
# 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;
|
||||
""",
|
||||
),
|
||||
]
|
||||
@@ -432,32 +432,35 @@ class Document(MP_Node, BaseModel):
|
||||
def save(self, *args, **kwargs):
|
||||
"""Write content to object storage only if _content has changed."""
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
if self._content:
|
||||
file_key = self.file_key
|
||||
bytes_content = self._content.encode("utf-8")
|
||||
self.save_content(self._content)
|
||||
|
||||
# Attempt to directly check if the object exists using the storage client.
|
||||
try:
|
||||
response = default_storage.connection.meta.client.head_object(
|
||||
Bucket=default_storage.bucket_name, Key=file_key
|
||||
)
|
||||
except ClientError as excpt:
|
||||
# If the error is a 404, the object doesn't exist, so we should create it.
|
||||
if excpt.response["Error"]["Code"] == "404":
|
||||
has_changed = True
|
||||
else:
|
||||
raise
|
||||
def save_content(self, content):
|
||||
"""Save content to object storage."""
|
||||
|
||||
file_key = self.file_key
|
||||
bytes_content = content.encode("utf-8")
|
||||
|
||||
# Attempt to directly check if the object exists using the storage client.
|
||||
try:
|
||||
response = default_storage.connection.meta.client.head_object(
|
||||
Bucket=default_storage.bucket_name, Key=file_key
|
||||
)
|
||||
except ClientError as excpt:
|
||||
# If the error is a 404, the object doesn't exist, so we should create it.
|
||||
if excpt.response["Error"]["Code"] == "404":
|
||||
has_changed = True
|
||||
else:
|
||||
# Compare the existing ETag with the MD5 hash of the new content.
|
||||
has_changed = (
|
||||
response["ETag"].strip('"')
|
||||
!= hashlib.md5(bytes_content).hexdigest() # noqa: S324
|
||||
)
|
||||
raise
|
||||
else:
|
||||
# Compare the existing ETag with the MD5 hash of the new content.
|
||||
has_changed = (
|
||||
response["ETag"].strip('"') != hashlib.md5(bytes_content).hexdigest() # noqa: S324
|
||||
)
|
||||
|
||||
if has_changed:
|
||||
content_file = ContentFile(bytes_content)
|
||||
default_storage.save(file_key, content_file)
|
||||
if has_changed:
|
||||
content_file = ContentFile(bytes_content)
|
||||
default_storage.save(file_key, content_file)
|
||||
|
||||
def is_leaf(self):
|
||||
"""
|
||||
@@ -903,7 +906,8 @@ class Document(MP_Node, BaseModel):
|
||||
|
||||
# Mark all descendants as soft deleted
|
||||
self.get_descendants().filter(ancestors_deleted_at__isnull=True).update(
|
||||
ancestors_deleted_at=self.ancestors_deleted_at
|
||||
ancestors_deleted_at=self.ancestors_deleted_at,
|
||||
updated_at=self.updated_at,
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
|
||||
298
src/backend/core/services/search_indexers.py
Normal file
298
src/backend/core/services/search_indexers.py
Normal file
@@ -0,0 +1,298 @@
|
||||
"""Document search index management utilities and indexers"""
|
||||
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import defaultdict
|
||||
from functools import cache
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.db.models import Subquery
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
import requests
|
||||
|
||||
from core import models, utils
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@cache
|
||||
def get_document_indexer():
|
||||
"""Returns an instance of indexer service if enabled and properly configured."""
|
||||
classpath = settings.SEARCH_INDEXER_CLASS
|
||||
|
||||
# For this usecase an empty indexer class is not an issue but a feature.
|
||||
if not classpath:
|
||||
logger.info("Document indexer is not configured (see SEARCH_INDEXER_CLASS)")
|
||||
return None
|
||||
|
||||
try:
|
||||
indexer_class = import_string(settings.SEARCH_INDEXER_CLASS)
|
||||
return indexer_class()
|
||||
except ImportError as err:
|
||||
logger.error("SEARCH_INDEXER_CLASS setting is not valid : %s", err)
|
||||
except ImproperlyConfigured as err:
|
||||
logger.error("Document indexer is not properly configured : %s", err)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_batch_accesses_by_users_and_teams(paths):
|
||||
"""
|
||||
Get accesses related to a list of document paths,
|
||||
grouped by users and teams, including all ancestor paths.
|
||||
"""
|
||||
ancestor_map = utils.get_ancestor_to_descendants_map(
|
||||
paths, steplen=models.Document.steplen
|
||||
)
|
||||
ancestor_paths = list(ancestor_map.keys())
|
||||
|
||||
access_qs = models.DocumentAccess.objects.filter(
|
||||
document__path__in=ancestor_paths
|
||||
).values("document__path", "user__sub", "team")
|
||||
|
||||
access_by_document_path = defaultdict(lambda: {"users": set(), "teams": set()})
|
||||
|
||||
for access in access_qs:
|
||||
ancestor_path = access["document__path"]
|
||||
user_sub = access["user__sub"]
|
||||
team = access["team"]
|
||||
|
||||
for descendant_path in ancestor_map.get(ancestor_path, []):
|
||||
if user_sub:
|
||||
access_by_document_path[descendant_path]["users"].add(str(user_sub))
|
||||
if team:
|
||||
access_by_document_path[descendant_path]["teams"].add(team)
|
||||
|
||||
return dict(access_by_document_path)
|
||||
|
||||
|
||||
def get_visited_document_ids_of(queryset, user):
|
||||
"""
|
||||
Returns the ids of the documents that have a linktrace to the user and NOT owned.
|
||||
It will be use to limit the opensearch responses to the public documents already
|
||||
"visited" by the user.
|
||||
"""
|
||||
if isinstance(user, AnonymousUser):
|
||||
return []
|
||||
|
||||
qs = models.LinkTrace.objects.filter(user=user)
|
||||
|
||||
docs = (
|
||||
queryset.exclude(accesses__user=user)
|
||||
.filter(
|
||||
deleted_at__isnull=True,
|
||||
ancestors_deleted_at__isnull=True,
|
||||
)
|
||||
.filter(pk__in=Subquery(qs.values("document_id")))
|
||||
.order_by("pk")
|
||||
.distinct("pk")
|
||||
)
|
||||
|
||||
return [str(id) for id in docs.values_list("pk", flat=True)]
|
||||
|
||||
|
||||
class BaseDocumentIndexer(ABC):
|
||||
"""
|
||||
Base class for document indexers.
|
||||
|
||||
Handles batching and access resolution. Subclasses must implement both
|
||||
`serialize_document()` and `push()` to define backend-specific behavior.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
Initialize the indexer.
|
||||
"""
|
||||
self.batch_size = 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(
|
||||
"SEARCH_INDEXER_URL must be set in Django settings."
|
||||
)
|
||||
|
||||
if not self.indexer_secret:
|
||||
raise ImproperlyConfigured(
|
||||
"SEARCH_INDEXER_SECRET must be set in Django settings."
|
||||
)
|
||||
|
||||
if not self.search_url:
|
||||
raise ImproperlyConfigured(
|
||||
"SEARCH_INDEXER_QUERY_URL must be set in Django settings."
|
||||
)
|
||||
|
||||
def index(self, queryset=None, batch_size=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]
|
||||
)
|
||||
|
||||
if not documents_batch:
|
||||
break
|
||||
|
||||
doc_paths = [doc.path for doc in documents_batch]
|
||||
last_id = documents_batch[-1].id
|
||||
accesses_by_document_path = get_batch_accesses_by_users_and_teams(doc_paths)
|
||||
|
||||
serialized_batch = [
|
||||
self.serialize_document(document, accesses_by_document_path)
|
||||
for document in documents_batch
|
||||
if document.content or document.title
|
||||
]
|
||||
|
||||
if serialized_batch:
|
||||
self.push(serialized_batch)
|
||||
count += len(serialized_batch)
|
||||
|
||||
return count
|
||||
|
||||
@abstractmethod
|
||||
def serialize_document(self, document, accesses):
|
||||
"""
|
||||
Convert a Document instance to a JSON-serializable format for indexing.
|
||||
|
||||
Must be implemented by subclasses.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def push(self, data):
|
||||
"""
|
||||
Push a batch of serialized documents to the backend.
|
||||
|
||||
Must be implemented by subclasses.
|
||||
"""
|
||||
|
||||
# pylint: disable-next=too-many-arguments,too-many-positional-arguments
|
||||
def search(self, text, token, visited=(), nb_results=None):
|
||||
"""
|
||||
Search for documents in Find app.
|
||||
Ensure the same default ordering as "Docs" list : -updated_at
|
||||
|
||||
Returns ids of the documents
|
||||
|
||||
Args:
|
||||
text (str): Text search content.
|
||||
token (str): OIDC Authentication token.
|
||||
visited (list, optional):
|
||||
List of ids of active public documents with LinkTrace
|
||||
Defaults to settings.SEARCH_INDEXER_BATCH_SIZE.
|
||||
nb_results (int, optional):
|
||||
The number of results to return.
|
||||
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,
|
||||
"order_by": "updated_at",
|
||||
"order_direction": "desc",
|
||||
},
|
||||
token=token,
|
||||
)
|
||||
|
||||
return [d["_id"] for d in response]
|
||||
|
||||
@abstractmethod
|
||||
def search_query(self, data, token) -> dict:
|
||||
"""
|
||||
Retrieve documents from the Find app API.
|
||||
|
||||
Must be implemented by subclasses.
|
||||
"""
|
||||
|
||||
|
||||
class SearchIndexer(BaseDocumentIndexer):
|
||||
"""
|
||||
Document indexer that pushes documents to La Suite Find app.
|
||||
"""
|
||||
|
||||
def serialize_document(self, document, accesses):
|
||||
"""
|
||||
Convert a Document to the JSON format expected by La Suite Find.
|
||||
|
||||
Args:
|
||||
document (Document): The document instance.
|
||||
accesses (dict): Mapping of document ID to user/team access.
|
||||
|
||||
Returns:
|
||||
dict: A JSON-serializable dictionary.
|
||||
"""
|
||||
doc_path = document.path
|
||||
doc_content = document.content
|
||||
text_content = utils.base64_yjs_to_text(doc_content) if doc_content else ""
|
||||
|
||||
return {
|
||||
"id": str(document.id),
|
||||
"title": document.title or "",
|
||||
"content": text_content,
|
||||
"depth": document.depth,
|
||||
"path": document.path,
|
||||
"numchild": document.numchild,
|
||||
"created_at": document.created_at.isoformat(),
|
||||
"updated_at": document.updated_at.isoformat(),
|
||||
"users": list(accesses.get(doc_path, {}).get("users", set())),
|
||||
"groups": list(accesses.get(doc_path, {}).get("teams", set())),
|
||||
"reach": document.computed_link_reach,
|
||||
"size": len(text_content.encode("utf-8")),
|
||||
"is_active": not bool(document.ancestors_deleted_at),
|
||||
}
|
||||
|
||||
def search_query(self, data, token) -> requests.Response:
|
||||
"""
|
||||
Retrieve documents from the Find app API.
|
||||
|
||||
Args:
|
||||
data (dict): search data
|
||||
token (str): OICD token
|
||||
|
||||
Returns:
|
||||
dict: A JSON-serializable dictionary.
|
||||
"""
|
||||
response = requests.post(
|
||||
self.search_url,
|
||||
json=data,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def push(self, data):
|
||||
"""
|
||||
Push a batch of documents to the Find backend.
|
||||
|
||||
Args:
|
||||
data (list): List of document dictionaries.
|
||||
"""
|
||||
response = requests.post(
|
||||
self.indexer_url,
|
||||
json=data,
|
||||
headers={"Authorization": f"Bearer {self.indexer_secret}"},
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
33
src/backend/core/signals.py
Normal file
33
src/backend/core/signals.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""
|
||||
Declare and configure the signals for the impress core application
|
||||
"""
|
||||
|
||||
from functools import partial
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import signals
|
||||
from django.dispatch import receiver
|
||||
|
||||
from . import models
|
||||
from .tasks.search import trigger_batch_document_indexer
|
||||
|
||||
|
||||
@receiver(signals.post_save, sender=models.Document)
|
||||
def document_post_save(sender, instance, **kwargs): # pylint: disable=unused-argument
|
||||
"""
|
||||
Asynchronous call to the document indexer at the end of the transaction.
|
||||
Note : Within the transaction we can have an empty content and a serialization
|
||||
error.
|
||||
"""
|
||||
transaction.on_commit(partial(trigger_batch_document_indexer, instance))
|
||||
|
||||
|
||||
@receiver(signals.post_save, sender=models.DocumentAccess)
|
||||
def document_access_post_save(sender, instance, created, **kwargs): # pylint: disable=unused-argument
|
||||
"""
|
||||
Asynchronous call to the document indexer at the end of the transaction.
|
||||
"""
|
||||
if not created:
|
||||
transaction.on_commit(
|
||||
partial(trigger_batch_document_indexer, instance.document)
|
||||
)
|
||||
95
src/backend/core/tasks/search.py
Normal file
95
src/backend/core/tasks/search.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Trigger document indexation using celery task."""
|
||||
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Q
|
||||
|
||||
from django_redis.cache import RedisCache
|
||||
|
||||
from core import models
|
||||
from core.services.search_indexers import (
|
||||
get_document_indexer,
|
||||
)
|
||||
|
||||
from impress.celery_app import app
|
||||
|
||||
logger = getLogger(__file__)
|
||||
|
||||
|
||||
@app.task
|
||||
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))
|
||||
|
||||
|
||||
def batch_indexer_throttle_acquire(timeout: int = 0, atomic: bool = True):
|
||||
"""
|
||||
Enable the task throttle flag for a delay.
|
||||
Uses redis locks if available to ensure atomic changes
|
||||
"""
|
||||
key = "document-batch-indexer-throttle"
|
||||
|
||||
# Redis is used as cache database (not in tests). Use the lock feature here
|
||||
# to ensure atomicity of changes to the throttle flag.
|
||||
if isinstance(cache, RedisCache) and atomic:
|
||||
with cache.locks(key):
|
||||
return batch_indexer_throttle_acquire(timeout, atomic=False)
|
||||
|
||||
# Use add() here :
|
||||
# - set the flag and returns true if not exist
|
||||
# - do nothing and return false if exist
|
||||
return cache.add(key, 1, timeout=timeout)
|
||||
|
||||
|
||||
@app.task
|
||||
def batch_document_indexer_task(timestamp):
|
||||
"""Celery Task : Sends indexation query for a batch of documents."""
|
||||
indexer = get_document_indexer()
|
||||
|
||||
if indexer:
|
||||
queryset = models.Document.objects.filter(
|
||||
Q(updated_at__gte=timestamp)
|
||||
| Q(deleted_at__gte=timestamp)
|
||||
| Q(ancestors_deleted_at__gte=timestamp)
|
||||
)
|
||||
|
||||
count = indexer.index(queryset)
|
||||
logger.info("Indexed %d documents", count)
|
||||
|
||||
|
||||
def trigger_batch_document_indexer(item):
|
||||
"""
|
||||
Trigger indexation task with debounce a delay set by the SEARCH_INDEXER_COUNTDOWN setting.
|
||||
|
||||
Args:
|
||||
document (Document): The document instance.
|
||||
"""
|
||||
countdown = int(settings.SEARCH_INDEXER_COUNTDOWN)
|
||||
|
||||
# DO NOT create a task if indexation if disabled
|
||||
if not settings.SEARCH_INDEXER_CLASS:
|
||||
return
|
||||
|
||||
if countdown > 0:
|
||||
# Each time this method is called during a countdown, we increment the
|
||||
# counter and each task decrease it, so the index be run only once.
|
||||
if batch_indexer_throttle_acquire(timeout=countdown):
|
||||
logger.info(
|
||||
"Add task for batch document indexation from updated_at=%s in %d seconds",
|
||||
item.updated_at.isoformat(),
|
||||
countdown,
|
||||
)
|
||||
|
||||
batch_document_indexer_task.apply_async(
|
||||
args=[item.updated_at], countdown=countdown
|
||||
)
|
||||
else:
|
||||
logger.info("Skip task for batch document %s indexation", item.pk)
|
||||
else:
|
||||
document_indexer_task.apply(args=[item.pk])
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import random
|
||||
import re
|
||||
from unittest import mock
|
||||
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.test.utils import override_settings
|
||||
@@ -12,7 +13,10 @@ 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
|
||||
from core.authentication.backends import (
|
||||
OIDCAuthenticationBackend,
|
||||
create_or_update_contact,
|
||||
)
|
||||
from core.factories import UserFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -509,3 +513,79 @@ 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()
|
||||
|
||||
65
src/backend/core/tests/commands/test_index.py
Normal file
65
src/backend/core/tests/commands/test_index.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Unit test for `index` command.
|
||||
"""
|
||||
|
||||
from operator import itemgetter
|
||||
from unittest import mock
|
||||
|
||||
from django.core.management import CommandError, call_command
|
||||
from django.db import transaction
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories
|
||||
from core.services.search_indexers import SearchIndexer
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_index():
|
||||
"""Test the command `index` that run the Find app indexer for all the available documents."""
|
||||
user = factories.UserFactory()
|
||||
indexer = SearchIndexer()
|
||||
|
||||
with transaction.atomic():
|
||||
doc = factories.DocumentFactory()
|
||||
empty_doc = factories.DocumentFactory(title=None, content="")
|
||||
no_title_doc = factories.DocumentFactory(title=None)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=doc, user=user)
|
||||
factories.UserDocumentAccessFactory(document=empty_doc, user=user)
|
||||
factories.UserDocumentAccessFactory(document=no_title_doc, user=user)
|
||||
|
||||
accesses = {
|
||||
str(doc.path): {"users": [user.sub]},
|
||||
str(empty_doc.path): {"users": [user.sub]},
|
||||
str(no_title_doc.path): {"users": [user.sub]},
|
||||
}
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
call_command("index")
|
||||
|
||||
push_call_args = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
# called once but with a batch of docs
|
||||
mock_push.assert_called_once()
|
||||
|
||||
assert sorted(push_call_args[0], key=itemgetter("id")) == sorted(
|
||||
[
|
||||
indexer.serialize_document(doc, accesses),
|
||||
indexer.serialize_document(no_title_doc, accesses),
|
||||
],
|
||||
key=itemgetter("id"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_index_improperly_configured(indexer_settings):
|
||||
"""The command should raise an exception if the indexer is not configured"""
|
||||
indexer_settings.SEARCH_INDEXER_CLASS = None
|
||||
|
||||
with pytest.raises(CommandError) as err:
|
||||
call_command("index")
|
||||
|
||||
assert str(err.value) == "The indexer is not enabled or properly configured."
|
||||
@@ -24,3 +24,30 @@ def mock_user_teams():
|
||||
"core.models.User.teams", new_callable=mock.PropertyMock
|
||||
) as mock_teams:
|
||||
yield mock_teams
|
||||
|
||||
|
||||
@pytest.fixture(name="indexer_settings")
|
||||
def indexer_settings_fixture(settings):
|
||||
"""
|
||||
Setup valid settings for the document indexer. Clear the indexer cache.
|
||||
"""
|
||||
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from core.services.search_indexers import ( # noqa: PLC0415
|
||||
get_document_indexer,
|
||||
)
|
||||
|
||||
get_document_indexer.cache_clear()
|
||||
|
||||
settings.SEARCH_INDEXER_CLASS = "core.services.search_indexers.SearchIndexer"
|
||||
settings.SEARCH_INDEXER_SECRET = "ThisIsAKeyForTest"
|
||||
settings.SEARCH_INDEXER_URL = "http://localhost:8081/api/v1.0/documents/index/"
|
||||
settings.SEARCH_INDEXER_QUERY_URL = (
|
||||
"http://localhost:8081/api/v1.0/documents/search/"
|
||||
)
|
||||
settings.SEARCH_INDEXER_COUNTDOWN = 1
|
||||
|
||||
yield settings
|
||||
|
||||
# clear cache to prevent issues with other tests
|
||||
get_document_indexer.cache_clear()
|
||||
|
||||
425
src/backend/core/tests/documents/test_api_documents_search.py
Normal file
425
src/backend/core/tests/documents/test_api_documents_search.py
Normal file
@@ -0,0 +1,425 @@
|
||||
"""
|
||||
Tests for Documents API endpoint in impress's core app: list
|
||||
"""
|
||||
|
||||
import random
|
||||
from json import loads as json_loads
|
||||
|
||||
from django.test import RequestFactory
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from faker import Faker
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core.services.search_indexers import get_document_indexer
|
||||
|
||||
fake = Faker()
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def build_search_url(**kwargs):
|
||||
"""Build absolute uri for search endpoint with ORDERED query arguments"""
|
||||
return (
|
||||
RequestFactory()
|
||||
.get("/api/v1.0/documents/search/", dict(sorted(kwargs.items())))
|
||||
.build_absolute_uri()
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
|
||||
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
|
||||
@responses.activate
|
||||
def test_api_documents_search_anonymous(reach, role, indexer_settings):
|
||||
"""
|
||||
Anonymous users should not be allowed to search documents whatever the
|
||||
link reach and link role
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
|
||||
|
||||
factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
|
||||
# Find response
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"http://find/api/v1.0/search",
|
||||
json=[],
|
||||
status=200,
|
||||
)
|
||||
|
||||
response = APIClient().get("/api/v1.0/documents/search/", data={"q": "alpha"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 0,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [],
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_search_endpoint_is_none(indexer_settings):
|
||||
"""
|
||||
Missing SEARCH_INDEXER_QUERY_URL, so the indexer is not properly configured.
|
||||
Should fallback on title filter
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = None
|
||||
|
||||
assert get_document_indexer() is None
|
||||
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(title="alpha")
|
||||
access = factories.UserDocumentAccessFactory(document=document, user=user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/documents/search/", data={"q": "alpha"})
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
results = content.pop("results")
|
||||
assert content == {
|
||||
"count": 1,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
}
|
||||
assert len(results) == 1
|
||||
assert results[0] == {
|
||||
"id": str(document.id),
|
||||
"abilities": document.get_abilities(user),
|
||||
"ancestors_link_reach": None,
|
||||
"ancestors_link_role": None,
|
||||
"computed_link_reach": document.computed_link_reach,
|
||||
"computed_link_role": document.computed_link_role,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"deleted_at": None,
|
||||
"user_role": access.role,
|
||||
}
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_search_invalid_params(indexer_settings):
|
||||
"""Validate the format of documents as returned by the search view."""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/documents/search/")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"q": ["This field is required."]}
|
||||
|
||||
response = client.get("/api/v1.0/documents/search/", data={"q": " "})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"q": ["This field may not be blank."]}
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": "any", "page": "NaN"}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"page": ["A valid integer is required."]}
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_search_format(indexer_settings):
|
||||
"""Validate the format of documents as returned by the search view."""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
|
||||
|
||||
assert get_document_indexer() is not None
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
user_a, user_b, user_c = factories.UserFactory.create_batch(3)
|
||||
document = factories.DocumentFactory(
|
||||
title="alpha",
|
||||
users=(user_a, user_c),
|
||||
link_traces=(user, user_b),
|
||||
)
|
||||
access = factories.UserDocumentAccessFactory(document=document, user=user)
|
||||
|
||||
# Find response
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"http://find/api/v1.0/search",
|
||||
json=[
|
||||
{"_id": str(document.pk)},
|
||||
],
|
||||
status=200,
|
||||
)
|
||||
response = client.get("/api/v1.0/documents/search/", data={"q": "alpha"})
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
results = content.pop("results")
|
||||
assert content == {
|
||||
"count": 1,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
}
|
||||
assert len(results) == 1
|
||||
assert results[0] == {
|
||||
"id": str(document.id),
|
||||
"abilities": document.get_abilities(user),
|
||||
"ancestors_link_reach": None,
|
||||
"ancestors_link_role": None,
|
||||
"computed_link_reach": document.computed_link_reach,
|
||||
"computed_link_role": document.computed_link_role,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 3,
|
||||
"nb_accesses_direct": 3,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"deleted_at": None,
|
||||
"user_role": access.role,
|
||||
}
|
||||
|
||||
|
||||
@responses.activate
|
||||
@pytest.mark.parametrize(
|
||||
"pagination, status, expected",
|
||||
(
|
||||
(
|
||||
{"page": 1, "page_size": 10},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": None,
|
||||
"next": None,
|
||||
"range": (0, None),
|
||||
},
|
||||
),
|
||||
(
|
||||
{},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": None,
|
||||
"next": None,
|
||||
"range": (0, None),
|
||||
"api_page_size": 21, # default page_size is 20
|
||||
},
|
||||
),
|
||||
(
|
||||
{"page": 2, "page_size": 10},
|
||||
404,
|
||||
{},
|
||||
),
|
||||
(
|
||||
{"page": 1, "page_size": 5},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": None,
|
||||
"next": {"page": 2, "page_size": 5},
|
||||
"range": (0, 5),
|
||||
},
|
||||
),
|
||||
(
|
||||
{"page": 2, "page_size": 5},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": {"page_size": 5},
|
||||
"next": None,
|
||||
"range": (5, None),
|
||||
},
|
||||
),
|
||||
({"page": 3, "page_size": 5}, 404, {}),
|
||||
),
|
||||
)
|
||||
def test_api_documents_search_pagination(
|
||||
indexer_settings, pagination, status, expected
|
||||
):
|
||||
"""Documents should be ordered by descending "score" by default"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
|
||||
|
||||
assert get_document_indexer() is not None
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
docs = factories.DocumentFactory.create_batch(10, title="alpha", users=[user])
|
||||
|
||||
docs_by_uuid = {str(doc.pk): doc for doc in docs}
|
||||
api_results = [{"_id": id} for id in docs_by_uuid.keys()]
|
||||
|
||||
# reorder randomly to simulate score ordering
|
||||
random.shuffle(api_results)
|
||||
|
||||
# Find response
|
||||
# pylint: disable-next=assignment-from-none
|
||||
api_search = responses.add(
|
||||
responses.POST,
|
||||
"http://find/api/v1.0/search",
|
||||
json=api_results,
|
||||
status=200,
|
||||
)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/",
|
||||
data={
|
||||
"q": "alpha",
|
||||
**pagination,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == status
|
||||
|
||||
if response.status_code < 300:
|
||||
previous_url = (
|
||||
build_search_url(q="alpha", **expected["previous"])
|
||||
if expected["previous"]
|
||||
else None
|
||||
)
|
||||
next_url = (
|
||||
build_search_url(q="alpha", **expected["next"])
|
||||
if expected["next"]
|
||||
else None
|
||||
)
|
||||
start, end = expected["range"]
|
||||
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == expected["count"]
|
||||
assert content["previous"] == previous_url
|
||||
assert content["next"] == next_url
|
||||
|
||||
results = content.pop("results")
|
||||
|
||||
# The find api results ordering by score is kept
|
||||
assert [r["id"] for r in results] == [r["_id"] for r in api_results[start:end]]
|
||||
|
||||
# Check the query parameters.
|
||||
assert api_search.call_count == 1
|
||||
assert api_search.calls[0].response.status_code == 200
|
||||
assert json_loads(api_search.calls[0].request.body) == {
|
||||
"q": "alpha",
|
||||
"visited": [],
|
||||
"services": ["docs"],
|
||||
"nb_results": 50,
|
||||
"order_by": "updated_at",
|
||||
"order_direction": "desc",
|
||||
}
|
||||
|
||||
|
||||
@responses.activate
|
||||
@pytest.mark.parametrize(
|
||||
"pagination, status, expected",
|
||||
(
|
||||
(
|
||||
{"page": 1, "page_size": 10},
|
||||
200,
|
||||
{"count": 10, "previous": None, "next": None, "range": (0, None)},
|
||||
),
|
||||
(
|
||||
{},
|
||||
200,
|
||||
{"count": 10, "previous": None, "next": None, "range": (0, None)},
|
||||
),
|
||||
(
|
||||
{"page": 2, "page_size": 10},
|
||||
404,
|
||||
{},
|
||||
),
|
||||
(
|
||||
{"page": 1, "page_size": 5},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": None,
|
||||
"next": {"page": 2, "page_size": 5},
|
||||
"range": (0, 5),
|
||||
},
|
||||
),
|
||||
(
|
||||
{"page": 2, "page_size": 5},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": {"page_size": 5},
|
||||
"next": None,
|
||||
"range": (5, None),
|
||||
},
|
||||
),
|
||||
({"page": 3, "page_size": 5}, 404, {}),
|
||||
),
|
||||
)
|
||||
def test_api_documents_search_pagination_endpoint_is_none(
|
||||
indexer_settings, pagination, status, expected
|
||||
):
|
||||
"""Documents should be ordered by descending "-updated_at" by default"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = None
|
||||
|
||||
assert get_document_indexer() is None
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory.create_batch(10, title="alpha", users=[user])
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/",
|
||||
data={
|
||||
"q": "alpha",
|
||||
**pagination,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == status
|
||||
|
||||
if response.status_code < 300:
|
||||
previous_url = (
|
||||
build_search_url(q="alpha", **expected["previous"])
|
||||
if expected["previous"]
|
||||
else None
|
||||
)
|
||||
next_url = (
|
||||
build_search_url(q="alpha", **expected["next"])
|
||||
if expected["next"]
|
||||
else None
|
||||
)
|
||||
queryset = models.Document.objects.order_by("-updated_at")
|
||||
start, end = expected["range"]
|
||||
expected_results = [str(d.pk) for d in queryset[start:end]]
|
||||
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == expected["count"]
|
||||
assert content["previous"] == previous_url
|
||||
assert content["next"] == next_url
|
||||
|
||||
results = content.pop("results")
|
||||
|
||||
assert [r["id"] for r in results] == expected_results
|
||||
@@ -24,6 +24,7 @@ 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"},
|
||||
@@ -49,6 +50,7 @@ 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"],
|
||||
|
||||
107
src/backend/core/tests/test_api_throttling_document_throttle.py
Normal file
107
src/backend/core/tests/test_api_throttling_document_throttle.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
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
|
||||
@@ -76,6 +76,131 @@ 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
|
||||
|
||||
441
src/backend/core/tests/test_models_documents_indexer.py
Normal file
441
src/backend/core/tests/test_models_documents_indexer.py
Normal file
@@ -0,0 +1,441 @@
|
||||
"""
|
||||
Unit tests for the Document model
|
||||
"""
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
from operator import itemgetter
|
||||
from unittest import mock
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.db import transaction
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories, models
|
||||
from core.services.search_indexers import SearchIndexer
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def reset_batch_indexer_throttle():
|
||||
"""Reset throttle flag"""
|
||||
cache.delete("document-batch-indexer-throttle")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_throttle():
|
||||
"""Reset throttle flag before each test"""
|
||||
reset_batch_indexer_throttle()
|
||||
yield
|
||||
reset_batch_indexer_throttle()
|
||||
|
||||
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer(mock_push):
|
||||
"""Test indexation task on document creation"""
|
||||
with transaction.atomic():
|
||||
doc1, doc2, doc3 = factories.DocumentFactory.create_batch(3)
|
||||
|
||||
accesses = {}
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
indexer = SearchIndexer()
|
||||
|
||||
assert len(data) == 1
|
||||
|
||||
# One call
|
||||
assert sorted(data[0], key=itemgetter("id")) == sorted(
|
||||
[
|
||||
indexer.serialize_document(doc1, accesses),
|
||||
indexer.serialize_document(doc2, accesses),
|
||||
indexer.serialize_document(doc3, accesses),
|
||||
],
|
||||
key=itemgetter("id"),
|
||||
)
|
||||
|
||||
# The throttle counters should be reset
|
||||
assert cache.get("document-batch-indexer-throttle") == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_no_batches(indexer_settings):
|
||||
"""Test indexation task on doculment creation, no throttle"""
|
||||
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
with transaction.atomic():
|
||||
doc1, doc2, doc3 = factories.DocumentFactory.create_batch(3)
|
||||
|
||||
accesses = {}
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
indexer = SearchIndexer()
|
||||
|
||||
# 3 calls
|
||||
assert len(data) == 3
|
||||
# one document per call
|
||||
assert [len(d) for d in data] == [1] * 3
|
||||
# all documents are indexed
|
||||
assert sorted([d[0] for d in data], key=itemgetter("id")) == sorted(
|
||||
[
|
||||
indexer.serialize_document(doc1, accesses),
|
||||
indexer.serialize_document(doc2, accesses),
|
||||
indexer.serialize_document(doc3, accesses),
|
||||
],
|
||||
key=itemgetter("id"),
|
||||
)
|
||||
|
||||
# The throttle counters should be reset
|
||||
assert cache.get("file-batch-indexer-throttle") is None
|
||||
|
||||
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_not_configured(mock_push, indexer_settings):
|
||||
"""Task should not start an indexation when disabled"""
|
||||
indexer_settings.SEARCH_INDEXER_CLASS = None
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
with transaction.atomic():
|
||||
doc = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=doc, user=user)
|
||||
|
||||
assert mock_push.assert_not_called
|
||||
|
||||
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_wrongly_configured(
|
||||
mock_push, indexer_settings
|
||||
):
|
||||
"""Task should not start an indexation when disabled"""
|
||||
indexer_settings.SEARCH_INDEXER_URL = None
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
with transaction.atomic():
|
||||
doc = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=doc, user=user)
|
||||
|
||||
assert mock_push.assert_not_called
|
||||
|
||||
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_with_accesses(mock_push):
|
||||
"""Test indexation task on document creation"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
with transaction.atomic():
|
||||
doc1, doc2, doc3 = factories.DocumentFactory.create_batch(3)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=doc1, user=user)
|
||||
factories.UserDocumentAccessFactory(document=doc2, user=user)
|
||||
factories.UserDocumentAccessFactory(document=doc3, user=user)
|
||||
|
||||
accesses = {
|
||||
str(doc1.path): {"users": [user.sub]},
|
||||
str(doc2.path): {"users": [user.sub]},
|
||||
str(doc3.path): {"users": [user.sub]},
|
||||
}
|
||||
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
indexer = SearchIndexer()
|
||||
|
||||
assert len(data) == 1
|
||||
assert sorted(data[0], key=itemgetter("id")) == sorted(
|
||||
[
|
||||
indexer.serialize_document(doc1, accesses),
|
||||
indexer.serialize_document(doc2, accesses),
|
||||
indexer.serialize_document(doc3, accesses),
|
||||
],
|
||||
key=itemgetter("id"),
|
||||
)
|
||||
|
||||
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_deleted(mock_push):
|
||||
"""Indexation task on deleted or ancestor_deleted documents"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
with transaction.atomic():
|
||||
doc = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED
|
||||
)
|
||||
main_doc = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED
|
||||
)
|
||||
child_doc = factories.DocumentFactory(
|
||||
parent=main_doc,
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED,
|
||||
)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=doc, user=user)
|
||||
factories.UserDocumentAccessFactory(document=main_doc, user=user)
|
||||
factories.UserDocumentAccessFactory(document=child_doc, user=user)
|
||||
|
||||
# Manually reset the throttle flag here or the next indexation will be ignored for 1 second
|
||||
reset_batch_indexer_throttle()
|
||||
|
||||
with transaction.atomic():
|
||||
main_doc_deleted = models.Document.objects.get(pk=main_doc.pk)
|
||||
main_doc_deleted.soft_delete()
|
||||
|
||||
child_doc_deleted = models.Document.objects.get(pk=child_doc.pk)
|
||||
|
||||
main_doc_deleted.refresh_from_db()
|
||||
child_doc_deleted.refresh_from_db()
|
||||
|
||||
assert main_doc_deleted.deleted_at is not None
|
||||
assert child_doc_deleted.ancestors_deleted_at is not None
|
||||
|
||||
assert child_doc_deleted.deleted_at is None
|
||||
assert child_doc_deleted.ancestors_deleted_at is not None
|
||||
|
||||
accesses = {
|
||||
str(doc.path): {"users": [user.sub]},
|
||||
str(main_doc_deleted.path): {"users": [user.sub]},
|
||||
str(child_doc_deleted.path): {"users": [user.sub]},
|
||||
}
|
||||
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
indexer = SearchIndexer()
|
||||
|
||||
assert len(data) == 2
|
||||
|
||||
# First indexation on document creation
|
||||
assert sorted(data[0], key=itemgetter("id")) == sorted(
|
||||
[
|
||||
indexer.serialize_document(doc, accesses),
|
||||
indexer.serialize_document(main_doc, accesses),
|
||||
indexer.serialize_document(child_doc, accesses),
|
||||
],
|
||||
key=itemgetter("id"),
|
||||
)
|
||||
|
||||
# Even deleted items are re-indexed : only update their status in the future
|
||||
assert sorted(data[1], key=itemgetter("id")) == sorted(
|
||||
[
|
||||
indexer.serialize_document(main_doc_deleted, accesses), # soft_delete()
|
||||
indexer.serialize_document(child_doc_deleted, accesses),
|
||||
],
|
||||
key=itemgetter("id"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_models_documents_indexer_hard_deleted():
|
||||
"""Indexation task on hard deleted document"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
with transaction.atomic():
|
||||
doc = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED
|
||||
)
|
||||
factories.UserDocumentAccessFactory(document=doc, user=user)
|
||||
|
||||
# Call task on deleted document.
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
doc.delete()
|
||||
|
||||
# Hard delete document are not re-indexed.
|
||||
assert mock_push.assert_not_called
|
||||
|
||||
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_restored(mock_push):
|
||||
"""Restart indexation task on restored documents"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
with transaction.atomic():
|
||||
doc = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED
|
||||
)
|
||||
doc_deleted = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED
|
||||
)
|
||||
doc_ancestor_deleted = factories.DocumentFactory(
|
||||
parent=doc_deleted,
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED,
|
||||
)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=doc, user=user)
|
||||
factories.UserDocumentAccessFactory(document=doc_deleted, user=user)
|
||||
factories.UserDocumentAccessFactory(document=doc_ancestor_deleted, user=user)
|
||||
|
||||
doc_deleted.soft_delete()
|
||||
|
||||
doc_deleted.refresh_from_db()
|
||||
doc_ancestor_deleted.refresh_from_db()
|
||||
|
||||
assert doc_deleted.deleted_at is not None
|
||||
assert doc_deleted.ancestors_deleted_at is not None
|
||||
|
||||
assert doc_ancestor_deleted.deleted_at is None
|
||||
assert doc_ancestor_deleted.ancestors_deleted_at is not None
|
||||
|
||||
# Manually reset the throttle flag here or the next indexation will be ignored for 1 second
|
||||
reset_batch_indexer_throttle()
|
||||
|
||||
with transaction.atomic():
|
||||
doc_restored = models.Document.objects.get(pk=doc_deleted.pk)
|
||||
doc_restored.restore()
|
||||
|
||||
doc_ancestor_restored = models.Document.objects.get(pk=doc_ancestor_deleted.pk)
|
||||
|
||||
assert doc_restored.deleted_at is None
|
||||
assert doc_restored.ancestors_deleted_at is None
|
||||
|
||||
assert doc_ancestor_restored.deleted_at is None
|
||||
assert doc_ancestor_restored.ancestors_deleted_at is None
|
||||
|
||||
accesses = {
|
||||
str(doc.path): {"users": [user.sub]},
|
||||
str(doc_deleted.path): {"users": [user.sub]},
|
||||
str(doc_ancestor_deleted.path): {"users": [user.sub]},
|
||||
}
|
||||
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
indexer = SearchIndexer()
|
||||
|
||||
# All docs are re-indexed
|
||||
assert len(data) == 2
|
||||
|
||||
# First indexation on items creation & soft delete (in the same transaction)
|
||||
assert sorted(data[0], key=itemgetter("id")) == sorted(
|
||||
[
|
||||
indexer.serialize_document(doc, accesses),
|
||||
indexer.serialize_document(doc_deleted, accesses),
|
||||
indexer.serialize_document(doc_ancestor_deleted, accesses),
|
||||
],
|
||||
key=itemgetter("id"),
|
||||
)
|
||||
|
||||
# Restored items are re-indexed : only update their status in the future
|
||||
assert sorted(data[1], key=itemgetter("id")) == sorted(
|
||||
[
|
||||
indexer.serialize_document(doc_restored, accesses), # restore()
|
||||
indexer.serialize_document(doc_ancestor_restored, accesses),
|
||||
],
|
||||
key=itemgetter("id"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_models_documents_post_save_indexer_throttle():
|
||||
"""Test indexation task skipping on document update"""
|
||||
indexer = SearchIndexer()
|
||||
user = factories.UserFactory()
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push"):
|
||||
with transaction.atomic():
|
||||
docs = factories.DocumentFactory.create_batch(5, users=(user,))
|
||||
|
||||
accesses = {str(item.path): {"users": [user.sub]} for item in docs}
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
# Simulate 1 running task
|
||||
cache.set("document-batch-indexer-throttle", 1)
|
||||
|
||||
# save doc to trigger the indexer, but nothing should be done since
|
||||
# the flag is up
|
||||
with transaction.atomic():
|
||||
docs[0].save()
|
||||
docs[2].save()
|
||||
docs[3].save()
|
||||
|
||||
assert [call.args[0] for call in mock_push.call_args_list] == []
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
# No waiting task
|
||||
cache.delete("document-batch-indexer-throttle")
|
||||
|
||||
with transaction.atomic():
|
||||
docs[0].save()
|
||||
docs[2].save()
|
||||
docs[3].save()
|
||||
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
# One call
|
||||
assert len(data) == 1
|
||||
|
||||
assert sorted(data[0], key=itemgetter("id")) == sorted(
|
||||
[
|
||||
indexer.serialize_document(docs[0], accesses),
|
||||
indexer.serialize_document(docs[2], accesses),
|
||||
indexer.serialize_document(docs[3], accesses),
|
||||
],
|
||||
key=itemgetter("id"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_models_documents_access_post_save_indexer():
|
||||
"""Test indexation task on DocumentAccess update"""
|
||||
users = factories.UserFactory.create_batch(3)
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push"):
|
||||
with transaction.atomic():
|
||||
doc = factories.DocumentFactory(users=users)
|
||||
doc_accesses = models.DocumentAccess.objects.filter(document=doc).order_by(
|
||||
"user__sub"
|
||||
)
|
||||
|
||||
reset_batch_indexer_throttle()
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
with transaction.atomic():
|
||||
for doc_access in doc_accesses:
|
||||
doc_access.save()
|
||||
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
# One call
|
||||
assert len(data) == 1
|
||||
|
||||
assert [d["id"] for d in data[0]] == [str(doc.pk)]
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_items_access_post_save_indexer_no_throttle(indexer_settings):
|
||||
"""Test indexation task on ItemAccess update, no throttle"""
|
||||
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
|
||||
|
||||
users = factories.UserFactory.create_batch(3)
|
||||
|
||||
with transaction.atomic():
|
||||
doc = factories.DocumentFactory(users=users)
|
||||
doc_accesses = models.DocumentAccess.objects.filter(document=doc).order_by(
|
||||
"user__sub"
|
||||
)
|
||||
|
||||
reset_batch_indexer_throttle()
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
with transaction.atomic():
|
||||
for doc_access in doc_accesses:
|
||||
doc_access.save()
|
||||
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
# 3 calls
|
||||
assert len(data) == 3
|
||||
# one document per call
|
||||
assert [len(d) for d in data] == [1] * 3
|
||||
# the same document is indexed 3 times
|
||||
assert [d[0]["id"] for d in data] == [str(doc.pk)] * 3
|
||||
635
src/backend/core/tests/test_services_search_indexers.py
Normal file
635
src/backend/core/tests/test_services_search_indexers.py
Normal file
@@ -0,0 +1,635 @@
|
||||
"""Tests for Documents search indexers"""
|
||||
|
||||
from functools import partial
|
||||
from json import dumps as json_dumps
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from requests import HTTPError
|
||||
|
||||
from core import factories, models, utils
|
||||
from core.services.search_indexers import (
|
||||
BaseDocumentIndexer,
|
||||
SearchIndexer,
|
||||
get_document_indexer,
|
||||
get_visited_document_ids_of,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
class FakeDocumentIndexer(BaseDocumentIndexer):
|
||||
"""Fake indexer for test purpose"""
|
||||
|
||||
def serialize_document(self, document, accesses):
|
||||
return {}
|
||||
|
||||
def push(self, data):
|
||||
pass
|
||||
|
||||
def search_query(self, data, token):
|
||||
return {}
|
||||
|
||||
|
||||
def test_services_search_indexer_class_invalid(indexer_settings):
|
||||
"""
|
||||
Should raise RuntimeError if SEARCH_INDEXER_CLASS cannot be imported.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_CLASS = "unknown.Unknown"
|
||||
|
||||
assert get_document_indexer() is None
|
||||
|
||||
|
||||
def test_services_search_indexer_class(indexer_settings):
|
||||
"""
|
||||
Import indexer class defined in setting SEARCH_INDEXER_CLASS.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_CLASS = (
|
||||
"core.tests.test_services_search_indexers.FakeDocumentIndexer"
|
||||
)
|
||||
|
||||
assert isinstance(
|
||||
get_document_indexer(),
|
||||
import_string("core.tests.test_services_search_indexers.FakeDocumentIndexer"),
|
||||
)
|
||||
|
||||
|
||||
def test_services_search_indexer_is_configured(indexer_settings):
|
||||
"""
|
||||
Should return true only when the indexer class and other configuration settings
|
||||
are valid.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_CLASS = None
|
||||
|
||||
# None
|
||||
get_document_indexer.cache_clear()
|
||||
assert not get_document_indexer()
|
||||
|
||||
# Empty
|
||||
indexer_settings.SEARCH_INDEXER_CLASS = ""
|
||||
|
||||
get_document_indexer.cache_clear()
|
||||
assert not get_document_indexer()
|
||||
|
||||
# Valid class
|
||||
indexer_settings.SEARCH_INDEXER_CLASS = (
|
||||
"core.services.search_indexers.SearchIndexer"
|
||||
)
|
||||
|
||||
get_document_indexer.cache_clear()
|
||||
assert get_document_indexer() is not None
|
||||
|
||||
indexer_settings.SEARCH_INDEXER_URL = ""
|
||||
|
||||
# Invalid url
|
||||
get_document_indexer.cache_clear()
|
||||
assert not get_document_indexer()
|
||||
|
||||
|
||||
def test_services_search_indexer_url_is_none(indexer_settings):
|
||||
"""
|
||||
Indexer should raise RuntimeError if SEARCH_INDEXER_URL is None or empty.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_URL = None
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
SearchIndexer()
|
||||
|
||||
assert "SEARCH_INDEXER_URL must be set in Django settings." in str(exc_info.value)
|
||||
|
||||
|
||||
def test_services_search_indexer_url_is_empty(indexer_settings):
|
||||
"""
|
||||
Indexer should raise RuntimeError if SEARCH_INDEXER_URL is empty string.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_URL = ""
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
SearchIndexer()
|
||||
|
||||
assert "SEARCH_INDEXER_URL must be set in Django settings." in str(exc_info.value)
|
||||
|
||||
|
||||
def test_services_search_indexer_secret_is_none(indexer_settings):
|
||||
"""
|
||||
Indexer should raise RuntimeError if SEARCH_INDEXER_SECRET is None.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_SECRET = None
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
SearchIndexer()
|
||||
|
||||
assert "SEARCH_INDEXER_SECRET must be set in Django settings." in str(
|
||||
exc_info.value
|
||||
)
|
||||
|
||||
|
||||
def test_services_search_indexer_secret_is_empty(indexer_settings):
|
||||
"""
|
||||
Indexer should raise RuntimeError if SEARCH_INDEXER_SECRET is empty string.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_SECRET = ""
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
SearchIndexer()
|
||||
|
||||
assert "SEARCH_INDEXER_SECRET must be set in Django settings." in str(
|
||||
exc_info.value
|
||||
)
|
||||
|
||||
|
||||
def test_services_search_endpoint_is_none(indexer_settings):
|
||||
"""
|
||||
Indexer should raise RuntimeError if SEARCH_INDEXER_QUERY_URL is None.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = None
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
SearchIndexer()
|
||||
|
||||
assert "SEARCH_INDEXER_QUERY_URL must be set in Django settings." in str(
|
||||
exc_info.value
|
||||
)
|
||||
|
||||
|
||||
def test_services_search_endpoint_is_empty(indexer_settings):
|
||||
"""
|
||||
Indexer should raise RuntimeError if SEARCH_INDEXER_QUERY_URL is empty.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = ""
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
SearchIndexer()
|
||||
|
||||
assert "SEARCH_INDEXER_QUERY_URL must be set in Django settings." in str(
|
||||
exc_info.value
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_serialize_document_returns_expected_json():
|
||||
"""
|
||||
It should serialize documents with correct metadata and access control.
|
||||
"""
|
||||
user_a, user_b = factories.UserFactory.create_batch(2)
|
||||
document = factories.DocumentFactory()
|
||||
factories.DocumentFactory(parent=document)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=document, user=user_a)
|
||||
factories.UserDocumentAccessFactory(document=document, user=user_b)
|
||||
factories.TeamDocumentAccessFactory(document=document, team="team1")
|
||||
factories.TeamDocumentAccessFactory(document=document, team="team2")
|
||||
|
||||
accesses = {
|
||||
document.path: {
|
||||
"users": {str(user_a.sub), str(user_b.sub)},
|
||||
"teams": {"team1", "team2"},
|
||||
}
|
||||
}
|
||||
|
||||
indexer = SearchIndexer()
|
||||
result = indexer.serialize_document(document, accesses)
|
||||
|
||||
assert set(result.pop("users")) == {str(user_a.sub), str(user_b.sub)}
|
||||
assert set(result.pop("groups")) == {"team1", "team2"}
|
||||
assert result == {
|
||||
"id": str(document.id),
|
||||
"title": document.title,
|
||||
"depth": 1,
|
||||
"path": document.path,
|
||||
"numchild": 1,
|
||||
"content": utils.base64_yjs_to_text(document.content),
|
||||
"created_at": document.created_at.isoformat(),
|
||||
"updated_at": document.updated_at.isoformat(),
|
||||
"reach": document.link_reach,
|
||||
"size": 13,
|
||||
"is_active": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_serialize_document_deleted():
|
||||
"""Deleted documents are marked as just in the serialized json."""
|
||||
parent = factories.DocumentFactory()
|
||||
document = factories.DocumentFactory(parent=parent)
|
||||
|
||||
parent.soft_delete()
|
||||
document.refresh_from_db()
|
||||
|
||||
indexer = SearchIndexer()
|
||||
result = indexer.serialize_document(document, {})
|
||||
|
||||
assert result["is_active"] is False
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_serialize_document_empty():
|
||||
"""Empty documents returns empty content in the serialized json."""
|
||||
document = factories.DocumentFactory(content="", title=None)
|
||||
|
||||
indexer = SearchIndexer()
|
||||
result = indexer.serialize_document(document, {})
|
||||
|
||||
assert result["content"] == ""
|
||||
assert result["title"] == ""
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_services_search_indexers_index_errors(indexer_settings):
|
||||
"""
|
||||
Documents indexing response handling on Find API HTTP errors.
|
||||
"""
|
||||
factories.DocumentFactory()
|
||||
|
||||
indexer_settings.SEARCH_INDEXER_URL = "http://app-find/api/v1.0/documents/index/"
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"http://app-find/api/v1.0/documents/index/",
|
||||
status=401,
|
||||
body=json_dumps({"message": "Authentication failed."}),
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPError):
|
||||
SearchIndexer().index()
|
||||
|
||||
|
||||
@patch.object(SearchIndexer, "push")
|
||||
def test_services_search_indexers_batches_pass_only_batch_accesses(
|
||||
mock_push, indexer_settings
|
||||
):
|
||||
"""
|
||||
Documents indexing should be processed in batches,
|
||||
and only the access data relevant to each batch should be used.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_BATCH_SIZE = 2
|
||||
documents = factories.DocumentFactory.create_batch(5)
|
||||
|
||||
# Attach a single user access to each document
|
||||
expected_user_subs = {}
|
||||
for document in documents:
|
||||
access = factories.UserDocumentAccessFactory(document=document)
|
||||
expected_user_subs[str(document.id)] = str(access.user.sub)
|
||||
|
||||
assert SearchIndexer().index() == 5
|
||||
|
||||
# Should be 3 batches: 2 + 2 + 1
|
||||
assert mock_push.call_count == 3
|
||||
|
||||
seen_doc_ids = set()
|
||||
|
||||
for call in mock_push.call_args_list:
|
||||
batch = call.args[0]
|
||||
assert isinstance(batch, list)
|
||||
|
||||
for doc_json in batch:
|
||||
doc_id = doc_json["id"]
|
||||
seen_doc_ids.add(doc_id)
|
||||
|
||||
# Only one user expected per document
|
||||
assert doc_json["users"] == [expected_user_subs[doc_id]]
|
||||
assert doc_json["groups"] == []
|
||||
|
||||
# Make sure all 5 documents were indexed
|
||||
assert seen_doc_ids == {str(d.id) for d in documents}
|
||||
|
||||
|
||||
@patch.object(SearchIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_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):
|
||||
"""
|
||||
Documents indexing should be processed in batches,
|
||||
and only the access data relevant to each batch should be used.
|
||||
"""
|
||||
document = factories.DocumentFactory()
|
||||
factories.DocumentFactory(content="", title="")
|
||||
empty_title = factories.DocumentFactory(title="")
|
||||
empty_content = factories.DocumentFactory(content="")
|
||||
|
||||
assert SearchIndexer().index() == 3
|
||||
|
||||
assert mock_push.call_count == 1
|
||||
|
||||
# Make sure only not eempty documents are indexed
|
||||
results = {doc["id"] for doc in mock_push.call_args[0][0]}
|
||||
assert results == {
|
||||
str(d.id)
|
||||
for d in (
|
||||
document,
|
||||
empty_content,
|
||||
empty_title,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@patch.object(SearchIndexer, "push")
|
||||
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):
|
||||
"""Document accesses and reach should take into account ancestors link reaches."""
|
||||
great_grand_parent = factories.DocumentFactory(link_reach="restricted")
|
||||
grand_parent = factories.DocumentFactory(
|
||||
parent=great_grand_parent, link_reach="authenticated"
|
||||
)
|
||||
parent = factories.DocumentFactory(parent=grand_parent, link_reach="public")
|
||||
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
|
||||
|
||||
assert SearchIndexer().index() == 4
|
||||
|
||||
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
|
||||
assert len(results) == 4
|
||||
assert results[str(great_grand_parent.id)]["reach"] == "restricted"
|
||||
assert results[str(grand_parent.id)]["reach"] == "authenticated"
|
||||
assert results[str(parent.id)]["reach"] == "public"
|
||||
assert results[str(document.id)]["reach"] == "public"
|
||||
|
||||
|
||||
@patch.object(SearchIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_ancestors_users(mock_push):
|
||||
"""Document accesses and reach should include users from ancestors."""
|
||||
user_gp, user_p, user_d = factories.UserFactory.create_batch(3)
|
||||
|
||||
grand_parent = factories.DocumentFactory(users=[user_gp])
|
||||
parent = factories.DocumentFactory(parent=grand_parent, users=[user_p])
|
||||
document = factories.DocumentFactory(parent=parent, users=[user_d])
|
||||
|
||||
assert SearchIndexer().index() == 3
|
||||
|
||||
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
|
||||
assert len(results) == 3
|
||||
assert results[str(grand_parent.id)]["users"] == [str(user_gp.sub)]
|
||||
assert set(results[str(parent.id)]["users"]) == {str(user_gp.sub), str(user_p.sub)}
|
||||
assert set(results[str(document.id)]["users"]) == {
|
||||
str(user_gp.sub),
|
||||
str(user_p.sub),
|
||||
str(user_d.sub),
|
||||
}
|
||||
|
||||
|
||||
@patch.object(SearchIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_ancestors_teams(mock_push):
|
||||
"""Document accesses and reach should include teams from ancestors."""
|
||||
grand_parent = factories.DocumentFactory(teams=["team_gp"])
|
||||
parent = factories.DocumentFactory(parent=grand_parent, teams=["team_p"])
|
||||
document = factories.DocumentFactory(parent=parent, teams=["team_d"])
|
||||
|
||||
assert SearchIndexer().index() == 3
|
||||
|
||||
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
|
||||
assert len(results) == 3
|
||||
assert results[str(grand_parent.id)]["groups"] == ["team_gp"]
|
||||
assert set(results[str(parent.id)]["groups"]) == {"team_gp", "team_p"}
|
||||
assert set(results[str(document.id)]["groups"]) == {"team_gp", "team_p", "team_d"}
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_push_uses_correct_url_and_data(mock_post, indexer_settings):
|
||||
"""
|
||||
push() should call requests.post with the correct URL from settings
|
||||
the timeout set to 10 seconds and the data as JSON.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_URL = "http://example.com/index"
|
||||
|
||||
indexer = SearchIndexer()
|
||||
sample_data = [{"id": "123", "title": "Test"}]
|
||||
|
||||
mock_response = mock_post.return_value
|
||||
mock_response.raise_for_status.return_value = None # No error
|
||||
|
||||
indexer.push(sample_data)
|
||||
|
||||
mock_post.assert_called_once()
|
||||
args, kwargs = mock_post.call_args
|
||||
|
||||
assert args[0] == indexer_settings.SEARCH_INDEXER_URL
|
||||
assert kwargs.get("json") == sample_data
|
||||
assert kwargs.get("timeout") == 10
|
||||
|
||||
|
||||
def test_get_visited_document_ids_of():
|
||||
"""
|
||||
get_visited_document_ids_of() returns the ids of the documents viewed
|
||||
by the user BUT without specific access configuration (like public ones)
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
other = factories.UserFactory()
|
||||
anonymous = AnonymousUser()
|
||||
queryset = models.Document.objects.all()
|
||||
|
||||
assert not get_visited_document_ids_of(queryset, anonymous)
|
||||
assert not get_visited_document_ids_of(queryset, user)
|
||||
|
||||
doc1, doc2, _ = factories.DocumentFactory.create_batch(3)
|
||||
|
||||
create_link = partial(models.LinkTrace.objects.create, user=user, is_masked=False)
|
||||
|
||||
create_link(document=doc1)
|
||||
create_link(document=doc2)
|
||||
|
||||
# The third document is not visited
|
||||
assert sorted(get_visited_document_ids_of(queryset, user)) == sorted(
|
||||
[str(doc1.pk), str(doc2.pk)]
|
||||
)
|
||||
|
||||
factories.UserDocumentAccessFactory(user=other, document=doc1)
|
||||
factories.UserDocumentAccessFactory(user=user, document=doc2)
|
||||
|
||||
# The second document have an access for the user
|
||||
assert get_visited_document_ids_of(queryset, user) == [str(doc1.pk)]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_get_visited_document_ids_of_deleted():
|
||||
"""
|
||||
get_visited_document_ids_of() returns the ids of the documents viewed
|
||||
by the user if they are not deleted.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
anonymous = AnonymousUser()
|
||||
queryset = models.Document.objects.all()
|
||||
|
||||
assert not get_visited_document_ids_of(queryset, anonymous)
|
||||
assert not get_visited_document_ids_of(queryset, user)
|
||||
|
||||
doc = factories.DocumentFactory()
|
||||
doc_deleted = factories.DocumentFactory()
|
||||
doc_ancestor_deleted = factories.DocumentFactory(parent=doc_deleted)
|
||||
|
||||
create_link = partial(models.LinkTrace.objects.create, user=user, is_masked=False)
|
||||
|
||||
create_link(document=doc)
|
||||
create_link(document=doc_deleted)
|
||||
create_link(document=doc_ancestor_deleted)
|
||||
|
||||
# The all documents are visited
|
||||
assert sorted(get_visited_document_ids_of(queryset, user)) == sorted(
|
||||
[str(doc.pk), str(doc_deleted.pk), str(doc_ancestor_deleted.pk)]
|
||||
)
|
||||
|
||||
doc_deleted.soft_delete()
|
||||
|
||||
# Only the first document is not deleted
|
||||
assert get_visited_document_ids_of(queryset, user) == [str(doc.pk)]
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_services_search_indexers_search_errors(indexer_settings):
|
||||
"""
|
||||
Documents indexing response handling on Find API HTTP errors.
|
||||
"""
|
||||
factories.DocumentFactory()
|
||||
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = (
|
||||
"http://app-find/api/v1.0/documents/search/"
|
||||
)
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"http://app-find/api/v1.0/documents/search/",
|
||||
status=401,
|
||||
body=json_dumps({"message": "Authentication failed."}),
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPError):
|
||||
SearchIndexer().search("alpha", token="mytoken")
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_services_search_indexers_search(mock_post, indexer_settings):
|
||||
"""
|
||||
search() should call requests.post to SEARCH_INDEXER_QUERY_URL with the
|
||||
document ids from linktraces.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
indexer = SearchIndexer()
|
||||
|
||||
mock_response = mock_post.return_value
|
||||
mock_response.raise_for_status.return_value = None # No error
|
||||
|
||||
doc1, doc2, _ = factories.DocumentFactory.create_batch(3)
|
||||
|
||||
create_link = partial(models.LinkTrace.objects.create, user=user, is_masked=False)
|
||||
|
||||
create_link(document=doc1)
|
||||
create_link(document=doc2)
|
||||
|
||||
visited = get_visited_document_ids_of(models.Document.objects.all(), user)
|
||||
|
||||
indexer.search("alpha", visited=visited, token="mytoken")
|
||||
|
||||
args, kwargs = mock_post.call_args
|
||||
|
||||
assert args[0] == indexer_settings.SEARCH_INDEXER_QUERY_URL
|
||||
|
||||
query_data = kwargs.get("json")
|
||||
assert query_data["q"] == "alpha"
|
||||
assert sorted(query_data["visited"]) == sorted([str(doc1.pk), str(doc2.pk)])
|
||||
assert query_data["services"] == ["docs"]
|
||||
assert query_data["nb_results"] == 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
|
||||
@@ -75,3 +75,28 @@ def test_utils_extract_attachments():
|
||||
base64_string = base64.b64encode(update).decode("utf-8")
|
||||
# image_key2 is missing the "/media/" part and shouldn't get extracted
|
||||
assert utils.extract_attachments(base64_string) == [image_key1, image_key3]
|
||||
|
||||
|
||||
def test_utils_get_ancestor_to_descendants_map_single_path():
|
||||
"""Test ancestor mapping of a single path."""
|
||||
paths = ["000100020005"]
|
||||
result = utils.get_ancestor_to_descendants_map(paths, steplen=4)
|
||||
|
||||
assert result == {
|
||||
"0001": {"000100020005"},
|
||||
"00010002": {"000100020005"},
|
||||
"000100020005": {"000100020005"},
|
||||
}
|
||||
|
||||
|
||||
def test_utils_get_ancestor_to_descendants_map_multiple_paths():
|
||||
"""Test ancestor mapping of multiple paths with shared prefixes."""
|
||||
paths = ["000100020005", "00010003"]
|
||||
result = utils.get_ancestor_to_descendants_map(paths, steplen=4)
|
||||
|
||||
assert result == {
|
||||
"0001": {"000100020005", "00010003"},
|
||||
"00010002": {"000100020005"},
|
||||
"000100020005": {"000100020005"},
|
||||
"00010003": {"00010003"},
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import base64
|
||||
import re
|
||||
from collections import defaultdict
|
||||
|
||||
import pycrdt
|
||||
from bs4 import BeautifulSoup
|
||||
@@ -9,6 +10,27 @@ from bs4 import BeautifulSoup
|
||||
from core import enums
|
||||
|
||||
|
||||
def get_ancestor_to_descendants_map(paths, steplen):
|
||||
"""
|
||||
Given a list of document paths, return a mapping of ancestor_path -> set of descendant_paths.
|
||||
|
||||
Each path is assumed to use materialized path format with fixed-length segments.
|
||||
|
||||
Args:
|
||||
paths (list of str): List of full document paths.
|
||||
steplen (int): Length of each path segment.
|
||||
|
||||
Returns:
|
||||
dict[str, set[str]]: Mapping from ancestor path to its descendant paths (including itself).
|
||||
"""
|
||||
ancestor_map = defaultdict(set)
|
||||
for path in paths:
|
||||
for i in range(steplen, len(path) + 1, steplen):
|
||||
ancestor = path[:i]
|
||||
ancestor_map[ancestor].add(path)
|
||||
return ancestor_map
|
||||
|
||||
|
||||
def filter_descendants(paths, root_paths, skip_sorting=False):
|
||||
"""
|
||||
Filters paths to keep only those that are descendants of any path in root_paths.
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
# ruff: noqa: S311, S106
|
||||
"""create_demo management command"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import math
|
||||
import random
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from uuid import uuid4
|
||||
|
||||
from django import db
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
import pycrdt
|
||||
from faker import Faker
|
||||
|
||||
from core import models
|
||||
|
||||
from demo import defaults
|
||||
|
||||
fake = Faker()
|
||||
languages = [x for (x, y) in settings.LANGUAGES]
|
||||
fake = Faker(languages)
|
||||
|
||||
logger = logging.getLogger("impress.commands.demo.create_demo")
|
||||
|
||||
@@ -27,6 +31,16 @@ def random_true_with_probability(probability):
|
||||
return random.random() < probability
|
||||
|
||||
|
||||
def get_ydoc_for_text(text):
|
||||
"""Return a ydoc from plain text for demo purposes."""
|
||||
ydoc = pycrdt.Doc()
|
||||
paragraph = pycrdt.XmlElement("p", {}, [pycrdt.XmlText(text)])
|
||||
fragment = pycrdt.XmlFragment([paragraph])
|
||||
ydoc["document-store"] = fragment
|
||||
update = ydoc.get_update()
|
||||
return base64.b64encode(update).decode("utf-8")
|
||||
|
||||
|
||||
class BulkQueue:
|
||||
"""A utility class to create Django model instances in bulk by just pushing to a queue."""
|
||||
|
||||
@@ -48,7 +62,7 @@ class BulkQueue:
|
||||
self.queue[objects[0]._meta.model.__name__] = [] # noqa: SLF001
|
||||
|
||||
def push(self, obj):
|
||||
"""Add a model instance to queue to that it gets created in bulk."""
|
||||
"""Add a model instance to queue so that it gets created in bulk."""
|
||||
objects = self.queue[obj._meta.model.__name__] # noqa: SLF001
|
||||
objects.append(obj)
|
||||
if len(objects) > self.BATCH_SIZE:
|
||||
@@ -127,7 +141,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(settings.LANGUAGES)[0],
|
||||
language=random.choice(languages),
|
||||
)
|
||||
)
|
||||
queue.flush()
|
||||
@@ -139,17 +153,19 @@ def create_demo(stdout):
|
||||
# pylint: disable=protected-access
|
||||
key = models.Document._int2str(i) # noqa: SLF001
|
||||
padding = models.Document.alphabet[0] * (models.Document.steplen - len(key))
|
||||
queue.push(
|
||||
models.Document(
|
||||
depth=1,
|
||||
path=f"{padding}{key}",
|
||||
creator_id=random.choice(users_ids),
|
||||
title=fake.sentence(nb_words=4),
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED
|
||||
if random_true_with_probability(0.5)
|
||||
else random.choice(models.LinkReachChoices.values),
|
||||
)
|
||||
title = fake.sentence(nb_words=4)
|
||||
document = models.Document(
|
||||
id=uuid4(),
|
||||
depth=1,
|
||||
path=f"{padding}{key}",
|
||||
creator_id=random.choice(users_ids),
|
||||
title=title,
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED
|
||||
if random_true_with_probability(0.5)
|
||||
else random.choice(models.LinkReachChoices.values),
|
||||
)
|
||||
document.save_content(get_ydoc_for_text(f"Content for {title:s}"))
|
||||
queue.push(document)
|
||||
|
||||
queue.flush()
|
||||
|
||||
@@ -179,8 +195,7 @@ def create_demo(stdout):
|
||||
is_superuser=False,
|
||||
is_active=True,
|
||||
is_staff=False,
|
||||
language=dev_user["language"]
|
||||
or random.choice(settings.LANGUAGES)[0],
|
||||
language=dev_user["language"] or random.choice(languages),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -99,6 +99,31 @@ class Base(Configuration):
|
||||
}
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
||||
|
||||
# Search
|
||||
SEARCH_INDEXER_CLASS = values.Value(
|
||||
default=None,
|
||||
environ_name="SEARCH_INDEXER_CLASS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
SEARCH_INDEXER_BATCH_SIZE = values.IntegerValue(
|
||||
default=100_000, environ_name="SEARCH_INDEXER_BATCH_SIZE", environ_prefix=None
|
||||
)
|
||||
SEARCH_INDEXER_URL = values.Value(
|
||||
default=None, environ_name="SEARCH_INDEXER_URL", environ_prefix=None
|
||||
)
|
||||
SEARCH_INDEXER_COUNTDOWN = values.IntegerValue(
|
||||
default=1, environ_name="SEARCH_INDEXER_COUNTDOWN", environ_prefix=None
|
||||
)
|
||||
SEARCH_INDEXER_SECRET = values.Value(
|
||||
default=None, environ_name="SEARCH_INDEXER_SECRET", environ_prefix=None
|
||||
)
|
||||
SEARCH_INDEXER_QUERY_URL = values.Value(
|
||||
default=None, environ_name="SEARCH_INDEXER_QUERY_URL", environ_prefix=None
|
||||
)
|
||||
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/"
|
||||
STATIC_ROOT = os.path.join(DATA_DIR, "static")
|
||||
@@ -328,6 +353,7 @@ class Base(Configuration):
|
||||
# OIDC third party
|
||||
"mozilla_django_oidc",
|
||||
"lasuite.malware_detection",
|
||||
"lasuite.marketing",
|
||||
"csp",
|
||||
]
|
||||
|
||||
@@ -483,6 +509,9 @@ 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"),
|
||||
@@ -808,6 +837,30 @@ 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):
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
|
||||
"PO-Revision-Date: 2025-11-19 10:13\n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Breton\n"
|
||||
"Language: br_FR\n"
|
||||
@@ -50,67 +50,72 @@ msgstr "Kuzhet"
|
||||
msgid "Favorite"
|
||||
msgstr "Sinedoù"
|
||||
|
||||
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
|
||||
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
|
||||
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:500 core/api/serializers.py:500
|
||||
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
|
||||
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:536 core/api/serializers.py:536
|
||||
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
|
||||
msgid "This field is required."
|
||||
msgstr "Ar vaezienn-mañ a zo rekis."
|
||||
|
||||
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:693 core/api/serializers.py:693
|
||||
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
|
||||
msgid "Body"
|
||||
msgstr "Korf"
|
||||
|
||||
#: build/lib/core/api/serializers.py:696 core/api/serializers.py:696
|
||||
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
|
||||
msgid "Body type"
|
||||
msgstr "Doare korf"
|
||||
|
||||
#: build/lib/core/api/serializers.py:702 core/api/serializers.py:702
|
||||
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
|
||||
msgid "Format"
|
||||
msgstr "Stumm"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "eilenn {title}"
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:42 core/choices.py:35
|
||||
#: core/choices.py:42
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
msgstr "Lenner"
|
||||
|
||||
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
|
||||
#: core/choices.py:43
|
||||
#: 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
|
||||
msgid "Editor"
|
||||
msgstr "Embanner"
|
||||
|
||||
#: build/lib/core/choices.py:44 core/choices.py:44
|
||||
#: build/lib/core/choices.py:46 core/choices.py:46
|
||||
msgid "Administrator"
|
||||
msgstr "Merour"
|
||||
|
||||
#: build/lib/core/choices.py:45 core/choices.py:45
|
||||
#: build/lib/core/choices.py:47 core/choices.py:47
|
||||
msgid "Owner"
|
||||
msgstr "Perc'henn"
|
||||
|
||||
#: build/lib/core/choices.py:56 core/choices.py:56
|
||||
#: build/lib/core/choices.py:58 core/choices.py:58
|
||||
msgid "Restricted"
|
||||
msgstr "Strishaet"
|
||||
|
||||
#: build/lib/core/choices.py:60 core/choices.py:60
|
||||
#: build/lib/core/choices.py:62 core/choices.py:62
|
||||
msgid "Authenticated"
|
||||
msgstr "Anavezet"
|
||||
|
||||
#: build/lib/core/choices.py:62 core/choices.py:62
|
||||
#: build/lib/core/choices.py:64 core/choices.py:64
|
||||
msgid "Public"
|
||||
msgstr "Publik"
|
||||
|
||||
@@ -234,8 +239,8 @@ msgstr "implijer"
|
||||
msgid "users"
|
||||
msgstr "implijerien"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
|
||||
#: core/models.py:361 core/models.py:1276
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
msgid "title"
|
||||
msgstr "titl"
|
||||
|
||||
@@ -251,155 +256,188 @@ msgstr "Restr"
|
||||
msgid "Documents"
|
||||
msgstr "Restroù"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
|
||||
#: core/models.py:822
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
msgid "Untitled Document"
|
||||
msgstr "Restr hep titl"
|
||||
|
||||
#: build/lib/core/models.py:857 core/models.py:857
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} en deus rannet ur restr ganeoc'h!"
|
||||
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} en deus pedet ac'hanoc'h gant ar rol \"{role}\" war ar restr da-heul:"
|
||||
|
||||
#: build/lib/core/models.py:867 core/models.py:867
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} en deus rannet ur restr ganeoc'h: {title}"
|
||||
|
||||
#: build/lib/core/models.py:967 core/models.py:967
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Roud liamm ar restr/an implijer"
|
||||
|
||||
#: build/lib/core/models.py:968 core/models.py:968
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Roudoù liamm ar restr/an implijer"
|
||||
|
||||
#: build/lib/core/models.py:974 core/models.py:974
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "Ur roud liamm a zo dija evit an restr/an implijer."
|
||||
|
||||
#: build/lib/core/models.py:997 core/models.py:997
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
msgid "Document favorite"
|
||||
msgstr "Restr muiañ-karet"
|
||||
|
||||
#: build/lib/core/models.py:998 core/models.py:998
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
msgid "Document favorites"
|
||||
msgstr "Restroù muiañ-karet"
|
||||
|
||||
#: build/lib/core/models.py:1004 core/models.py:1004
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "Ar restr-mañ a zo ur restr muiañ karet gant an implijer-mañ."
|
||||
|
||||
#: build/lib/core/models.py:1026 core/models.py:1026
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
msgid "Document/user relation"
|
||||
msgstr "Liamm restr/implijer"
|
||||
|
||||
#: build/lib/core/models.py:1027 core/models.py:1027
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
msgid "Document/user relations"
|
||||
msgstr "Liammoù restr/implijer"
|
||||
|
||||
#: build/lib/core/models.py:1033 core/models.py:1033
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
msgid "This user is already in this document."
|
||||
msgstr "An implijer-mañ a zo dija er restr-mañ."
|
||||
|
||||
#: build/lib/core/models.py:1039 core/models.py:1039
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Ar skipailh-mañ a zo dija en restr-mañ."
|
||||
|
||||
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
|
||||
#: core/models.py:1045 core/models.py:1362
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "An implijer pe ar skipailh a rank bezañ termenet, ket an daou avat."
|
||||
|
||||
#: build/lib/core/models.py:1191 core/models.py:1191
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
msgid "Document ask for access"
|
||||
msgstr "Goulenn tizhout ar restr"
|
||||
|
||||
#: build/lib/core/models.py:1192 core/models.py:1192
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "Goulennoù tizhout ar restr"
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr "An implijer en deus goulennet tizhout ar restr-mañ."
|
||||
|
||||
#: build/lib/core/models.py:1255 core/models.py:1255
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr "{name} en defe c'hoant da dizhout ar restr-mañ!"
|
||||
|
||||
#: build/lib/core/models.py:1259 core/models.py:1259
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr "{name} en defe c'hoant da dizhout ar restr da-heul:"
|
||||
|
||||
#: build/lib/core/models.py:1265 core/models.py:1265
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr "{name} en defe c'hoant da dizhout ar restr: {title}"
|
||||
|
||||
#: build/lib/core/models.py:1277 core/models.py:1277
|
||||
#: build/lib/core/models.py: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
|
||||
msgid "description"
|
||||
msgstr "deskrivadur"
|
||||
|
||||
#: build/lib/core/models.py:1278 core/models.py:1278
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
msgid "code"
|
||||
msgstr "kod"
|
||||
|
||||
#: build/lib/core/models.py:1279 core/models.py:1279
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
msgid "css"
|
||||
msgstr "css"
|
||||
|
||||
#: build/lib/core/models.py:1281 core/models.py:1281
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "public"
|
||||
msgstr "publik"
|
||||
|
||||
#: build/lib/core/models.py:1283 core/models.py:1283
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "M'eo foran ar patrom-mañ hag implijus gant n'eus forzh piv."
|
||||
|
||||
#: build/lib/core/models.py:1289 core/models.py:1289
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
msgid "Template"
|
||||
msgstr "Patrom"
|
||||
|
||||
#: build/lib/core/models.py:1290 core/models.py:1290
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
msgid "Templates"
|
||||
msgstr "Patromoù"
|
||||
|
||||
#: build/lib/core/models.py:1343 core/models.py:1343
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
msgid "Template/user relation"
|
||||
msgstr "Liamm patrom/implijer"
|
||||
|
||||
#: build/lib/core/models.py:1344 core/models.py:1344
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
msgid "Template/user relations"
|
||||
msgstr "Liammoù patrom/implijer"
|
||||
|
||||
#: build/lib/core/models.py:1350 core/models.py:1350
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
msgid "This user is already in this template."
|
||||
msgstr "An implijer-mañ a zo dija er patrom-mañ."
|
||||
|
||||
#: build/lib/core/models.py:1356 core/models.py:1356
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Ar skipailh-mañ a zo dija er patrom-mañ."
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
msgid "email address"
|
||||
msgstr "postel"
|
||||
|
||||
#: build/lib/core/models.py:1452 core/models.py:1452
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
msgid "Document invitation"
|
||||
msgstr "Pedadenn d'ur restr"
|
||||
|
||||
#: build/lib/core/models.py:1453 core/models.py:1453
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
msgid "Document invitations"
|
||||
msgstr "Pedadennoù d'ur restr"
|
||||
|
||||
#: build/lib/core/models.py:1473 core/models.py:1473
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Ar postel-mañ a zo liammet ouzh un implijer enskrivet."
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
|
||||
"PO-Revision-Date: 2025-11-19 10:13\n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: German\n"
|
||||
"Language: de_DE\n"
|
||||
@@ -50,67 +50,72 @@ msgstr ""
|
||||
msgid "Favorite"
|
||||
msgstr "Favorit"
|
||||
|
||||
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
|
||||
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "Ein neues Dokument wurde in Ihrem Namen erstellt!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:500 core/api/serializers.py:500
|
||||
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr "Sie sind Besitzer eines neuen Dokuments:"
|
||||
|
||||
#: build/lib/core/api/serializers.py:536 core/api/serializers.py:536
|
||||
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
|
||||
msgid "This field is required."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:693 core/api/serializers.py:693
|
||||
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
|
||||
msgid "Body"
|
||||
msgstr "Inhalt"
|
||||
|
||||
#: build/lib/core/api/serializers.py:696 core/api/serializers.py:696
|
||||
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
|
||||
msgid "Body type"
|
||||
msgstr "Typ"
|
||||
|
||||
#: build/lib/core/api/serializers.py:702 core/api/serializers.py:702
|
||||
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
|
||||
msgid "Format"
|
||||
msgstr "Format"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "Kopie von {title}"
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:42 core/choices.py:35
|
||||
#: core/choices.py:42
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
msgstr "Lesen"
|
||||
|
||||
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
|
||||
#: core/choices.py:43
|
||||
#: 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
|
||||
msgid "Editor"
|
||||
msgstr "Bearbeiten"
|
||||
|
||||
#: build/lib/core/choices.py:44 core/choices.py:44
|
||||
#: build/lib/core/choices.py:46 core/choices.py:46
|
||||
msgid "Administrator"
|
||||
msgstr "Administrator"
|
||||
|
||||
#: build/lib/core/choices.py:45 core/choices.py:45
|
||||
#: build/lib/core/choices.py:47 core/choices.py:47
|
||||
msgid "Owner"
|
||||
msgstr "Besitzer"
|
||||
|
||||
#: build/lib/core/choices.py:56 core/choices.py:56
|
||||
#: build/lib/core/choices.py:58 core/choices.py:58
|
||||
msgid "Restricted"
|
||||
msgstr "Beschränkt"
|
||||
|
||||
#: build/lib/core/choices.py:60 core/choices.py:60
|
||||
#: build/lib/core/choices.py:62 core/choices.py:62
|
||||
msgid "Authenticated"
|
||||
msgstr "Authentifiziert"
|
||||
|
||||
#: build/lib/core/choices.py:62 core/choices.py:62
|
||||
#: build/lib/core/choices.py:64 core/choices.py:64
|
||||
msgid "Public"
|
||||
msgstr "Öffentlich"
|
||||
|
||||
@@ -234,8 +239,8 @@ msgstr "Benutzer"
|
||||
msgid "users"
|
||||
msgstr "Benutzer"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
|
||||
#: core/models.py:361 core/models.py:1276
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
msgid "title"
|
||||
msgstr "Titel"
|
||||
|
||||
@@ -251,155 +256,188 @@ msgstr "Dokument"
|
||||
msgid "Documents"
|
||||
msgstr "Dokumente"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
|
||||
#: core/models.py:822
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
msgid "Untitled Document"
|
||||
msgstr "Unbenanntes Dokument"
|
||||
|
||||
#: build/lib/core/models.py:857 core/models.py:857
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} hat ein Dokument mit Ihnen geteilt!"
|
||||
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} hat Sie mit der Rolle \"{role}\" zu folgendem Dokument eingeladen:"
|
||||
|
||||
#: build/lib/core/models.py:867 core/models.py:867
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} hat ein Dokument mit Ihnen geteilt: {title}"
|
||||
|
||||
#: build/lib/core/models.py:967 core/models.py:967
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Dokument/Benutzer Linkverfolgung"
|
||||
|
||||
#: build/lib/core/models.py:968 core/models.py:968
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Dokument/Benutzer Linkverfolgung"
|
||||
|
||||
#: build/lib/core/models.py:974 core/models.py:974
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "Für dieses Dokument/ diesen Benutzer ist bereits eine Linkverfolgung vorhanden."
|
||||
|
||||
#: build/lib/core/models.py:997 core/models.py:997
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
msgid "Document favorite"
|
||||
msgstr "Dokumentenfavorit"
|
||||
|
||||
#: build/lib/core/models.py:998 core/models.py:998
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
msgid "Document favorites"
|
||||
msgstr "Dokumentfavoriten"
|
||||
|
||||
#: build/lib/core/models.py:1004 core/models.py:1004
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "Dieses Dokument ist bereits durch den gleichen Benutzer favorisiert worden."
|
||||
|
||||
#: build/lib/core/models.py:1026 core/models.py:1026
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
msgid "Document/user relation"
|
||||
msgstr "Dokument/Benutzerbeziehung"
|
||||
|
||||
#: build/lib/core/models.py:1027 core/models.py:1027
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
msgid "Document/user relations"
|
||||
msgstr "Dokument/Benutzerbeziehungen"
|
||||
|
||||
#: build/lib/core/models.py:1033 core/models.py:1033
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Dieser Benutzer befindet sich bereits in diesem Dokument."
|
||||
|
||||
#: build/lib/core/models.py:1039 core/models.py:1039
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Dieses Team befindet sich bereits in diesem Dokument."
|
||||
|
||||
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
|
||||
#: core/models.py:1045 core/models.py:1362
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Benutzer oder Team müssen gesetzt werden, nicht beides."
|
||||
|
||||
#: build/lib/core/models.py:1191 core/models.py:1191
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
msgid "Document ask for access"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1192 core/models.py:1192
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
msgid "Document ask for accesses"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1255 core/models.py:1255
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1259 core/models.py:1259
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1265 core/models.py:1265
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1277 core/models.py:1277
|
||||
#: build/lib/core/models.py: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
|
||||
msgid "description"
|
||||
msgstr "Beschreibung"
|
||||
|
||||
#: build/lib/core/models.py:1278 core/models.py:1278
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
msgid "code"
|
||||
msgstr "Code"
|
||||
|
||||
#: build/lib/core/models.py:1279 core/models.py:1279
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
msgid "css"
|
||||
msgstr "CSS"
|
||||
|
||||
#: build/lib/core/models.py:1281 core/models.py:1281
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "public"
|
||||
msgstr "öffentlich"
|
||||
|
||||
#: build/lib/core/models.py:1283 core/models.py:1283
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "Ob diese Vorlage für jedermann öffentlich ist."
|
||||
|
||||
#: build/lib/core/models.py:1289 core/models.py:1289
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
msgid "Template"
|
||||
msgstr "Vorlage"
|
||||
|
||||
#: build/lib/core/models.py:1290 core/models.py:1290
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
msgid "Templates"
|
||||
msgstr "Vorlagen"
|
||||
|
||||
#: build/lib/core/models.py:1343 core/models.py:1343
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
msgid "Template/user relation"
|
||||
msgstr "Vorlage/Benutzer-Beziehung"
|
||||
|
||||
#: build/lib/core/models.py:1344 core/models.py:1344
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
msgid "Template/user relations"
|
||||
msgstr "Vorlage/Benutzerbeziehungen"
|
||||
|
||||
#: build/lib/core/models.py:1350 core/models.py:1350
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
msgid "This user is already in this template."
|
||||
msgstr "Dieser Benutzer ist bereits in dieser Vorlage."
|
||||
|
||||
#: build/lib/core/models.py:1356 core/models.py:1356
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Dieses Team ist bereits in diesem Template."
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
msgid "email address"
|
||||
msgstr "E-Mail-Adresse"
|
||||
|
||||
#: build/lib/core/models.py:1452 core/models.py:1452
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
msgid "Document invitation"
|
||||
msgstr "Einladung zum Dokument"
|
||||
|
||||
#: build/lib/core/models.py:1453 core/models.py:1453
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
msgid "Document invitations"
|
||||
msgstr "Dokumenteinladungen"
|
||||
|
||||
#: build/lib/core/models.py:1473 core/models.py:1473
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Diese E-Mail ist bereits einem registrierten Benutzer zugeordnet."
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
|
||||
"PO-Revision-Date: 2025-11-19 10:13\n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
"Language: en_US\n"
|
||||
@@ -50,67 +50,72 @@ msgstr ""
|
||||
msgid "Favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
|
||||
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:500 core/api/serializers.py:500
|
||||
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:536 core/api/serializers.py:536
|
||||
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
|
||||
msgid "This field is required."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:693 core/api/serializers.py:693
|
||||
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
|
||||
msgid "Body"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:696 core/api/serializers.py:696
|
||||
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
|
||||
msgid "Body type"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:702 core/api/serializers.py:702
|
||||
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
|
||||
msgid "Format"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:42 core/choices.py:35
|
||||
#: core/choices.py:42
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
|
||||
#: core/choices.py:43
|
||||
#: 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
|
||||
msgid "Editor"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:44 core/choices.py:44
|
||||
#: build/lib/core/choices.py:46 core/choices.py:46
|
||||
msgid "Administrator"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:45 core/choices.py:45
|
||||
#: build/lib/core/choices.py:47 core/choices.py:47
|
||||
msgid "Owner"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:56 core/choices.py:56
|
||||
#: build/lib/core/choices.py:58 core/choices.py:58
|
||||
msgid "Restricted"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:60 core/choices.py:60
|
||||
#: build/lib/core/choices.py:62 core/choices.py:62
|
||||
msgid "Authenticated"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:62 core/choices.py:62
|
||||
#: build/lib/core/choices.py:64 core/choices.py:64
|
||||
msgid "Public"
|
||||
msgstr ""
|
||||
|
||||
@@ -234,8 +239,8 @@ msgstr ""
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
|
||||
#: core/models.py:361 core/models.py:1276
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
@@ -251,155 +256,188 @@ msgstr ""
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
|
||||
#: core/models.py:822
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:857 core/models.py:857
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:867 core/models.py:867
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:967 core/models.py:967
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:968 core/models.py:968
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:974 core/models.py:974
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:997 core/models.py:997
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
msgid "Document favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:998 core/models.py:998
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
msgid "Document favorites"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1004 core/models.py:1004
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1026 core/models.py:1026
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1027 core/models.py:1027
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1033 core/models.py:1033
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1039 core/models.py:1039
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
|
||||
#: core/models.py:1045 core/models.py:1362
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1191 core/models.py:1191
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
msgid "Document ask for access"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1192 core/models.py:1192
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
msgid "Document ask for accesses"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1255 core/models.py:1255
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1259 core/models.py:1259
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1265 core/models.py:1265
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1277 core/models.py:1277
|
||||
#: build/lib/core/models.py: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
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1278 core/models.py:1278
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1279 core/models.py:1279
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1281 core/models.py:1281
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1283 core/models.py:1283
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1289 core/models.py:1289
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1290 core/models.py:1290
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1343 core/models.py:1343
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1344 core/models.py:1344
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1350 core/models.py:1350
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1356 core/models.py:1356
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1452 core/models.py:1452
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1453 core/models.py:1453
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1473 core/models.py:1473
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
|
||||
"PO-Revision-Date: 2025-11-19 10:13\n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Spanish\n"
|
||||
"Language: es_ES\n"
|
||||
@@ -50,67 +50,72 @@ msgstr ""
|
||||
msgid "Favorite"
|
||||
msgstr "Favorito"
|
||||
|
||||
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
|
||||
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "¡Un nuevo documento se ha creado por ti!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:500 core/api/serializers.py:500
|
||||
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
|
||||
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:536 core/api/serializers.py:536
|
||||
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
|
||||
msgid "This field is required."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:693 core/api/serializers.py:693
|
||||
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
|
||||
msgid "Body"
|
||||
msgstr "Cuerpo"
|
||||
|
||||
#: build/lib/core/api/serializers.py:696 core/api/serializers.py:696
|
||||
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
|
||||
msgid "Body type"
|
||||
msgstr "Tipo de Cuerpo"
|
||||
|
||||
#: build/lib/core/api/serializers.py:702 core/api/serializers.py:702
|
||||
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
|
||||
msgid "Format"
|
||||
msgstr "Formato"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "copia de {title}"
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:42 core/choices.py:35
|
||||
#: core/choices.py:42
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
msgstr "Lector"
|
||||
|
||||
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
|
||||
#: core/choices.py:43
|
||||
#: 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
|
||||
msgid "Editor"
|
||||
msgstr "Editor"
|
||||
|
||||
#: build/lib/core/choices.py:44 core/choices.py:44
|
||||
#: build/lib/core/choices.py:46 core/choices.py:46
|
||||
msgid "Administrator"
|
||||
msgstr "Administrador"
|
||||
|
||||
#: build/lib/core/choices.py:45 core/choices.py:45
|
||||
#: build/lib/core/choices.py:47 core/choices.py:47
|
||||
msgid "Owner"
|
||||
msgstr "Propietario"
|
||||
|
||||
#: build/lib/core/choices.py:56 core/choices.py:56
|
||||
#: build/lib/core/choices.py:58 core/choices.py:58
|
||||
msgid "Restricted"
|
||||
msgstr "Restringido"
|
||||
|
||||
#: build/lib/core/choices.py:60 core/choices.py:60
|
||||
#: build/lib/core/choices.py:62 core/choices.py:62
|
||||
msgid "Authenticated"
|
||||
msgstr "Autentificado"
|
||||
|
||||
#: build/lib/core/choices.py:62 core/choices.py:62
|
||||
#: build/lib/core/choices.py:64 core/choices.py:64
|
||||
msgid "Public"
|
||||
msgstr "Público"
|
||||
|
||||
@@ -234,8 +239,8 @@ msgstr "usuario"
|
||||
msgid "users"
|
||||
msgstr "usuarios"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
|
||||
#: core/models.py:361 core/models.py:1276
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
msgid "title"
|
||||
msgstr "título"
|
||||
|
||||
@@ -251,155 +256,188 @@ msgstr "Documento"
|
||||
msgid "Documents"
|
||||
msgstr "Documentos"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
|
||||
#: core/models.py:822
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
msgid "Untitled Document"
|
||||
msgstr "Documento sin título"
|
||||
|
||||
#: build/lib/core/models.py:857 core/models.py:857
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "¡{name} ha compartido un documento contigo!"
|
||||
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "Te ha invitado {name} al siguiente documento con el rol \"{role}\" :"
|
||||
|
||||
#: build/lib/core/models.py:867 core/models.py:867
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} ha compartido un documento contigo: {title}"
|
||||
|
||||
#: build/lib/core/models.py:967 core/models.py:967
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Traza del enlace de documento/usuario"
|
||||
|
||||
#: build/lib/core/models.py:968 core/models.py:968
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Trazas del enlace de documento/usuario"
|
||||
|
||||
#: build/lib/core/models.py:974 core/models.py:974
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "Ya existe una traza de enlace para este documento/usuario."
|
||||
|
||||
#: build/lib/core/models.py:997 core/models.py:997
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
msgid "Document favorite"
|
||||
msgstr "Documento favorito"
|
||||
|
||||
#: build/lib/core/models.py:998 core/models.py:998
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
msgid "Document favorites"
|
||||
msgstr "Documentos favoritos"
|
||||
|
||||
#: build/lib/core/models.py:1004 core/models.py:1004
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "Este documento ya ha sido marcado como favorito por el usuario."
|
||||
|
||||
#: build/lib/core/models.py:1026 core/models.py:1026
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
msgid "Document/user relation"
|
||||
msgstr "Relación documento/usuario"
|
||||
|
||||
#: build/lib/core/models.py:1027 core/models.py:1027
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
msgid "Document/user relations"
|
||||
msgstr "Relaciones documento/usuario"
|
||||
|
||||
#: build/lib/core/models.py:1033 core/models.py:1033
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Este usuario ya forma parte del documento."
|
||||
|
||||
#: build/lib/core/models.py:1039 core/models.py:1039
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Este equipo ya forma parte del documento."
|
||||
|
||||
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
|
||||
#: core/models.py:1045 core/models.py:1362
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Debe establecerse un usuario o un equipo, no ambos."
|
||||
|
||||
#: build/lib/core/models.py:1191 core/models.py:1191
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
msgid "Document ask for access"
|
||||
msgstr "Solicitud de acceso"
|
||||
|
||||
#: build/lib/core/models.py:1192 core/models.py:1192
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "Solicitud de accesos"
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr "Este usuario ya ha solicitado acceso a este documento."
|
||||
|
||||
#: build/lib/core/models.py:1255 core/models.py:1255
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr "¡{name} desea acceder a un documento!"
|
||||
|
||||
#: build/lib/core/models.py:1259 core/models.py:1259
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr "{name} desea acceso al siguiente documento:"
|
||||
|
||||
#: build/lib/core/models.py:1265 core/models.py:1265
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr "{name} está pidiendo acceso al documento: {title}"
|
||||
|
||||
#: build/lib/core/models.py:1277 core/models.py:1277
|
||||
#: build/lib/core/models.py: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
|
||||
msgid "description"
|
||||
msgstr "descripción"
|
||||
|
||||
#: build/lib/core/models.py:1278 core/models.py:1278
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
msgid "code"
|
||||
msgstr "código"
|
||||
|
||||
#: build/lib/core/models.py:1279 core/models.py:1279
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
msgid "css"
|
||||
msgstr "css"
|
||||
|
||||
#: build/lib/core/models.py:1281 core/models.py:1281
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "public"
|
||||
msgstr "público"
|
||||
|
||||
#: build/lib/core/models.py:1283 core/models.py:1283
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "Si esta plantilla es pública para que cualquiera la utilice."
|
||||
|
||||
#: build/lib/core/models.py:1289 core/models.py:1289
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
msgid "Template"
|
||||
msgstr "Plantilla"
|
||||
|
||||
#: build/lib/core/models.py:1290 core/models.py:1290
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
msgid "Templates"
|
||||
msgstr "Plantillas"
|
||||
|
||||
#: build/lib/core/models.py:1343 core/models.py:1343
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
msgid "Template/user relation"
|
||||
msgstr "Relación plantilla/usuario"
|
||||
|
||||
#: build/lib/core/models.py:1344 core/models.py:1344
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
msgid "Template/user relations"
|
||||
msgstr "Relaciones plantilla/usuario"
|
||||
|
||||
#: build/lib/core/models.py:1350 core/models.py:1350
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
msgid "This user is already in this template."
|
||||
msgstr "Este usuario ya forma parte de la plantilla."
|
||||
|
||||
#: build/lib/core/models.py:1356 core/models.py:1356
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Este equipo ya se encuentra en esta plantilla."
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
msgid "email address"
|
||||
msgstr "dirección de correo electrónico"
|
||||
|
||||
#: build/lib/core/models.py:1452 core/models.py:1452
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
msgid "Document invitation"
|
||||
msgstr "Invitación al documento"
|
||||
|
||||
#: build/lib/core/models.py:1453 core/models.py:1453
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
msgid "Document invitations"
|
||||
msgstr "Invitaciones a documentos"
|
||||
|
||||
#: build/lib/core/models.py:1473 core/models.py:1473
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Este correo electrónico está asociado a un usuario registrado."
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
|
||||
"PO-Revision-Date: 2025-11-19 10:13\n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: French\n"
|
||||
"Language: fr_FR\n"
|
||||
@@ -50,67 +50,72 @@ msgstr "Masqué"
|
||||
msgid "Favorite"
|
||||
msgstr "Favoris"
|
||||
|
||||
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
|
||||
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "Un nouveau document a été créé pour vous !"
|
||||
|
||||
#: build/lib/core/api/serializers.py:500 core/api/serializers.py:500
|
||||
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
|
||||
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:536 core/api/serializers.py:536
|
||||
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
|
||||
msgid "This field is required."
|
||||
msgstr "Ce champ est obligatoire."
|
||||
|
||||
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#, 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:693 core/api/serializers.py:693
|
||||
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
|
||||
msgid "Body"
|
||||
msgstr "Corps"
|
||||
|
||||
#: build/lib/core/api/serializers.py:696 core/api/serializers.py:696
|
||||
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
|
||||
msgid "Body type"
|
||||
msgstr "Type de corps"
|
||||
|
||||
#: build/lib/core/api/serializers.py:702 core/api/serializers.py:702
|
||||
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
|
||||
msgid "Format"
|
||||
msgstr "Format"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "copie de {title}"
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:42 core/choices.py:35
|
||||
#: core/choices.py:42
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
msgstr "Lecteur"
|
||||
|
||||
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
|
||||
#: core/choices.py:43
|
||||
#: 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
|
||||
msgid "Editor"
|
||||
msgstr "Éditeur"
|
||||
|
||||
#: build/lib/core/choices.py:44 core/choices.py:44
|
||||
#: build/lib/core/choices.py:46 core/choices.py:46
|
||||
msgid "Administrator"
|
||||
msgstr "Administrateur"
|
||||
|
||||
#: build/lib/core/choices.py:45 core/choices.py:45
|
||||
#: build/lib/core/choices.py:47 core/choices.py:47
|
||||
msgid "Owner"
|
||||
msgstr "Propriétaire"
|
||||
|
||||
#: build/lib/core/choices.py:56 core/choices.py:56
|
||||
#: build/lib/core/choices.py:58 core/choices.py:58
|
||||
msgid "Restricted"
|
||||
msgstr "Restreint"
|
||||
|
||||
#: build/lib/core/choices.py:60 core/choices.py:60
|
||||
#: build/lib/core/choices.py:62 core/choices.py:62
|
||||
msgid "Authenticated"
|
||||
msgstr "Authentifié"
|
||||
|
||||
#: build/lib/core/choices.py:62 core/choices.py:62
|
||||
#: build/lib/core/choices.py:64 core/choices.py:64
|
||||
msgid "Public"
|
||||
msgstr "Public"
|
||||
|
||||
@@ -234,8 +239,8 @@ msgstr "utilisateur"
|
||||
msgid "users"
|
||||
msgstr "utilisateurs"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
|
||||
#: core/models.py:361 core/models.py:1276
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
msgid "title"
|
||||
msgstr "titre"
|
||||
|
||||
@@ -251,155 +256,188 @@ msgstr "Document"
|
||||
msgid "Documents"
|
||||
msgstr "Documents"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
|
||||
#: core/models.py:822
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
msgid "Untitled Document"
|
||||
msgstr "Document sans titre"
|
||||
|
||||
#: build/lib/core/models.py:857 core/models.py:857
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} a partagé un document avec vous!"
|
||||
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} vous a invité avec le rôle \"{role}\" sur le document suivant :"
|
||||
|
||||
#: build/lib/core/models.py:867 core/models.py:867
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} a partagé un document avec vous : {title}"
|
||||
|
||||
#: build/lib/core/models.py:967 core/models.py:967
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Trace du lien document/utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:968 core/models.py:968
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Traces du lien document/utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:974 core/models.py:974
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "Une trace de lien existe déjà pour ce document/utilisateur."
|
||||
|
||||
#: build/lib/core/models.py:997 core/models.py:997
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
msgid "Document favorite"
|
||||
msgstr "Document favori"
|
||||
|
||||
#: build/lib/core/models.py:998 core/models.py:998
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
msgid "Document favorites"
|
||||
msgstr "Documents favoris"
|
||||
|
||||
#: build/lib/core/models.py:1004 core/models.py:1004
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "Ce document est déjà un favori de cet utilisateur."
|
||||
|
||||
#: build/lib/core/models.py:1026 core/models.py:1026
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
msgid "Document/user relation"
|
||||
msgstr "Relation document/utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:1027 core/models.py:1027
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
msgid "Document/user relations"
|
||||
msgstr "Relations document/utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:1033 core/models.py:1033
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Cet utilisateur est déjà dans ce document."
|
||||
|
||||
#: build/lib/core/models.py:1039 core/models.py:1039
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Cette équipe est déjà dans ce document."
|
||||
|
||||
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
|
||||
#: core/models.py:1045 core/models.py:1362
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "L'utilisateur ou l'équipe doivent être définis, pas les deux."
|
||||
|
||||
#: build/lib/core/models.py:1191 core/models.py:1191
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
msgid "Document ask for access"
|
||||
msgstr "Demande d'accès au document"
|
||||
|
||||
#: build/lib/core/models.py:1192 core/models.py:1192
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "Demande d'accès au document"
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr "Cet utilisateur a déjà demandé l'accès à ce document."
|
||||
|
||||
#: build/lib/core/models.py:1255 core/models.py:1255
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr "{name} souhaiterait accéder au document suivant !"
|
||||
|
||||
#: build/lib/core/models.py:1259 core/models.py:1259
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr "{name} souhaiterait accéder au document suivant :"
|
||||
|
||||
#: build/lib/core/models.py:1265 core/models.py:1265
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr "{name} demande l'accès au document : {title}"
|
||||
|
||||
#: build/lib/core/models.py:1277 core/models.py:1277
|
||||
#: build/lib/core/models.py: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
|
||||
msgid "description"
|
||||
msgstr "description"
|
||||
|
||||
#: build/lib/core/models.py:1278 core/models.py:1278
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
msgid "code"
|
||||
msgstr "code"
|
||||
|
||||
#: build/lib/core/models.py:1279 core/models.py:1279
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
msgid "css"
|
||||
msgstr "CSS"
|
||||
|
||||
#: build/lib/core/models.py:1281 core/models.py:1281
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "public"
|
||||
msgstr "public"
|
||||
|
||||
#: build/lib/core/models.py:1283 core/models.py:1283
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "Si ce modèle est public, utilisable par n'importe qui."
|
||||
|
||||
#: build/lib/core/models.py:1289 core/models.py:1289
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
msgid "Template"
|
||||
msgstr "Modèle"
|
||||
|
||||
#: build/lib/core/models.py:1290 core/models.py:1290
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
msgid "Templates"
|
||||
msgstr "Modèles"
|
||||
|
||||
#: build/lib/core/models.py:1343 core/models.py:1343
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
msgid "Template/user relation"
|
||||
msgstr "Relation modèle/utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:1344 core/models.py:1344
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
msgid "Template/user relations"
|
||||
msgstr "Relations modèle/utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:1350 core/models.py:1350
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
msgid "This user is already in this template."
|
||||
msgstr "Cet utilisateur est déjà dans ce modèle."
|
||||
|
||||
#: build/lib/core/models.py:1356 core/models.py:1356
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Cette équipe est déjà modèle."
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
msgid "email address"
|
||||
msgstr "adresse e-mail"
|
||||
|
||||
#: build/lib/core/models.py:1452 core/models.py:1452
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
msgid "Document invitation"
|
||||
msgstr "Invitation à un document"
|
||||
|
||||
#: build/lib/core/models.py:1453 core/models.py:1453
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
msgid "Document invitations"
|
||||
msgstr "Invitations à un document"
|
||||
|
||||
#: build/lib/core/models.py:1473 core/models.py:1473
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Cette adresse email est déjà associée à un utilisateur inscrit."
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
|
||||
"PO-Revision-Date: 2025-11-19 10:13\n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Italian\n"
|
||||
"Language: it_IT\n"
|
||||
@@ -50,67 +50,72 @@ msgstr ""
|
||||
msgid "Favorite"
|
||||
msgstr "Preferiti"
|
||||
|
||||
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
|
||||
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "Un nuovo documento è stato creato a tuo nome!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:500 core/api/serializers.py:500
|
||||
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr "Sei ora proprietario di un nuovo documento:"
|
||||
|
||||
#: build/lib/core/api/serializers.py:536 core/api/serializers.py:536
|
||||
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
|
||||
msgid "This field is required."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:693 core/api/serializers.py:693
|
||||
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
|
||||
msgid "Body"
|
||||
msgstr "Corpo"
|
||||
|
||||
#: build/lib/core/api/serializers.py:696 core/api/serializers.py:696
|
||||
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
|
||||
msgid "Body type"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:702 core/api/serializers.py:702
|
||||
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
|
||||
msgid "Format"
|
||||
msgstr "Formato"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "copia di {title}"
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:42 core/choices.py:35
|
||||
#: core/choices.py:42
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
msgstr "Lettore"
|
||||
|
||||
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
|
||||
#: core/choices.py:43
|
||||
#: 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
|
||||
msgid "Editor"
|
||||
msgstr "Editor"
|
||||
|
||||
#: build/lib/core/choices.py:44 core/choices.py:44
|
||||
#: build/lib/core/choices.py:46 core/choices.py:46
|
||||
msgid "Administrator"
|
||||
msgstr "Amministratore"
|
||||
|
||||
#: build/lib/core/choices.py:45 core/choices.py:45
|
||||
#: build/lib/core/choices.py:47 core/choices.py:47
|
||||
msgid "Owner"
|
||||
msgstr "Proprietario"
|
||||
|
||||
#: build/lib/core/choices.py:56 core/choices.py:56
|
||||
#: build/lib/core/choices.py:58 core/choices.py:58
|
||||
msgid "Restricted"
|
||||
msgstr "Limitato"
|
||||
|
||||
#: build/lib/core/choices.py:60 core/choices.py:60
|
||||
#: build/lib/core/choices.py:62 core/choices.py:62
|
||||
msgid "Authenticated"
|
||||
msgstr "Autenticato"
|
||||
|
||||
#: build/lib/core/choices.py:62 core/choices.py:62
|
||||
#: build/lib/core/choices.py:64 core/choices.py:64
|
||||
msgid "Public"
|
||||
msgstr "Pubblico"
|
||||
|
||||
@@ -234,8 +239,8 @@ msgstr "utente"
|
||||
msgid "users"
|
||||
msgstr "utenti"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
|
||||
#: core/models.py:361 core/models.py:1276
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
msgid "title"
|
||||
msgstr "titolo"
|
||||
|
||||
@@ -251,155 +256,188 @@ msgstr "Documento"
|
||||
msgid "Documents"
|
||||
msgstr "Documenti"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
|
||||
#: core/models.py:822
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
msgid "Untitled Document"
|
||||
msgstr "Documento senza titolo"
|
||||
|
||||
#: build/lib/core/models.py:857 core/models.py:857
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} ha condiviso un documento con te!"
|
||||
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} ti ha invitato con il ruolo \"{role}\" nel seguente documento:"
|
||||
|
||||
#: build/lib/core/models.py:867 core/models.py:867
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} ha condiviso un documento con te: {title}"
|
||||
|
||||
#: build/lib/core/models.py:967 core/models.py:967
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:968 core/models.py:968
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:974 core/models.py:974
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:997 core/models.py:997
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
msgid "Document favorite"
|
||||
msgstr "Documento preferito"
|
||||
|
||||
#: build/lib/core/models.py:998 core/models.py:998
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
msgid "Document favorites"
|
||||
msgstr "Documenti preferiti"
|
||||
|
||||
#: build/lib/core/models.py:1004 core/models.py:1004
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1026 core/models.py:1026
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1027 core/models.py:1027
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1033 core/models.py:1033
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Questo utente è già presente in questo documento."
|
||||
|
||||
#: build/lib/core/models.py:1039 core/models.py:1039
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Questo team è già presente in questo documento."
|
||||
|
||||
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
|
||||
#: core/models.py:1045 core/models.py:1362
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1191 core/models.py:1191
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
msgid "Document ask for access"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1192 core/models.py:1192
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
msgid "Document ask for accesses"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1255 core/models.py:1255
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1259 core/models.py:1259
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1265 core/models.py:1265
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1277 core/models.py:1277
|
||||
#: build/lib/core/models.py: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
|
||||
msgid "description"
|
||||
msgstr "descrizione"
|
||||
|
||||
#: build/lib/core/models.py:1278 core/models.py:1278
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
msgid "code"
|
||||
msgstr "code"
|
||||
|
||||
#: build/lib/core/models.py:1279 core/models.py:1279
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
msgid "css"
|
||||
msgstr "css"
|
||||
|
||||
#: build/lib/core/models.py:1281 core/models.py:1281
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "public"
|
||||
msgstr "pubblico"
|
||||
|
||||
#: build/lib/core/models.py:1283 core/models.py:1283
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "Indica se questo modello è pubblico per chiunque."
|
||||
|
||||
#: build/lib/core/models.py:1289 core/models.py:1289
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
msgid "Template"
|
||||
msgstr "Modello"
|
||||
|
||||
#: build/lib/core/models.py:1290 core/models.py:1290
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
msgid "Templates"
|
||||
msgstr "Modelli"
|
||||
|
||||
#: build/lib/core/models.py:1343 core/models.py:1343
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1344 core/models.py:1344
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1350 core/models.py:1350
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
msgid "This user is already in this template."
|
||||
msgstr "Questo utente è già in questo modello."
|
||||
|
||||
#: build/lib/core/models.py:1356 core/models.py:1356
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Questo team è già in questo modello."
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
msgid "email address"
|
||||
msgstr "indirizzo e-mail"
|
||||
|
||||
#: build/lib/core/models.py:1452 core/models.py:1452
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
msgid "Document invitation"
|
||||
msgstr "Invito al documento"
|
||||
|
||||
#: build/lib/core/models.py:1453 core/models.py:1453
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
msgid "Document invitations"
|
||||
msgstr "Inviti al documento"
|
||||
|
||||
#: build/lib/core/models.py:1473 core/models.py:1473
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Questa email è già associata a un utente registrato."
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
|
||||
"PO-Revision-Date: 2025-11-19 10:13\n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Dutch\n"
|
||||
"Language: nl_NL\n"
|
||||
@@ -50,67 +50,72 @@ msgstr "Gemaskeerd"
|
||||
msgid "Favorite"
|
||||
msgstr "Favoriet"
|
||||
|
||||
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
|
||||
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "Een nieuw document is namens u gemaakt!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:500 core/api/serializers.py:500
|
||||
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
|
||||
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:536 core/api/serializers.py:536
|
||||
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
|
||||
msgid "This field is required."
|
||||
msgstr "Dit veld is verplicht."
|
||||
|
||||
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#, 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:693 core/api/serializers.py:693
|
||||
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
|
||||
msgid "Body"
|
||||
msgstr "Text"
|
||||
|
||||
#: build/lib/core/api/serializers.py:696 core/api/serializers.py:696
|
||||
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
|
||||
msgid "Body type"
|
||||
msgstr "Text type"
|
||||
|
||||
#: build/lib/core/api/serializers.py:702 core/api/serializers.py:702
|
||||
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
|
||||
msgid "Format"
|
||||
msgstr "Formaat"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "kopie van {title}"
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:42 core/choices.py:35
|
||||
#: core/choices.py:42
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
msgstr "Lezer"
|
||||
|
||||
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
|
||||
#: core/choices.py:43
|
||||
#: 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
|
||||
msgid "Editor"
|
||||
msgstr "Redacteur"
|
||||
|
||||
#: build/lib/core/choices.py:44 core/choices.py:44
|
||||
#: build/lib/core/choices.py:46 core/choices.py:46
|
||||
msgid "Administrator"
|
||||
msgstr "Beheerder"
|
||||
|
||||
#: build/lib/core/choices.py:45 core/choices.py:45
|
||||
#: build/lib/core/choices.py:47 core/choices.py:47
|
||||
msgid "Owner"
|
||||
msgstr "Eigenaar"
|
||||
|
||||
#: build/lib/core/choices.py:56 core/choices.py:56
|
||||
#: build/lib/core/choices.py:58 core/choices.py:58
|
||||
msgid "Restricted"
|
||||
msgstr "Beperkt"
|
||||
|
||||
#: build/lib/core/choices.py:60 core/choices.py:60
|
||||
#: build/lib/core/choices.py:62 core/choices.py:62
|
||||
msgid "Authenticated"
|
||||
msgstr "Geauthenticeerd"
|
||||
|
||||
#: build/lib/core/choices.py:62 core/choices.py:62
|
||||
#: build/lib/core/choices.py:64 core/choices.py:64
|
||||
msgid "Public"
|
||||
msgstr "Publiek"
|
||||
|
||||
@@ -234,8 +239,8 @@ msgstr "gebruiker"
|
||||
msgid "users"
|
||||
msgstr "gebruikers"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
|
||||
#: core/models.py:361 core/models.py:1276
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
msgid "title"
|
||||
msgstr "titel"
|
||||
|
||||
@@ -251,155 +256,188 @@ msgstr "Document"
|
||||
msgid "Documents"
|
||||
msgstr "Documenten"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
|
||||
#: core/models.py:822
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
msgid "Untitled Document"
|
||||
msgstr "Naamloos Document"
|
||||
|
||||
#: build/lib/core/models.py:857 core/models.py:857
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} heeft een document met u gedeeld!"
|
||||
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} heeft u uitgenodigd met de rol \"{role}\" op het volgende document:"
|
||||
|
||||
#: build/lib/core/models.py:867 core/models.py:867
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} heeft een document met u gedeeld: {title}"
|
||||
|
||||
#: build/lib/core/models.py:967 core/models.py:967
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Document/gebruiker link"
|
||||
|
||||
#: build/lib/core/models.py:968 core/models.py:968
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Document/gebruiker link"
|
||||
|
||||
#: build/lib/core/models.py:974 core/models.py:974
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
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:997 core/models.py:997
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
msgid "Document favorite"
|
||||
msgstr "Document favoriet"
|
||||
|
||||
#: build/lib/core/models.py:998 core/models.py:998
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
msgid "Document favorites"
|
||||
msgstr "Document favorieten"
|
||||
|
||||
#: build/lib/core/models.py:1004 core/models.py:1004
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "Dit document is al in gebruik als favoriet door dezelfde gebruiker."
|
||||
|
||||
#: build/lib/core/models.py:1026 core/models.py:1026
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
msgid "Document/user relation"
|
||||
msgstr "Document/gebruiker relatie"
|
||||
|
||||
#: build/lib/core/models.py:1027 core/models.py:1027
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
msgid "Document/user relations"
|
||||
msgstr "Document/gebruiker relaties"
|
||||
|
||||
#: build/lib/core/models.py:1033 core/models.py:1033
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
msgid "This user is already in this document."
|
||||
msgstr "De gebruiker bestaat al in dit document."
|
||||
|
||||
#: build/lib/core/models.py:1039 core/models.py:1039
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Dit team bestaat al in dit document."
|
||||
|
||||
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
|
||||
#: core/models.py:1045 core/models.py:1362
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Een gebruiker of team moet gekozen worden, maar niet beide."
|
||||
|
||||
#: build/lib/core/models.py:1191 core/models.py:1191
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
msgid "Document ask for access"
|
||||
msgstr "Document verzoekt om toegang"
|
||||
|
||||
#: build/lib/core/models.py:1192 core/models.py:1192
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "Document verzoekt om toegangen"
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr "Deze gebruiker heeft al om toegang tot dit document gevraagd."
|
||||
|
||||
#: build/lib/core/models.py:1255 core/models.py:1255
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr "{name} verzoekt toegang tot een document!"
|
||||
|
||||
#: build/lib/core/models.py:1259 core/models.py:1259
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#, 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:1265 core/models.py:1265
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#, 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:1277 core/models.py:1277
|
||||
#: 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
|
||||
msgid "description"
|
||||
msgstr "omschrijving"
|
||||
|
||||
#: build/lib/core/models.py:1278 core/models.py:1278
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
msgid "code"
|
||||
msgstr "code"
|
||||
|
||||
#: build/lib/core/models.py:1279 core/models.py:1279
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
msgid "css"
|
||||
msgstr "css"
|
||||
|
||||
#: build/lib/core/models.py:1281 core/models.py:1281
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "public"
|
||||
msgstr "publiek"
|
||||
|
||||
#: build/lib/core/models.py:1283 core/models.py:1283
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "Of dit sjabloon door iedereen publiekelijk te gebruiken is."
|
||||
|
||||
#: build/lib/core/models.py:1289 core/models.py:1289
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
msgid "Template"
|
||||
msgstr "Sjabloon"
|
||||
|
||||
#: build/lib/core/models.py:1290 core/models.py:1290
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
msgid "Templates"
|
||||
msgstr "Sjabloon"
|
||||
|
||||
#: build/lib/core/models.py:1343 core/models.py:1343
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
msgid "Template/user relation"
|
||||
msgstr "Sjabloon/gebruiker relatie"
|
||||
|
||||
#: build/lib/core/models.py:1344 core/models.py:1344
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
msgid "Template/user relations"
|
||||
msgstr "Sjabloon/gebruiker relaties"
|
||||
|
||||
#: build/lib/core/models.py:1350 core/models.py:1350
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
msgid "This user is already in this template."
|
||||
msgstr "De gebruiker bestaat al in dit sjabloon."
|
||||
|
||||
#: build/lib/core/models.py:1356 core/models.py:1356
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Het team bestaat al in dit sjabloon."
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
msgid "email address"
|
||||
msgstr "e-mailadres"
|
||||
|
||||
#: build/lib/core/models.py:1452 core/models.py:1452
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
msgid "Document invitation"
|
||||
msgstr "Document uitnodiging"
|
||||
|
||||
#: build/lib/core/models.py:1453 core/models.py:1453
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
msgid "Document invitations"
|
||||
msgstr "Document uitnodigingen"
|
||||
|
||||
#: build/lib/core/models.py:1473 core/models.py:1473
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Deze email is al geassocieerd met een geregistreerde gebruiker."
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
|
||||
"PO-Revision-Date: 2025-11-19 10:13\n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Portuguese\n"
|
||||
"Language: pt_PT\n"
|
||||
@@ -50,67 +50,72 @@ msgstr ""
|
||||
msgid "Favorite"
|
||||
msgstr "Favorito"
|
||||
|
||||
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
|
||||
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "Um novo documento foi criado em seu nome!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:500 core/api/serializers.py:500
|
||||
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
|
||||
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:536 core/api/serializers.py:536
|
||||
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
|
||||
msgid "This field is required."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:693 core/api/serializers.py:693
|
||||
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
|
||||
msgid "Body"
|
||||
msgstr "Corpo"
|
||||
|
||||
#: build/lib/core/api/serializers.py:696 core/api/serializers.py:696
|
||||
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
|
||||
msgid "Body type"
|
||||
msgstr "Tipo de corpo"
|
||||
|
||||
#: build/lib/core/api/serializers.py:702 core/api/serializers.py:702
|
||||
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
|
||||
msgid "Format"
|
||||
msgstr "Formato"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "cópia de {title}"
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:42 core/choices.py:35
|
||||
#: core/choices.py:42
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
msgstr "Leitor"
|
||||
|
||||
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
|
||||
#: core/choices.py:43
|
||||
#: 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
|
||||
msgid "Editor"
|
||||
msgstr "Editor"
|
||||
|
||||
#: build/lib/core/choices.py:44 core/choices.py:44
|
||||
#: build/lib/core/choices.py:46 core/choices.py:46
|
||||
msgid "Administrator"
|
||||
msgstr "Administrador"
|
||||
|
||||
#: build/lib/core/choices.py:45 core/choices.py:45
|
||||
#: build/lib/core/choices.py:47 core/choices.py:47
|
||||
msgid "Owner"
|
||||
msgstr "Dono"
|
||||
|
||||
#: build/lib/core/choices.py:56 core/choices.py:56
|
||||
#: build/lib/core/choices.py:58 core/choices.py:58
|
||||
msgid "Restricted"
|
||||
msgstr "Restrito"
|
||||
|
||||
#: build/lib/core/choices.py:60 core/choices.py:60
|
||||
#: build/lib/core/choices.py:62 core/choices.py:62
|
||||
msgid "Authenticated"
|
||||
msgstr "Autenticado"
|
||||
|
||||
#: build/lib/core/choices.py:62 core/choices.py:62
|
||||
#: build/lib/core/choices.py:64 core/choices.py:64
|
||||
msgid "Public"
|
||||
msgstr "Público"
|
||||
|
||||
@@ -234,8 +239,8 @@ msgstr ""
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
|
||||
#: core/models.py:361 core/models.py:1276
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
@@ -251,155 +256,188 @@ msgstr ""
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
|
||||
#: core/models.py:822
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:857 core/models.py:857
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:867 core/models.py:867
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:967 core/models.py:967
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:968 core/models.py:968
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:974 core/models.py:974
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:997 core/models.py:997
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
msgid "Document favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:998 core/models.py:998
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
msgid "Document favorites"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1004 core/models.py:1004
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1026 core/models.py:1026
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1027 core/models.py:1027
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1033 core/models.py:1033
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1039 core/models.py:1039
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
|
||||
#: core/models.py:1045 core/models.py:1362
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1191 core/models.py:1191
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
msgid "Document ask for access"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1192 core/models.py:1192
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
msgid "Document ask for accesses"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1255 core/models.py:1255
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1259 core/models.py:1259
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1265 core/models.py:1265
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1277 core/models.py:1277
|
||||
#: build/lib/core/models.py: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
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1278 core/models.py:1278
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1279 core/models.py:1279
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1281 core/models.py:1281
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1283 core/models.py:1283
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1289 core/models.py:1289
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1290 core/models.py:1290
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1343 core/models.py:1343
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1344 core/models.py:1344
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1350 core/models.py:1350
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1356 core/models.py:1356
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1452 core/models.py:1452
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1453 core/models.py:1453
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1473 core/models.py:1473
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
|
||||
"PO-Revision-Date: 2025-11-19 10:13\n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Russian\n"
|
||||
"Language: ru_RU\n"
|
||||
@@ -50,67 +50,72 @@ msgstr "Скрытый"
|
||||
msgid "Favorite"
|
||||
msgstr "Избранное"
|
||||
|
||||
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
|
||||
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "Новый документ был создан от вашего имени!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:500 core/api/serializers.py:500
|
||||
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr "Вы назначены владельцем для нового документа:"
|
||||
|
||||
#: build/lib/core/api/serializers.py:536 core/api/serializers.py:536
|
||||
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
|
||||
msgid "This field is required."
|
||||
msgstr "Это поле обязательное."
|
||||
|
||||
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#, 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:693 core/api/serializers.py:693
|
||||
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
|
||||
msgid "Body"
|
||||
msgstr "Текст сообщения"
|
||||
|
||||
#: build/lib/core/api/serializers.py:696 core/api/serializers.py:696
|
||||
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
|
||||
msgid "Body type"
|
||||
msgstr "Тип сообщения"
|
||||
|
||||
#: build/lib/core/api/serializers.py:702 core/api/serializers.py:702
|
||||
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
|
||||
msgid "Format"
|
||||
msgstr "Формат"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "копия {title}"
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:42 core/choices.py:35
|
||||
#: core/choices.py:42
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
msgstr "Читатель"
|
||||
|
||||
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
|
||||
#: core/choices.py:43
|
||||
#: 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
|
||||
msgid "Editor"
|
||||
msgstr "Редактор"
|
||||
|
||||
#: build/lib/core/choices.py:44 core/choices.py:44
|
||||
#: build/lib/core/choices.py:46 core/choices.py:46
|
||||
msgid "Administrator"
|
||||
msgstr "Администратор"
|
||||
|
||||
#: build/lib/core/choices.py:45 core/choices.py:45
|
||||
#: build/lib/core/choices.py:47 core/choices.py:47
|
||||
msgid "Owner"
|
||||
msgstr "Владелец"
|
||||
|
||||
#: build/lib/core/choices.py:56 core/choices.py:56
|
||||
#: build/lib/core/choices.py:58 core/choices.py:58
|
||||
msgid "Restricted"
|
||||
msgstr "Доступ ограничен"
|
||||
|
||||
#: build/lib/core/choices.py:60 core/choices.py:60
|
||||
#: build/lib/core/choices.py:62 core/choices.py:62
|
||||
msgid "Authenticated"
|
||||
msgstr "Аутентификация выполнена"
|
||||
|
||||
#: build/lib/core/choices.py:62 core/choices.py:62
|
||||
#: build/lib/core/choices.py:64 core/choices.py:64
|
||||
msgid "Public"
|
||||
msgstr "Доступно всем"
|
||||
|
||||
@@ -234,8 +239,8 @@ msgstr "пользователь"
|
||||
msgid "users"
|
||||
msgstr "пользователи"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
|
||||
#: core/models.py:361 core/models.py:1276
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
msgid "title"
|
||||
msgstr "заголовок"
|
||||
|
||||
@@ -251,155 +256,188 @@ msgstr "Документ"
|
||||
msgid "Documents"
|
||||
msgstr "Документы"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
|
||||
#: core/models.py:822
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
msgid "Untitled Document"
|
||||
msgstr "Безымянный документ"
|
||||
|
||||
#: build/lib/core/models.py:857 core/models.py:857
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} делится с вами документом!"
|
||||
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} приглашает вас присоединиться к следующему документу с ролью \"{role}\":"
|
||||
|
||||
#: build/lib/core/models.py:867 core/models.py:867
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} делится с вами документом: {title}"
|
||||
|
||||
#: build/lib/core/models.py:967 core/models.py:967
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Трассировка связи документ/пользователь"
|
||||
|
||||
#: build/lib/core/models.py:968 core/models.py:968
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Трассировка связей документ/пользователь"
|
||||
|
||||
#: build/lib/core/models.py:974 core/models.py:974
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "Для этого документа/пользователя уже существует трассировка ссылки."
|
||||
|
||||
#: build/lib/core/models.py:997 core/models.py:997
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
msgid "Document favorite"
|
||||
msgstr "Избранный документ"
|
||||
|
||||
#: build/lib/core/models.py:998 core/models.py:998
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
msgid "Document favorites"
|
||||
msgstr "Избранные документы"
|
||||
|
||||
#: build/lib/core/models.py:1004 core/models.py:1004
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "Этот документ уже помечен как избранный для этого пользователя."
|
||||
|
||||
#: build/lib/core/models.py:1026 core/models.py:1026
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
msgid "Document/user relation"
|
||||
msgstr "Отношение документ/пользователь"
|
||||
|
||||
#: build/lib/core/models.py:1027 core/models.py:1027
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
msgid "Document/user relations"
|
||||
msgstr "Отношения документ/пользователь"
|
||||
|
||||
#: build/lib/core/models.py:1033 core/models.py:1033
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Этот пользователь уже имеет доступ к этому документу."
|
||||
|
||||
#: build/lib/core/models.py:1039 core/models.py:1039
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Эта команда уже имеет доступ к этому документу."
|
||||
|
||||
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
|
||||
#: core/models.py:1045 core/models.py:1362
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Может быть выбран либо пользователь, либо команда, но не оба варианта сразу."
|
||||
|
||||
#: build/lib/core/models.py:1191 core/models.py:1191
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
msgid "Document ask for access"
|
||||
msgstr "Документ запрашивает доступ"
|
||||
|
||||
#: build/lib/core/models.py:1192 core/models.py:1192
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "Документ запрашивает доступы"
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr "Этот пользователь уже запросил доступ к этому документу."
|
||||
|
||||
#: build/lib/core/models.py:1255 core/models.py:1255
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr "{name} хочет получить доступ к документу!"
|
||||
|
||||
#: build/lib/core/models.py:1259 core/models.py:1259
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr "{name} хочет получить доступ к следующему документу:"
|
||||
|
||||
#: build/lib/core/models.py:1265 core/models.py:1265
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr "{name} запрашивает доступ к документу: {title}"
|
||||
|
||||
#: build/lib/core/models.py:1277 core/models.py:1277
|
||||
#: build/lib/core/models.py: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
|
||||
msgid "description"
|
||||
msgstr "описание"
|
||||
|
||||
#: build/lib/core/models.py:1278 core/models.py:1278
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
msgid "code"
|
||||
msgstr "код"
|
||||
|
||||
#: build/lib/core/models.py:1279 core/models.py:1279
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
msgid "css"
|
||||
msgstr "css"
|
||||
|
||||
#: build/lib/core/models.py:1281 core/models.py:1281
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "public"
|
||||
msgstr "доступно всем"
|
||||
|
||||
#: build/lib/core/models.py:1283 core/models.py:1283
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "Этот шаблон доступен всем пользователям."
|
||||
|
||||
#: build/lib/core/models.py:1289 core/models.py:1289
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
msgid "Template"
|
||||
msgstr "Шаблон"
|
||||
|
||||
#: build/lib/core/models.py:1290 core/models.py:1290
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
msgid "Templates"
|
||||
msgstr "Шаблоны"
|
||||
|
||||
#: build/lib/core/models.py:1343 core/models.py:1343
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
msgid "Template/user relation"
|
||||
msgstr "Отношение шаблон/пользователь"
|
||||
|
||||
#: build/lib/core/models.py:1344 core/models.py:1344
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
msgid "Template/user relations"
|
||||
msgstr "Отношения шаблон/пользователь"
|
||||
|
||||
#: build/lib/core/models.py:1350 core/models.py:1350
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
msgid "This user is already in this template."
|
||||
msgstr "Этот пользователь уже указан в этом шаблоне."
|
||||
|
||||
#: build/lib/core/models.py:1356 core/models.py:1356
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Эта команда уже указана в этом шаблоне."
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
msgid "email address"
|
||||
msgstr "адрес электронной почты"
|
||||
|
||||
#: build/lib/core/models.py:1452 core/models.py:1452
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
msgid "Document invitation"
|
||||
msgstr "Приглашение для документа"
|
||||
|
||||
#: build/lib/core/models.py:1453 core/models.py:1453
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
msgid "Document invitations"
|
||||
msgstr "Приглашения для документов"
|
||||
|
||||
#: build/lib/core/models.py:1473 core/models.py:1473
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Этот адрес уже связан с зарегистрированным пользователем."
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
|
||||
"PO-Revision-Date: 2025-11-19 10:13\n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Slovenian\n"
|
||||
"Language: sl_SI\n"
|
||||
@@ -50,67 +50,72 @@ msgstr ""
|
||||
msgid "Favorite"
|
||||
msgstr "Priljubljena"
|
||||
|
||||
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
|
||||
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
|
||||
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:500 core/api/serializers.py:500
|
||||
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
|
||||
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:536 core/api/serializers.py:536
|
||||
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
|
||||
msgid "This field is required."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:693 core/api/serializers.py:693
|
||||
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
|
||||
msgid "Body"
|
||||
msgstr "Telo"
|
||||
|
||||
#: build/lib/core/api/serializers.py:696 core/api/serializers.py:696
|
||||
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
|
||||
msgid "Body type"
|
||||
msgstr "Vrsta telesa"
|
||||
|
||||
#: build/lib/core/api/serializers.py:702 core/api/serializers.py:702
|
||||
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
|
||||
msgid "Format"
|
||||
msgstr "Oblika"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:42 core/choices.py:35
|
||||
#: core/choices.py:42
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
msgstr "Bralec"
|
||||
|
||||
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
|
||||
#: core/choices.py:43
|
||||
#: 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
|
||||
msgid "Editor"
|
||||
msgstr "Urednik"
|
||||
|
||||
#: build/lib/core/choices.py:44 core/choices.py:44
|
||||
#: build/lib/core/choices.py:46 core/choices.py:46
|
||||
msgid "Administrator"
|
||||
msgstr "Skrbnik"
|
||||
|
||||
#: build/lib/core/choices.py:45 core/choices.py:45
|
||||
#: build/lib/core/choices.py:47 core/choices.py:47
|
||||
msgid "Owner"
|
||||
msgstr "Lastnik"
|
||||
|
||||
#: build/lib/core/choices.py:56 core/choices.py:56
|
||||
#: build/lib/core/choices.py:58 core/choices.py:58
|
||||
msgid "Restricted"
|
||||
msgstr "Omejeno"
|
||||
|
||||
#: build/lib/core/choices.py:60 core/choices.py:60
|
||||
#: build/lib/core/choices.py:62 core/choices.py:62
|
||||
msgid "Authenticated"
|
||||
msgstr "Preverjeno"
|
||||
|
||||
#: build/lib/core/choices.py:62 core/choices.py:62
|
||||
#: build/lib/core/choices.py:64 core/choices.py:64
|
||||
msgid "Public"
|
||||
msgstr "Javno"
|
||||
|
||||
@@ -234,8 +239,8 @@ msgstr "uporabnik"
|
||||
msgid "users"
|
||||
msgstr "uporabniki"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
|
||||
#: core/models.py:361 core/models.py:1276
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
msgid "title"
|
||||
msgstr "naslov"
|
||||
|
||||
@@ -251,155 +256,188 @@ msgstr "Dokument"
|
||||
msgid "Documents"
|
||||
msgstr "Dokumenti"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
|
||||
#: core/models.py:822
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
msgid "Untitled Document"
|
||||
msgstr "Dokument brez naslova"
|
||||
|
||||
#: build/lib/core/models.py:857 core/models.py:857
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} je delil dokument z vami!"
|
||||
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} vas je povabil z vlogo \"{role}\" na naslednjem dokumentu:"
|
||||
|
||||
#: build/lib/core/models.py:867 core/models.py:867
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} je delil dokument z vami: {title}"
|
||||
|
||||
#: build/lib/core/models.py:967 core/models.py:967
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Dokument/sled povezave uporabnika"
|
||||
|
||||
#: build/lib/core/models.py:968 core/models.py:968
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Sledi povezav dokumenta/uporabnika"
|
||||
|
||||
#: build/lib/core/models.py:974 core/models.py:974
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "Za ta dokument/uporabnika že obstaja sled povezave."
|
||||
|
||||
#: build/lib/core/models.py:997 core/models.py:997
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
msgid "Document favorite"
|
||||
msgstr "Priljubljeni dokument"
|
||||
|
||||
#: build/lib/core/models.py:998 core/models.py:998
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
msgid "Document favorites"
|
||||
msgstr "Priljubljeni dokumenti"
|
||||
|
||||
#: build/lib/core/models.py:1004 core/models.py:1004
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "Ta dokument je že ciljno usmerjen s priljubljenim primerkom relacije za istega uporabnika."
|
||||
|
||||
#: build/lib/core/models.py:1026 core/models.py:1026
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
msgid "Document/user relation"
|
||||
msgstr "Odnos dokument/uporabnik"
|
||||
|
||||
#: build/lib/core/models.py:1027 core/models.py:1027
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
msgid "Document/user relations"
|
||||
msgstr "Odnosi dokument/uporabnik"
|
||||
|
||||
#: build/lib/core/models.py:1033 core/models.py:1033
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Ta uporabnik je že v tem dokumentu."
|
||||
|
||||
#: build/lib/core/models.py:1039 core/models.py:1039
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Ta ekipa je že v tem dokumentu."
|
||||
|
||||
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
|
||||
#: core/models.py:1045 core/models.py:1362
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Nastaviti je treba bodisi uporabnika ali ekipo, a ne obojega."
|
||||
|
||||
#: build/lib/core/models.py:1191 core/models.py:1191
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
msgid "Document ask for access"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1192 core/models.py:1192
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
msgid "Document ask for accesses"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1255 core/models.py:1255
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1259 core/models.py:1259
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1265 core/models.py:1265
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1277 core/models.py:1277
|
||||
#: build/lib/core/models.py: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
|
||||
msgid "description"
|
||||
msgstr "opis"
|
||||
|
||||
#: build/lib/core/models.py:1278 core/models.py:1278
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
msgid "code"
|
||||
msgstr "koda"
|
||||
|
||||
#: build/lib/core/models.py:1279 core/models.py:1279
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
msgid "css"
|
||||
msgstr "css"
|
||||
|
||||
#: build/lib/core/models.py:1281 core/models.py:1281
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "public"
|
||||
msgstr "javno"
|
||||
|
||||
#: build/lib/core/models.py:1283 core/models.py:1283
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "Ali je ta predloga javna za uporabo."
|
||||
|
||||
#: build/lib/core/models.py:1289 core/models.py:1289
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
msgid "Template"
|
||||
msgstr "Predloga"
|
||||
|
||||
#: build/lib/core/models.py:1290 core/models.py:1290
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
msgid "Templates"
|
||||
msgstr "Predloge"
|
||||
|
||||
#: build/lib/core/models.py:1343 core/models.py:1343
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
msgid "Template/user relation"
|
||||
msgstr "Odnos predloga/uporabnik"
|
||||
|
||||
#: build/lib/core/models.py:1344 core/models.py:1344
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
msgid "Template/user relations"
|
||||
msgstr "Odnosi med predlogo in uporabnikom"
|
||||
|
||||
#: build/lib/core/models.py:1350 core/models.py:1350
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
msgid "This user is already in this template."
|
||||
msgstr "Ta uporabnik je že v tej predlogi."
|
||||
|
||||
#: build/lib/core/models.py:1356 core/models.py:1356
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Ta ekipa je že v tej predlogi."
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
msgid "email address"
|
||||
msgstr "elektronski naslov"
|
||||
|
||||
#: build/lib/core/models.py:1452 core/models.py:1452
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
msgid "Document invitation"
|
||||
msgstr "Vabilo na dokument"
|
||||
|
||||
#: build/lib/core/models.py:1453 core/models.py:1453
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
msgid "Document invitations"
|
||||
msgstr "Vabila na dokument"
|
||||
|
||||
#: build/lib/core/models.py:1473 core/models.py:1473
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Ta e-poštni naslov je že povezan z registriranim uporabnikom."
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
|
||||
"PO-Revision-Date: 2025-11-19 10:13\n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Swedish\n"
|
||||
"Language: sv_SE\n"
|
||||
@@ -50,67 +50,72 @@ msgstr ""
|
||||
msgid "Favorite"
|
||||
msgstr "Favoriter"
|
||||
|
||||
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
|
||||
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "Ett nytt dokument skapades åt dig!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:500 core/api/serializers.py:500
|
||||
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
|
||||
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:536 core/api/serializers.py:536
|
||||
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
|
||||
msgid "This field is required."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:693 core/api/serializers.py:693
|
||||
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
|
||||
msgid "Body"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:696 core/api/serializers.py:696
|
||||
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
|
||||
msgid "Body type"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:702 core/api/serializers.py:702
|
||||
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
|
||||
msgid "Format"
|
||||
msgstr "Format"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:42 core/choices.py:35
|
||||
#: core/choices.py:42
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
|
||||
#: core/choices.py:43
|
||||
#: 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
|
||||
msgid "Editor"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:44 core/choices.py:44
|
||||
#: build/lib/core/choices.py:46 core/choices.py:46
|
||||
msgid "Administrator"
|
||||
msgstr "Administratör"
|
||||
|
||||
#: build/lib/core/choices.py:45 core/choices.py:45
|
||||
#: build/lib/core/choices.py:47 core/choices.py:47
|
||||
msgid "Owner"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:56 core/choices.py:56
|
||||
#: build/lib/core/choices.py:58 core/choices.py:58
|
||||
msgid "Restricted"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:60 core/choices.py:60
|
||||
#: build/lib/core/choices.py:62 core/choices.py:62
|
||||
msgid "Authenticated"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:62 core/choices.py:62
|
||||
#: build/lib/core/choices.py:64 core/choices.py:64
|
||||
msgid "Public"
|
||||
msgstr "Publik"
|
||||
|
||||
@@ -234,8 +239,8 @@ msgstr ""
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
|
||||
#: core/models.py:361 core/models.py:1276
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
@@ -251,155 +256,188 @@ msgstr ""
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
|
||||
#: core/models.py:822
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:857 core/models.py:857
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:867 core/models.py:867
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:967 core/models.py:967
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:968 core/models.py:968
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:974 core/models.py:974
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:997 core/models.py:997
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
msgid "Document favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:998 core/models.py:998
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
msgid "Document favorites"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1004 core/models.py:1004
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1026 core/models.py:1026
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1027 core/models.py:1027
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1033 core/models.py:1033
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1039 core/models.py:1039
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
|
||||
#: core/models.py:1045 core/models.py:1362
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1191 core/models.py:1191
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
msgid "Document ask for access"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1192 core/models.py:1192
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
msgid "Document ask for accesses"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1255 core/models.py:1255
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1259 core/models.py:1259
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1265 core/models.py:1265
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1277 core/models.py:1277
|
||||
#: build/lib/core/models.py: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
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1278 core/models.py:1278
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1279 core/models.py:1279
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1281 core/models.py:1281
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1283 core/models.py:1283
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1289 core/models.py:1289
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1290 core/models.py:1290
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1343 core/models.py:1343
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1344 core/models.py:1344
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1350 core/models.py:1350
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1356 core/models.py:1356
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
msgid "email address"
|
||||
msgstr "e-postadress"
|
||||
|
||||
#: build/lib/core/models.py:1452 core/models.py:1452
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
msgid "Document invitation"
|
||||
msgstr "Bjud in dokument"
|
||||
|
||||
#: build/lib/core/models.py:1453 core/models.py:1453
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
msgid "Document invitations"
|
||||
msgstr "Inbjudningar dokument"
|
||||
|
||||
#: build/lib/core/models.py:1473 core/models.py:1473
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Denna e-postadress är redan associerad med en registrerad användare."
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
|
||||
"PO-Revision-Date: 2025-11-19 10:13\n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Turkish\n"
|
||||
"Language: tr_TR\n"
|
||||
@@ -50,67 +50,72 @@ msgstr ""
|
||||
msgid "Favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
|
||||
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:500 core/api/serializers.py:500
|
||||
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:536 core/api/serializers.py:536
|
||||
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
|
||||
msgid "This field is required."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:693 core/api/serializers.py:693
|
||||
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
|
||||
msgid "Body"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:696 core/api/serializers.py:696
|
||||
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
|
||||
msgid "Body type"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:702 core/api/serializers.py:702
|
||||
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
|
||||
msgid "Format"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:42 core/choices.py:35
|
||||
#: core/choices.py:42
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
|
||||
#: core/choices.py:43
|
||||
#: 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
|
||||
msgid "Editor"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:44 core/choices.py:44
|
||||
#: build/lib/core/choices.py:46 core/choices.py:46
|
||||
msgid "Administrator"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:45 core/choices.py:45
|
||||
#: build/lib/core/choices.py:47 core/choices.py:47
|
||||
msgid "Owner"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:56 core/choices.py:56
|
||||
#: build/lib/core/choices.py:58 core/choices.py:58
|
||||
msgid "Restricted"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:60 core/choices.py:60
|
||||
#: build/lib/core/choices.py:62 core/choices.py:62
|
||||
msgid "Authenticated"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/choices.py:62 core/choices.py:62
|
||||
#: build/lib/core/choices.py:64 core/choices.py:64
|
||||
msgid "Public"
|
||||
msgstr ""
|
||||
|
||||
@@ -234,8 +239,8 @@ msgstr ""
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
|
||||
#: core/models.py:361 core/models.py:1276
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
@@ -251,155 +256,188 @@ msgstr ""
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
|
||||
#: core/models.py:822
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:857 core/models.py:857
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:867 core/models.py:867
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:967 core/models.py:967
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:968 core/models.py:968
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:974 core/models.py:974
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:997 core/models.py:997
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
msgid "Document favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:998 core/models.py:998
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
msgid "Document favorites"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1004 core/models.py:1004
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1026 core/models.py:1026
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1027 core/models.py:1027
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1033 core/models.py:1033
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1039 core/models.py:1039
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
|
||||
#: core/models.py:1045 core/models.py:1362
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1191 core/models.py:1191
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
msgid "Document ask for access"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1192 core/models.py:1192
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
msgid "Document ask for accesses"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1255 core/models.py:1255
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1259 core/models.py:1259
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1265 core/models.py:1265
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1277 core/models.py:1277
|
||||
#: build/lib/core/models.py: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
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1278 core/models.py:1278
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1279 core/models.py:1279
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1281 core/models.py:1281
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1283 core/models.py:1283
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1289 core/models.py:1289
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1290 core/models.py:1290
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1343 core/models.py:1343
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1344 core/models.py:1344
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1350 core/models.py:1350
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1356 core/models.py:1356
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1452 core/models.py:1452
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1453 core/models.py:1453
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1473 core/models.py:1473
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
|
||||
"PO-Revision-Date: 2025-11-19 10:13\n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Ukrainian\n"
|
||||
"Language: uk_UA\n"
|
||||
@@ -50,67 +50,72 @@ msgstr "Приховано"
|
||||
msgid "Favorite"
|
||||
msgstr "Обране"
|
||||
|
||||
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
|
||||
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "Новий документ був створений від вашого імені!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:500 core/api/serializers.py:500
|
||||
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr "Ви тепер є власником нового документа:"
|
||||
|
||||
#: build/lib/core/api/serializers.py:536 core/api/serializers.py:536
|
||||
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
|
||||
msgid "This field is required."
|
||||
msgstr "Це поле є обов’язковим."
|
||||
|
||||
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#, 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:693 core/api/serializers.py:693
|
||||
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
|
||||
msgid "Body"
|
||||
msgstr "Вміст"
|
||||
|
||||
#: build/lib/core/api/serializers.py:696 core/api/serializers.py:696
|
||||
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
|
||||
msgid "Body type"
|
||||
msgstr "Тип вмісту"
|
||||
|
||||
#: build/lib/core/api/serializers.py:702 core/api/serializers.py:702
|
||||
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
|
||||
msgid "Format"
|
||||
msgstr "Формат"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "копія {title}"
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:42 core/choices.py:35
|
||||
#: core/choices.py:42
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
msgstr "Читач"
|
||||
|
||||
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
|
||||
#: core/choices.py:43
|
||||
#: 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
|
||||
msgid "Editor"
|
||||
msgstr "Редактор"
|
||||
|
||||
#: build/lib/core/choices.py:44 core/choices.py:44
|
||||
#: build/lib/core/choices.py:46 core/choices.py:46
|
||||
msgid "Administrator"
|
||||
msgstr "Адміністратор"
|
||||
|
||||
#: build/lib/core/choices.py:45 core/choices.py:45
|
||||
#: build/lib/core/choices.py:47 core/choices.py:47
|
||||
msgid "Owner"
|
||||
msgstr "Власник"
|
||||
|
||||
#: build/lib/core/choices.py:56 core/choices.py:56
|
||||
#: build/lib/core/choices.py:58 core/choices.py:58
|
||||
msgid "Restricted"
|
||||
msgstr "Обмежено"
|
||||
|
||||
#: build/lib/core/choices.py:60 core/choices.py:60
|
||||
#: build/lib/core/choices.py:62 core/choices.py:62
|
||||
msgid "Authenticated"
|
||||
msgstr "Підтверджено"
|
||||
|
||||
#: build/lib/core/choices.py:62 core/choices.py:62
|
||||
#: build/lib/core/choices.py:64 core/choices.py:64
|
||||
msgid "Public"
|
||||
msgstr "Публічне"
|
||||
|
||||
@@ -234,8 +239,8 @@ msgstr "користувач"
|
||||
msgid "users"
|
||||
msgstr "користувачі"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
|
||||
#: core/models.py:361 core/models.py:1276
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
msgid "title"
|
||||
msgstr "заголовок"
|
||||
|
||||
@@ -251,155 +256,188 @@ msgstr "Документ"
|
||||
msgid "Documents"
|
||||
msgstr "Документи"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
|
||||
#: core/models.py:822
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
msgid "Untitled Document"
|
||||
msgstr "Документ без назви"
|
||||
|
||||
#: build/lib/core/models.py:857 core/models.py:857
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} ділиться з вами документом!"
|
||||
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} запрошує вас для роботи з документом із роллю \"{role}\":"
|
||||
|
||||
#: build/lib/core/models.py:867 core/models.py:867
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} ділиться з вами документом: {title}"
|
||||
|
||||
#: build/lib/core/models.py:967 core/models.py:967
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Трасування посилання Документ/користувач"
|
||||
|
||||
#: build/lib/core/models.py:968 core/models.py:968
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Трасування посилань Документ/користувач"
|
||||
|
||||
#: build/lib/core/models.py:974 core/models.py:974
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "Відстеження вже існуючих посилань для цього документа/користувача."
|
||||
|
||||
#: build/lib/core/models.py:997 core/models.py:997
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
msgid "Document favorite"
|
||||
msgstr "Обраний документ"
|
||||
|
||||
#: build/lib/core/models.py:998 core/models.py:998
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
msgid "Document favorites"
|
||||
msgstr "Обрані документи"
|
||||
|
||||
#: build/lib/core/models.py:1004 core/models.py:1004
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "Цей документ вже вказаний як обраний для одного користувача."
|
||||
|
||||
#: build/lib/core/models.py:1026 core/models.py:1026
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
msgid "Document/user relation"
|
||||
msgstr "Відносини документ/користувач"
|
||||
|
||||
#: build/lib/core/models.py:1027 core/models.py:1027
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
msgid "Document/user relations"
|
||||
msgstr "Відносини документ/користувач"
|
||||
|
||||
#: build/lib/core/models.py:1033 core/models.py:1033
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Цей користувач вже має доступ до цього документу."
|
||||
|
||||
#: build/lib/core/models.py:1039 core/models.py:1039
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Ця команда вже має доступ до цього документа."
|
||||
|
||||
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
|
||||
#: core/models.py:1045 core/models.py:1362
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Вкажіть користувача або команду, а не обох."
|
||||
|
||||
#: build/lib/core/models.py:1191 core/models.py:1191
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
msgid "Document ask for access"
|
||||
msgstr "Запит доступу до документа"
|
||||
|
||||
#: build/lib/core/models.py:1192 core/models.py:1192
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "Запит доступу для документа"
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr "Цей користувач вже попросив доступ до цього документа."
|
||||
|
||||
#: build/lib/core/models.py:1255 core/models.py:1255
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr "{name} хоче отримати доступ до документа!"
|
||||
|
||||
#: build/lib/core/models.py:1259 core/models.py:1259
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr "{name} бажає отримати доступ до наступного документа:"
|
||||
|
||||
#: build/lib/core/models.py:1265 core/models.py:1265
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr "{name} запитує доступ до документа: {title}"
|
||||
|
||||
#: build/lib/core/models.py:1277 core/models.py:1277
|
||||
#: build/lib/core/models.py: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
|
||||
msgid "description"
|
||||
msgstr "опис"
|
||||
|
||||
#: build/lib/core/models.py:1278 core/models.py:1278
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
msgid "code"
|
||||
msgstr "код"
|
||||
|
||||
#: build/lib/core/models.py:1279 core/models.py:1279
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
msgid "css"
|
||||
msgstr "css"
|
||||
|
||||
#: build/lib/core/models.py:1281 core/models.py:1281
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "public"
|
||||
msgstr "публічне"
|
||||
|
||||
#: build/lib/core/models.py:1283 core/models.py:1283
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "Чи є цей шаблон публічним для будь-кого користувача."
|
||||
|
||||
#: build/lib/core/models.py:1289 core/models.py:1289
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
msgid "Template"
|
||||
msgstr "Шаблон"
|
||||
|
||||
#: build/lib/core/models.py:1290 core/models.py:1290
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
msgid "Templates"
|
||||
msgstr "Шаблони"
|
||||
|
||||
#: build/lib/core/models.py:1343 core/models.py:1343
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
msgid "Template/user relation"
|
||||
msgstr "Відношення шаблон/користувач"
|
||||
|
||||
#: build/lib/core/models.py:1344 core/models.py:1344
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
msgid "Template/user relations"
|
||||
msgstr "Відношення шаблон/користувач"
|
||||
|
||||
#: build/lib/core/models.py:1350 core/models.py:1350
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
msgid "This user is already in this template."
|
||||
msgstr "Цей користувач вже має доступ до цього шаблону."
|
||||
|
||||
#: build/lib/core/models.py:1356 core/models.py:1356
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
msgid "This team is already in this template."
|
||||
msgstr "Ця команда вже має доступ до цього шаблону."
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
msgid "email address"
|
||||
msgstr "електронна адреса"
|
||||
|
||||
#: build/lib/core/models.py:1452 core/models.py:1452
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
msgid "Document invitation"
|
||||
msgstr "Запрошення до редагування документа"
|
||||
|
||||
#: build/lib/core/models.py:1453 core/models.py:1453
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
msgid "Document invitations"
|
||||
msgstr "Запрошення до редагування документів"
|
||||
|
||||
#: build/lib/core/models.py:1473 core/models.py:1473
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Ця електронна пошта вже пов'язана з зареєстрованим користувачем."
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-17 08:04+0000\n"
|
||||
"PO-Revision-Date: 2025-11-19 10:13\n"
|
||||
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
|
||||
"PO-Revision-Date: 2025-12-09 11:12\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Chinese Simplified\n"
|
||||
"Language: zh_CN\n"
|
||||
@@ -50,67 +50,72 @@ msgstr "已屏蔽"
|
||||
msgid "Favorite"
|
||||
msgstr "收藏"
|
||||
|
||||
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
|
||||
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "已为您创建了一份新文档!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:500 core/api/serializers.py:500
|
||||
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr "您已被授予新文档的所有权:"
|
||||
|
||||
#: build/lib/core/api/serializers.py:536 core/api/serializers.py:536
|
||||
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
|
||||
msgid "This field is required."
|
||||
msgstr "必填字段。"
|
||||
|
||||
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:693 core/api/serializers.py:693
|
||||
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
|
||||
msgid "Body"
|
||||
msgstr "正文"
|
||||
|
||||
#: build/lib/core/api/serializers.py:696 core/api/serializers.py:696
|
||||
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
|
||||
msgid "Body type"
|
||||
msgstr "正文类型"
|
||||
|
||||
#: build/lib/core/api/serializers.py:702 core/api/serializers.py:702
|
||||
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
|
||||
msgid "Format"
|
||||
msgstr "格式"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
|
||||
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "{title} 的副本"
|
||||
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:42 core/choices.py:35
|
||||
#: core/choices.py:42
|
||||
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
|
||||
#: core/choices.py:43
|
||||
msgid "Reader"
|
||||
msgstr "阅读者"
|
||||
|
||||
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
|
||||
#: core/choices.py:43
|
||||
#: 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
|
||||
msgid "Editor"
|
||||
msgstr "编辑者"
|
||||
|
||||
#: build/lib/core/choices.py:44 core/choices.py:44
|
||||
#: build/lib/core/choices.py:46 core/choices.py:46
|
||||
msgid "Administrator"
|
||||
msgstr "超级管理员"
|
||||
|
||||
#: build/lib/core/choices.py:45 core/choices.py:45
|
||||
#: build/lib/core/choices.py:47 core/choices.py:47
|
||||
msgid "Owner"
|
||||
msgstr "所有者"
|
||||
|
||||
#: build/lib/core/choices.py:56 core/choices.py:56
|
||||
#: build/lib/core/choices.py:58 core/choices.py:58
|
||||
msgid "Restricted"
|
||||
msgstr "受限的"
|
||||
|
||||
#: build/lib/core/choices.py:60 core/choices.py:60
|
||||
#: build/lib/core/choices.py:62 core/choices.py:62
|
||||
msgid "Authenticated"
|
||||
msgstr "已验证"
|
||||
|
||||
#: build/lib/core/choices.py:62 core/choices.py:62
|
||||
#: build/lib/core/choices.py:64 core/choices.py:64
|
||||
msgid "Public"
|
||||
msgstr "公开"
|
||||
|
||||
@@ -234,8 +239,8 @@ msgstr "用户"
|
||||
msgid "users"
|
||||
msgstr "个用户"
|
||||
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1276
|
||||
#: core/models.py:361 core/models.py:1276
|
||||
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
|
||||
#: core/models.py:361 core/models.py:1430
|
||||
msgid "title"
|
||||
msgstr "标题"
|
||||
|
||||
@@ -251,155 +256,188 @@ msgstr "文档"
|
||||
msgid "Documents"
|
||||
msgstr "个文档"
|
||||
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
|
||||
#: core/models.py:822
|
||||
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
|
||||
#: core/models.py:824
|
||||
msgid "Untitled Document"
|
||||
msgstr "未命名文档"
|
||||
|
||||
#: build/lib/core/models.py:857 core/models.py:857
|
||||
#: build/lib/core/models.py:859 core/models.py:859
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} 与您共享了一个文档!"
|
||||
|
||||
#: build/lib/core/models.py:861 core/models.py:861
|
||||
#: build/lib/core/models.py:863 core/models.py:863
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} 邀请您以“{role}”角色访问以下文档:"
|
||||
|
||||
#: build/lib/core/models.py:867 core/models.py:867
|
||||
#: build/lib/core/models.py:869 core/models.py:869
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} 与您共享了一个文档:{title}"
|
||||
|
||||
#: build/lib/core/models.py:967 core/models.py:967
|
||||
#: build/lib/core/models.py:969 core/models.py:969
|
||||
msgid "Document/user link trace"
|
||||
msgstr "文档/用户链接跟踪"
|
||||
|
||||
#: build/lib/core/models.py:968 core/models.py:968
|
||||
#: build/lib/core/models.py:970 core/models.py:970
|
||||
msgid "Document/user link traces"
|
||||
msgstr "个文档/用户链接跟踪"
|
||||
|
||||
#: build/lib/core/models.py:974 core/models.py:974
|
||||
#: build/lib/core/models.py:976 core/models.py:976
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "此文档/用户的链接跟踪已存在。"
|
||||
|
||||
#: build/lib/core/models.py:997 core/models.py:997
|
||||
#: build/lib/core/models.py:999 core/models.py:999
|
||||
msgid "Document favorite"
|
||||
msgstr "文档收藏"
|
||||
|
||||
#: build/lib/core/models.py:998 core/models.py:998
|
||||
#: build/lib/core/models.py:1000 core/models.py:1000
|
||||
msgid "Document favorites"
|
||||
msgstr "文档收藏夹"
|
||||
|
||||
#: build/lib/core/models.py:1004 core/models.py:1004
|
||||
#: build/lib/core/models.py:1006 core/models.py:1006
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "该文档已被同一用户的收藏关系实例关联。"
|
||||
|
||||
#: build/lib/core/models.py:1026 core/models.py:1026
|
||||
#: build/lib/core/models.py:1028 core/models.py:1028
|
||||
msgid "Document/user relation"
|
||||
msgstr "文档/用户关系"
|
||||
|
||||
#: build/lib/core/models.py:1027 core/models.py:1027
|
||||
#: build/lib/core/models.py:1029 core/models.py:1029
|
||||
msgid "Document/user relations"
|
||||
msgstr "文档/用户关系集"
|
||||
|
||||
#: build/lib/core/models.py:1033 core/models.py:1033
|
||||
#: build/lib/core/models.py:1035 core/models.py:1035
|
||||
msgid "This user is already in this document."
|
||||
msgstr "该用户已在此文档中。"
|
||||
|
||||
#: build/lib/core/models.py:1039 core/models.py:1039
|
||||
#: build/lib/core/models.py:1041 core/models.py:1041
|
||||
msgid "This team is already in this document."
|
||||
msgstr "该团队已在此文档中。"
|
||||
|
||||
#: build/lib/core/models.py:1045 build/lib/core/models.py:1362
|
||||
#: core/models.py:1045 core/models.py:1362
|
||||
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
|
||||
#: core/models.py:1047 core/models.py:1516
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "必须设置用户或团队之一,不能同时设置两者。"
|
||||
|
||||
#: build/lib/core/models.py:1191 core/models.py:1191
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
msgid "Document ask for access"
|
||||
msgstr "文档需要访问权限"
|
||||
|
||||
#: build/lib/core/models.py:1192 core/models.py:1192
|
||||
#: build/lib/core/models.py:1199 core/models.py:1199
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "文档需要访问权限"
|
||||
|
||||
#: build/lib/core/models.py:1198 core/models.py:1198
|
||||
#: build/lib/core/models.py:1205 core/models.py:1205
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr "用户已申请该文档的访问权限。"
|
||||
|
||||
#: build/lib/core/models.py:1255 core/models.py:1255
|
||||
#: build/lib/core/models.py:1262 core/models.py:1262
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr "{name} 申请访问文档!"
|
||||
|
||||
#: build/lib/core/models.py:1259 core/models.py:1259
|
||||
#: build/lib/core/models.py:1266 core/models.py:1266
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr "{name} 申请访问以下文档:"
|
||||
|
||||
#: build/lib/core/models.py:1265 core/models.py:1265
|
||||
#: build/lib/core/models.py:1272 core/models.py:1272
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr "{name}申请文档:{title}的访问权限"
|
||||
|
||||
#: build/lib/core/models.py:1277 core/models.py:1277
|
||||
#: build/lib/core/models.py: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
|
||||
msgid "description"
|
||||
msgstr "说明"
|
||||
|
||||
#: build/lib/core/models.py:1278 core/models.py:1278
|
||||
#: build/lib/core/models.py:1432 core/models.py:1432
|
||||
msgid "code"
|
||||
msgstr "代码"
|
||||
|
||||
#: build/lib/core/models.py:1279 core/models.py:1279
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
msgid "css"
|
||||
msgstr "css"
|
||||
|
||||
#: build/lib/core/models.py:1281 core/models.py:1281
|
||||
#: build/lib/core/models.py:1435 core/models.py:1435
|
||||
msgid "public"
|
||||
msgstr "公开"
|
||||
|
||||
#: build/lib/core/models.py:1283 core/models.py:1283
|
||||
#: build/lib/core/models.py:1437 core/models.py:1437
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "该模板是否公开供任何人使用。"
|
||||
|
||||
#: build/lib/core/models.py:1289 core/models.py:1289
|
||||
#: build/lib/core/models.py:1443 core/models.py:1443
|
||||
msgid "Template"
|
||||
msgstr "模板"
|
||||
|
||||
#: build/lib/core/models.py:1290 core/models.py:1290
|
||||
#: build/lib/core/models.py:1444 core/models.py:1444
|
||||
msgid "Templates"
|
||||
msgstr "模板"
|
||||
|
||||
#: build/lib/core/models.py:1343 core/models.py:1343
|
||||
#: build/lib/core/models.py:1497 core/models.py:1497
|
||||
msgid "Template/user relation"
|
||||
msgstr "模板/用户关系"
|
||||
|
||||
#: build/lib/core/models.py:1344 core/models.py:1344
|
||||
#: build/lib/core/models.py:1498 core/models.py:1498
|
||||
msgid "Template/user relations"
|
||||
msgstr "模板/用户关系集"
|
||||
|
||||
#: build/lib/core/models.py:1350 core/models.py:1350
|
||||
#: build/lib/core/models.py:1504 core/models.py:1504
|
||||
msgid "This user is already in this template."
|
||||
msgstr "该用户已在此模板中。"
|
||||
|
||||
#: build/lib/core/models.py:1356 core/models.py:1356
|
||||
#: build/lib/core/models.py:1510 core/models.py:1510
|
||||
msgid "This team is already in this template."
|
||||
msgstr "该团队已在此模板中。"
|
||||
|
||||
#: build/lib/core/models.py:1433 core/models.py:1433
|
||||
#: build/lib/core/models.py:1587 core/models.py:1587
|
||||
msgid "email address"
|
||||
msgstr "电子邮件地址"
|
||||
|
||||
#: build/lib/core/models.py:1452 core/models.py:1452
|
||||
#: build/lib/core/models.py:1606 core/models.py:1606
|
||||
msgid "Document invitation"
|
||||
msgstr "文档邀请"
|
||||
|
||||
#: build/lib/core/models.py:1453 core/models.py:1453
|
||||
#: build/lib/core/models.py:1607 core/models.py:1607
|
||||
msgid "Document invitations"
|
||||
msgstr "文档邀请"
|
||||
|
||||
#: build/lib/core/models.py:1473 core/models.py:1473
|
||||
#: build/lib/core/models.py:1627 core/models.py:1627
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "此电子邮件已经与现有注册用户关联。"
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "impress"
|
||||
version = "3.10.0"
|
||||
version = "4.2.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -34,12 +34,12 @@ dependencies = [
|
||||
"django-countries==8.1.0",
|
||||
"django-csp==4.0",
|
||||
"django-filter==25.2",
|
||||
"django-lasuite[all]==0.0.18",
|
||||
"django-lasuite[all]==0.0.22",
|
||||
"django-parler==2.3",
|
||||
"django-redis==6.0.0",
|
||||
"django-storages[s3]==1.14.6",
|
||||
"django-timezone-field>=5.1",
|
||||
"django==5.2.8",
|
||||
"django==5.2.9",
|
||||
"django-treebeard==4.7.1",
|
||||
"djangorestframework==3.16.1",
|
||||
"drf_spectacular==0.29.0",
|
||||
|
||||
@@ -126,6 +126,20 @@ 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,
|
||||
}) => {
|
||||
@@ -145,10 +159,6 @@ 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(
|
||||
@@ -168,6 +178,10 @@ test.describe('Config: Not logged', () => {
|
||||
|
||||
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,7 +1,12 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc, getOtherBrowserName, verifyDocName } from './utils-common';
|
||||
import { writeInEditor } from './utils-editor';
|
||||
import {
|
||||
closeHeaderMenu,
|
||||
createDoc,
|
||||
getOtherBrowserName,
|
||||
verifyDocName,
|
||||
} from './utils-common';
|
||||
import { getEditor, writeInEditor } from './utils-editor';
|
||||
import {
|
||||
addNewMember,
|
||||
connectOtherUserToDoc,
|
||||
@@ -43,6 +48,7 @@ test.describe('Doc Comments', () => {
|
||||
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();
|
||||
@@ -116,8 +122,7 @@ test.describe('Doc Comments', () => {
|
||||
await createDoc(page, 'comment-interaction', browserName, 1);
|
||||
|
||||
// Checks add react reaction
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await editor.locator('.bn-block-outer').last().fill('Hello World');
|
||||
const editor = await writeInEditor({ page, text: 'Hello' });
|
||||
await editor.getByText('Hello').selectText();
|
||||
await page.getByRole('button', { name: 'Comment' }).click();
|
||||
|
||||
@@ -131,6 +136,7 @@ test.describe('Doc Comments', () => {
|
||||
'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();
|
||||
@@ -147,7 +153,7 @@ test.describe('Doc Comments', () => {
|
||||
await thread.getByRole('menuitem', { name: 'Edit comment' }).click();
|
||||
const commentEditor = thread.getByText('This is a comment').first();
|
||||
await commentEditor.fill('This is an edited comment');
|
||||
const saveBtn = thread.getByRole('button', { name: 'Save' });
|
||||
const saveBtn = thread.locator('button[data-test="save"]').first();
|
||||
await saveBtn.click();
|
||||
await expect(saveBtn).toBeHidden();
|
||||
await expect(
|
||||
@@ -157,7 +163,8 @@ test.describe('Doc Comments', () => {
|
||||
|
||||
// Add second comment
|
||||
await thread.getByRole('paragraph').last().fill('This is a second comment');
|
||||
await thread.getByRole('button', { name: 'Save' }).click();
|
||||
await saveBtn.click();
|
||||
await expect(saveBtn).toBeHidden();
|
||||
await expect(
|
||||
thread.getByText('This is an edited comment').first(),
|
||||
).toBeVisible();
|
||||
@@ -181,6 +188,29 @@ test.describe('Doc Comments', () => {
|
||||
'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 }) => {
|
||||
@@ -191,6 +221,8 @@ test.describe('Doc Comments', () => {
|
||||
// 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);
|
||||
@@ -214,7 +246,7 @@ test.describe('Doc Comments', () => {
|
||||
text: 'Hello, I can edit the document',
|
||||
});
|
||||
await expect(
|
||||
otherEditor.getByText('Hello, I can edit the document'),
|
||||
editor.getByText('Hello, I can edit the document'),
|
||||
).toBeVisible();
|
||||
await otherEditor.getByText('Hello').selectText();
|
||||
await otherPage.getByRole('button', { name: 'Comment' }).click();
|
||||
@@ -292,4 +324,80 @@ test.describe('Doc Comments', () => {
|
||||
|
||||
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,6 +7,7 @@ import {
|
||||
randomName,
|
||||
verifyDocName,
|
||||
} from './utils-common';
|
||||
import { connectOtherUserToDoc } from './utils-share';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
@@ -73,6 +74,82 @@ 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', () => {
|
||||
|
||||
@@ -802,6 +802,8 @@ 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();
|
||||
@@ -878,14 +880,15 @@ test.describe('Doc Editor', () => {
|
||||
// Wait for the interlink to be created and rendered
|
||||
const editor = await getEditor({ page });
|
||||
|
||||
const interlinkChild2 = editor.getByRole('button', {
|
||||
name: docChild2,
|
||||
});
|
||||
const interlinkChild = editor
|
||||
.locator('.--docs--interlinking-link-inline-content')
|
||||
.first();
|
||||
|
||||
await expect(interlinkChild2).toBeVisible({ timeout: 10000 });
|
||||
await expect(interlinkChild2).toContainText('😀');
|
||||
await expect(interlinkChild2.locator('svg').first()).toBeHidden();
|
||||
await interlinkChild2.click();
|
||||
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 verifyDocName(page, docChild2);
|
||||
|
||||
@@ -895,11 +898,9 @@ test.describe('Doc Editor', () => {
|
||||
await input.fill(docChild1);
|
||||
await searchContainer.getByText(docChild1).click();
|
||||
|
||||
const interlinkChild1 = editor.getByRole('button', {
|
||||
name: docChild1,
|
||||
});
|
||||
await expect(interlinkChild1).toBeVisible({ timeout: 10000 });
|
||||
await expect(interlinkChild1.locator('svg').first()).toBeVisible();
|
||||
await expect(interlinkChild).toContainText(docChild1);
|
||||
await expect(interlinkChild).toBeVisible({ timeout: 10000 });
|
||||
await expect(interlinkChild.locator('svg').first()).toBeVisible();
|
||||
|
||||
await page.keyboard.press('@');
|
||||
|
||||
@@ -959,13 +960,35 @@ 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"]').first();
|
||||
const pdfBlock = page.locator('div[data-content-type="pdf"]').last();
|
||||
|
||||
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');
|
||||
@@ -990,10 +1013,50 @@ test.describe('Doc Editor', () => {
|
||||
await expect(pdfEmbed).toHaveAttribute('role', 'presentation');
|
||||
|
||||
// Check download with original filename
|
||||
await page.locator('.bn-block-content[data-content-type="pdf"]').click();
|
||||
await pdfBlock.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,6 +2,7 @@ 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 {
|
||||
@@ -31,7 +32,7 @@ test.describe('Doc Export', () => {
|
||||
|
||||
await expect(page.getByTestId('modal-export-title')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Download your document in a .docx, .odt or .pdf format.'),
|
||||
page.getByText(/Download your document in a \.docx, \.odt.*format\./i),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('combobox', { name: 'Template' }),
|
||||
@@ -187,6 +188,89 @@ test.describe('Doc Export', () => {
|
||||
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
|
||||
@@ -443,7 +527,7 @@ test.describe('Doc Export', () => {
|
||||
|
||||
await verifyDocName(page, docChild);
|
||||
|
||||
await page.locator('.bn-block-outer').last().fill('/');
|
||||
const editor = await openSuggestionMenu({ page });
|
||||
await page.getByText('Link a doc').first().click();
|
||||
|
||||
const input = page.locator(
|
||||
@@ -460,12 +544,11 @@ test.describe('Doc Export', () => {
|
||||
await searchContainer.getByText(randomDoc).click();
|
||||
|
||||
// Search the interlinking link in the editor (not in the document tree)
|
||||
const editor = page.locator('.ProseMirror.bn-editor');
|
||||
const interlink = editor.getByRole('button', {
|
||||
name: randomDoc,
|
||||
});
|
||||
const interlink = editor
|
||||
.locator('.--docs--interlinking-link-inline-content')
|
||||
.first();
|
||||
|
||||
await expect(interlink).toBeVisible();
|
||||
await expect(interlink).toContainText(randomDoc);
|
||||
|
||||
const downloadPromise = page.waitForEvent('download', (download) => {
|
||||
return download.suggestedFilename().includes(`${docChild}.pdf`);
|
||||
@@ -509,7 +592,7 @@ test.describe('Doc Export', () => {
|
||||
|
||||
await verifyDocName(page, docChild);
|
||||
|
||||
await page.locator('.bn-block-outer').last().fill('/');
|
||||
const editor = await openSuggestionMenu({ page });
|
||||
await page.getByText('Link a doc').first().click();
|
||||
|
||||
const input = page.locator(
|
||||
@@ -526,12 +609,11 @@ test.describe('Doc Export', () => {
|
||||
await searchContainer.getByText(randomDoc).click();
|
||||
|
||||
// Search the interlinking link in the editor (not in the document tree)
|
||||
const editor = page.locator('.ProseMirror.bn-editor');
|
||||
const interlink = editor.getByRole('button', {
|
||||
name: randomDoc,
|
||||
});
|
||||
const interlink = editor
|
||||
.locator('.--docs--interlinking-link-inline-content')
|
||||
.first();
|
||||
|
||||
await expect(interlink).toBeVisible();
|
||||
await expect(interlink).toContainText(randomDoc);
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc, mockedListDocs } from './utils-common';
|
||||
import { createDoc, mockedListDocs, toggleHeaderMenu } from './utils-common';
|
||||
import { createRootSubPage } from './utils-sub-pages';
|
||||
|
||||
test.describe('Doc grid dnd', () => {
|
||||
@@ -185,10 +185,7 @@ test.describe('Doc grid dnd mobile', () => {
|
||||
true,
|
||||
);
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: 'Open the header menu' })
|
||||
.getByText('menu')
|
||||
.click();
|
||||
await toggleHeaderMenu(page);
|
||||
|
||||
await expect(page.locator('.--docs-sub-page-item').first()).toHaveAttribute(
|
||||
'draggable',
|
||||
|
||||
@@ -408,40 +408,6 @@ 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',
|
||||
@@ -582,8 +548,12 @@ 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(
|
||||
|
||||
@@ -19,6 +19,8 @@ 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');
|
||||
|
||||
@@ -303,6 +303,40 @@ 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,6 +6,7 @@ import {
|
||||
mockedDocument,
|
||||
verifyDocName,
|
||||
} from './utils-common';
|
||||
import { openSuggestionMenu, writeInEditor } from './utils-editor';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
@@ -17,6 +18,7 @@ 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();
|
||||
@@ -26,31 +28,49 @@ 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.getByRole('heading', { name: 'Hello World' }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('Hello World')).toBeVisible();
|
||||
|
||||
await page
|
||||
.locator('.ProseMirror .bn-block')
|
||||
.getByRole('heading', { name: 'Hello World' })
|
||||
.fill('It will create a version');
|
||||
// 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.getByRole('heading', { name: 'It will create a version' }),
|
||||
page.getByText('It will create a second version'),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
@@ -60,11 +80,33 @@ 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(1);
|
||||
await items[0].click();
|
||||
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();
|
||||
|
||||
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 ({
|
||||
|
||||
@@ -177,3 +177,27 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,6 +66,7 @@ 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');
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ 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',
|
||||
@@ -83,6 +84,34 @@ export const randomName = (name: string, browserName: string, length: number) =>
|
||||
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,
|
||||
@@ -94,10 +123,7 @@ export const createDoc = async (
|
||||
|
||||
for (let i = 0; i < randomDocs.length; i++) {
|
||||
if (isMobile) {
|
||||
await page
|
||||
.getByRole('button', { name: 'Open the header menu' })
|
||||
.getByText('menu')
|
||||
.click();
|
||||
await openHeaderMenu(page);
|
||||
}
|
||||
|
||||
await page
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Page, expect } from '@playwright/test';
|
||||
|
||||
import {
|
||||
BrowserName,
|
||||
closeHeaderMenu,
|
||||
openHeaderMenu,
|
||||
randomName,
|
||||
updateDocTitle,
|
||||
verifyDocName,
|
||||
@@ -15,10 +17,7 @@ export const createRootSubPage = async (
|
||||
isMobile = false,
|
||||
) => {
|
||||
if (isMobile) {
|
||||
await page
|
||||
.getByRole('button', { name: 'Open the header menu' })
|
||||
.getByText('menu')
|
||||
.click();
|
||||
await openHeaderMenu(page);
|
||||
}
|
||||
|
||||
// Get response
|
||||
@@ -29,10 +28,7 @@ export const createRootSubPage = async (
|
||||
const subPageJson = (await response.json()) as { id: string };
|
||||
|
||||
if (isMobile) {
|
||||
await page
|
||||
.getByRole('button', { name: 'Open the header menu' })
|
||||
.getByText('menu')
|
||||
.click();
|
||||
await openHeaderMenu(page);
|
||||
}
|
||||
|
||||
// Get doc tree
|
||||
@@ -44,13 +40,9 @@ export const createRootSubPage = async (
|
||||
.getByTestId(`doc-sub-page-item-${subPageJson.id}`)
|
||||
.first();
|
||||
await expect(subPageItem).toBeVisible();
|
||||
await subPageItem.click();
|
||||
|
||||
if (isMobile) {
|
||||
await page
|
||||
.getByRole('button', { name: 'Open the header menu' })
|
||||
.getByText('close')
|
||||
.click();
|
||||
await closeHeaderMenu(page);
|
||||
}
|
||||
|
||||
// Update sub page name
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-e2e",
|
||||
"version": "3.10.0",
|
||||
"version": "4.2.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.56.1",
|
||||
"@playwright/test": "1.57.0",
|
||||
"@types/node": "*",
|
||||
"@types/pdf-parse": "1.1.5",
|
||||
"eslint-plugin-docs": "*",
|
||||
|
||||
@@ -6,7 +6,7 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
location / {
|
||||
try_files $uri index.html $uri/ =404;
|
||||
try_files $uri index.html $uri/index.html =404;
|
||||
|
||||
add_header X-Frame-Options DENY always;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-impress",
|
||||
"version": "3.10.0",
|
||||
"version": "4.2.0",
|
||||
"repository": "https://github.com/suitenumerique/docs",
|
||||
"author": "DINUM",
|
||||
"license": "MIT",
|
||||
@@ -19,30 +19,30 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ag-media/react-pdf-table": "2.0.3",
|
||||
"@blocknote/code-block": "0.42.3",
|
||||
"@blocknote/core": "0.42.3",
|
||||
"@blocknote/mantine": "0.42.3",
|
||||
"@blocknote/react": "0.42.3",
|
||||
"@blocknote/xl-docx-exporter": "0.42.3",
|
||||
"@blocknote/xl-multi-column": "0.42.3",
|
||||
"@blocknote/xl-odt-exporter": "0.42.3",
|
||||
"@blocknote/xl-pdf-exporter": "0.42.3",
|
||||
"@blocknote/code-block": "0.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",
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/modifiers": "9.0.0",
|
||||
"@emoji-mart/data": "1.2.1",
|
||||
"@emoji-mart/react": "1.1.1",
|
||||
"@fontsource-variable/inter": "5.2.8",
|
||||
"@fontsource-variable/material-symbols-outlined": "5.2.28",
|
||||
"@fontsource-variable/material-symbols-outlined": "5.2.30",
|
||||
"@fontsource/material-icons": "5.2.7",
|
||||
"@gouvfr-lasuite/integration": "1.0.3",
|
||||
"@gouvfr-lasuite/ui-kit": "0.18.0",
|
||||
"@hocuspocus/provider": "3.4.0",
|
||||
"@mantine/core": "8.3.6",
|
||||
"@mantine/hooks": "8.3.6",
|
||||
"@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",
|
||||
"@react-pdf/renderer": "4.3.1",
|
||||
"@sentry/nextjs": "10.27.0",
|
||||
"@tanstack/react-query": "5.90.6",
|
||||
"@sentry/nextjs": "10.30.0",
|
||||
"@tanstack/react-query": "5.90.12",
|
||||
"@tiptap/extensions": "*",
|
||||
"canvg": "4.0.3",
|
||||
"clsx": "2.1.1",
|
||||
@@ -52,17 +52,17 @@
|
||||
"emoji-datasource-apple": "16.0.0",
|
||||
"emoji-mart": "5.6.0",
|
||||
"emoji-regex": "10.6.0",
|
||||
"i18next": "25.6.0",
|
||||
"i18next": "25.7.2",
|
||||
"i18next-browser-languagedetector": "8.2.0",
|
||||
"idb": "8.0.3",
|
||||
"lodash": "4.17.21",
|
||||
"luxon": "3.7.2",
|
||||
"next": "15.5.4",
|
||||
"posthog-js": "1.284.0",
|
||||
"next": "15.5.9",
|
||||
"posthog-js": "1.306.1",
|
||||
"react": "*",
|
||||
"react-aria-components": "1.13.0",
|
||||
"react-dom": "*",
|
||||
"react-i18next": "16.3.3",
|
||||
"react-i18next": "16.5.0",
|
||||
"react-intersection-observer": "10.0.0",
|
||||
"react-resizable-panels": "3.0.6",
|
||||
"react-select": "5.10.2",
|
||||
@@ -70,36 +70,36 @@
|
||||
"use-debounce": "10.0.6",
|
||||
"y-protocols": "1.0.6",
|
||||
"yjs": "*",
|
||||
"zustand": "5.0.8"
|
||||
"zustand": "5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@svgr/webpack": "8.1.0",
|
||||
"@tanstack/react-query-devtools": "5.90.2",
|
||||
"@tanstack/react-query-devtools": "5.91.1",
|
||||
"@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.20",
|
||||
"@types/lodash": "4.17.21",
|
||||
"@types/luxon": "3.7.1",
|
||||
"@types/node": "*",
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"@vitejs/plugin-react": "5.1.0",
|
||||
"@vitejs/plugin-react": "5.1.2",
|
||||
"copy-webpack-plugin": "13.0.1",
|
||||
"cross-env": "10.1.0",
|
||||
"dotenv": "17.2.3",
|
||||
"eslint-plugin-docs": "*",
|
||||
"fetch-mock": "9.11.0",
|
||||
"jsdom": "27.1.0",
|
||||
"jsdom": "27.3.0",
|
||||
"node-fetch": "2.7.0",
|
||||
"prettier": "3.6.2",
|
||||
"stylelint": "16.25.0",
|
||||
"prettier": "3.7.4",
|
||||
"stylelint": "16.26.1",
|
||||
"stylelint-config-standard": "39.0.1",
|
||||
"stylelint-prettier": "5.0.3",
|
||||
"typescript": "*",
|
||||
"vite-tsconfig-paths": "5.1.4",
|
||||
"vitest": "4.0.6",
|
||||
"webpack": "5.102.1",
|
||||
"vite-tsconfig-paths": "6.0.1",
|
||||
"vitest": "4.0.15",
|
||||
"webpack": "5.103.0",
|
||||
"workbox-webpack-plugin": "7.1.0"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22"
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 992 B After Width: | Height: | Size: 932 B |
BIN
src/frontend/apps/impress/src/assets/icons/error-planetes.png
Normal file
BIN
src/frontend/apps/impress/src/assets/icons/error-planetes.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -4,6 +4,8 @@ import { CSSProperties, RuleSet } from 'styled-components/dist/types';
|
||||
|
||||
import {
|
||||
MarginPadding,
|
||||
Spacings,
|
||||
spacingValue,
|
||||
stylesMargin,
|
||||
stylesPadding,
|
||||
} from '@/utils/styleBuilder';
|
||||
@@ -22,7 +24,7 @@ export interface BoxProps {
|
||||
$display?: CSSProperties['display'];
|
||||
$effect?: 'show' | 'hide';
|
||||
$flex?: CSSProperties['flex'];
|
||||
$gap?: CSSProperties['gap'];
|
||||
$gap?: Spacings;
|
||||
$hasTransition?: boolean | 'slow';
|
||||
$height?: CSSProperties['height'];
|
||||
$justify?: CSSProperties['justifyContent'];
|
||||
@@ -70,7 +72,7 @@ export const Box = styled('div')<BoxProps>`
|
||||
${({ $display, as }) =>
|
||||
`display: ${$display || (as?.match('span|input') ? 'inline-flex' : 'flex')};`}
|
||||
${({ $flex }) => $flex && `flex: ${$flex};`}
|
||||
${({ $gap }) => $gap && `gap: ${$gap};`}
|
||||
${({ $gap }) => $gap && `gap: ${spacingValue($gap)};`}
|
||||
${({ $height }) => $height && `height: ${$height};`}
|
||||
${({ $hasTransition }) =>
|
||||
$hasTransition && $hasTransition === 'slow'
|
||||
|
||||
78
src/frontend/apps/impress/src/components/SkipToContent.tsx
Normal file
78
src/frontend/apps/impress/src/components/SkipToContent.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
|
||||
|
||||
export const SkipToContent = () => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
// Reset focus after route change so first TAB goes to skip link
|
||||
useEffect(() => {
|
||||
const handleRouteChange = () => {
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
|
||||
document.body.setAttribute('tabindex', '-1');
|
||||
document.body.focus({ preventScroll: true });
|
||||
|
||||
setTimeout(() => {
|
||||
document.body.removeAttribute('tabindex');
|
||||
}, 100);
|
||||
};
|
||||
|
||||
router.events.on('routeChangeComplete', handleRouteChange);
|
||||
return () => {
|
||||
router.events.off('routeChangeComplete', handleRouteChange);
|
||||
};
|
||||
}, [router.events]);
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const mainContent = document.getElementById(MAIN_LAYOUT_ID);
|
||||
if (mainContent) {
|
||||
mainContent.focus();
|
||||
mainContent.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
$css={css`
|
||||
.c__button--brand--primary.--docs--skip-to-content:focus-visible {
|
||||
box-shadow:
|
||||
0 0 0 1px var(--c--globals--colors--white-000),
|
||||
0 0 0 4px var(--c--contextuals--border--semantic--brand--primary);
|
||||
border-radius: var(--c--globals--spacings--st);
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Button
|
||||
onClick={handleClick}
|
||||
type="button"
|
||||
color="brand"
|
||||
className="--docs--skip-to-content"
|
||||
onFocus={() => setIsVisible(true)}
|
||||
onBlur={() => setIsVisible(false)}
|
||||
style={{
|
||||
opacity: isVisible ? 1 : 0,
|
||||
pointerEvents: isVisible ? 'auto' : 'none',
|
||||
position: 'fixed',
|
||||
top: spacingsTokens['2xs'],
|
||||
// padding header + logo(32px) + gap(3xs≈4px) + text "Docs"(≈70px) + 12px
|
||||
left: `calc(${spacingsTokens['base']} + 32px + ${spacingsTokens['3xs']} + 70px + 12px)`,
|
||||
zIndex: 9999,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{t('Go to content')}
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -11,5 +11,6 @@ export * from './Loading';
|
||||
export * from './modal';
|
||||
export * from './Overlayer';
|
||||
export * from './separators';
|
||||
export * from './SkipToContent';
|
||||
export * from './Text';
|
||||
export * from './TextErrors';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Loader } from '@openfun/cunningham-react';
|
||||
import Head from 'next/head';
|
||||
import Script from 'next/script';
|
||||
import { PropsWithChildren, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -87,6 +88,9 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => {
|
||||
<link rel="stylesheet" href={conf?.FRONTEND_CSS_URL} />
|
||||
</Head>
|
||||
)}
|
||||
{conf?.FRONTEND_JS_URL && (
|
||||
<Script src={conf?.FRONTEND_JS_URL} strategy="afterInteractive" />
|
||||
)}
|
||||
<AnalyticsProvider>
|
||||
<CrispProvider websiteId={conf?.CRISP_WEBSITE_ID}>
|
||||
{children}
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface ConfigResponse {
|
||||
ENVIRONMENT: string;
|
||||
FRONTEND_CSS_URL?: string;
|
||||
FRONTEND_HOMEPAGE_FEATURE_ENABLED?: boolean;
|
||||
FRONTEND_JS_URL?: string;
|
||||
FRONTEND_THEME?: Theme;
|
||||
LANGUAGES: [string, string][];
|
||||
LANGUAGE_CODE: string;
|
||||
|
||||
@@ -44,7 +44,7 @@ export const Auth = ({ children }: PropsWithChildren) => {
|
||||
if (config?.FRONTEND_HOMEPAGE_FEATURE_ENABLED) {
|
||||
if (pathname !== HOME_URL) {
|
||||
setIsRedirecting(true);
|
||||
void replace(HOME_URL).then(() => setIsRedirecting(false));
|
||||
window.location.replace(HOME_URL);
|
||||
}
|
||||
|
||||
return;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { baseApiUrl } from '@/api';
|
||||
|
||||
export const HOME_URL = '/home';
|
||||
export const HOME_URL = '/home/';
|
||||
export const LOGIN_URL = `${baseApiUrl()}authenticate/`;
|
||||
export const LOGOUT_URL = `${baseApiUrl()}logout/`;
|
||||
export const PATH_AUTH_LOCAL_STORAGE = 'docs-path-auth';
|
||||
|
||||
@@ -27,7 +27,7 @@ export const setAuthUrl = () => {
|
||||
window.location.pathname !== '/' &&
|
||||
window.location.pathname !== `${HOME_URL}/`
|
||||
) {
|
||||
localStorage.setItem(PATH_AUTH_LOCAL_STORAGE, window.location.pathname);
|
||||
localStorage.setItem(PATH_AUTH_LOCAL_STORAGE, window.location.href);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
defaultInlineContentSpecs,
|
||||
withPageBreak,
|
||||
} from '@blocknote/core';
|
||||
import { CommentsExtension } from '@blocknote/core/comments';
|
||||
import '@blocknote/core/fonts/inter.css';
|
||||
import * as locales from '@blocknote/core/locales';
|
||||
import { BlockNoteView } from '@blocknote/mantine';
|
||||
@@ -15,13 +16,13 @@ import { HocuspocusProvider } from '@hocuspocus/provider';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
import type { Awareness } from 'y-protocols/awareness';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { Box, TextErrors } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { Doc, useProviderStore } from '@/docs/doc-management';
|
||||
import { avatarUrlFromName, useAuth } from '@/features/auth';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import {
|
||||
useHeadings,
|
||||
@@ -84,10 +85,11 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
const { setEditor } = useEditorStore();
|
||||
const { t } = useTranslation();
|
||||
const { themeTokens } = useCunninghamTheme();
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const { isSynced: isConnectedToCollabServer } = useProviderStore();
|
||||
const refEditorContainer = useRef<HTMLDivElement>(null);
|
||||
const canSeeComment = doc.abilities.comment && isDesktop;
|
||||
const canSeeComment = doc.abilities.comment;
|
||||
// Determine if comments should be visible in the UI
|
||||
const showComments = canSeeComment;
|
||||
|
||||
useSaveDoc(doc.id, provider.document, isConnectedToCollabServer);
|
||||
const { i18n } = useTranslation();
|
||||
@@ -99,7 +101,11 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
const cursorName = collabName || t('Anonymous');
|
||||
const showCursorLabels: 'always' | 'activity' | (string & {}) = 'activity';
|
||||
|
||||
const threadStore = useComments(doc.id, canSeeComment, user);
|
||||
const { resolveUsers, threadStore } = useComments(
|
||||
doc.id,
|
||||
canSeeComment,
|
||||
user,
|
||||
);
|
||||
|
||||
const currentUserAvatarUrl = useMemo(() => {
|
||||
if (canSeeComment) {
|
||||
@@ -110,7 +116,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
const editor: DocsBlockNoteEditor = useCreateBlockNote(
|
||||
{
|
||||
collaboration: {
|
||||
provider: provider,
|
||||
provider: provider as { awareness?: Awareness | undefined },
|
||||
fragment: provider.document.getXmlFragment('document-store'),
|
||||
user: {
|
||||
name: cursorName,
|
||||
@@ -154,28 +160,34 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
},
|
||||
showCursorLabels: showCursorLabels as 'always' | 'activity',
|
||||
},
|
||||
comments: { threadStore },
|
||||
dictionary: {
|
||||
...locales[lang as keyof typeof locales],
|
||||
multi_column:
|
||||
multiColumnLocales?.[lang as keyof typeof multiColumnLocales],
|
||||
...(multiColumnLocales && {
|
||||
multi_column:
|
||||
multiColumnLocales[lang as keyof typeof multiColumnLocales],
|
||||
}),
|
||||
},
|
||||
resolveUsers: async (userIds) => {
|
||||
return Promise.resolve(
|
||||
userIds.map((encodedURIUserId) => {
|
||||
const fullName = decodeURIComponent(encodedURIUserId);
|
||||
pasteHandler: ({ event, defaultPasteHandler }) => {
|
||||
// Get clipboard data
|
||||
const blocknoteData = event.clipboardData?.getData('blocknote/html');
|
||||
|
||||
return {
|
||||
id: encodedURIUserId,
|
||||
username: fullName || t('Anonymous'),
|
||||
avatarUrl: avatarUrlFromName(
|
||||
fullName,
|
||||
themeTokens?.font?.families?.base,
|
||||
),
|
||||
};
|
||||
}),
|
||||
);
|
||||
/**
|
||||
* When pasting comments, the data-bn-thread-id
|
||||
* attribute is present in the clipboard data.
|
||||
* This indicates that the pasted content contains comments.
|
||||
* But if the content with comments comes from another document,
|
||||
* it will create orphaned comments that are not linked to this document
|
||||
* and create errors.
|
||||
* To avoid this, we refresh the threads to ensure that only comments
|
||||
* relevant to the current document are displayed.
|
||||
*/
|
||||
if (blocknoteData && blocknoteData.includes('data-bn-thread-id')) {
|
||||
void threadStore.refreshThreads();
|
||||
}
|
||||
|
||||
return defaultPasteHandler();
|
||||
},
|
||||
extensions: [CommentsExtension({ threadStore, resolveUsers })],
|
||||
tables: {
|
||||
splitCells: true,
|
||||
cellBackgroundColor: true,
|
||||
@@ -185,7 +197,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
uploadFile,
|
||||
schema: blockNoteSchema,
|
||||
},
|
||||
[cursorName, lang, provider, uploadFile, threadStore],
|
||||
[cursorName, lang, provider, uploadFile, threadStore, resolveUsers],
|
||||
);
|
||||
|
||||
useHeadings(editor);
|
||||
@@ -207,7 +219,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
ref={refEditorContainer}
|
||||
$css={css`
|
||||
${cssEditor};
|
||||
${cssComments(canSeeComment, currentUserAvatarUrl)}
|
||||
${cssComments(showComments, currentUserAvatarUrl)}
|
||||
`}
|
||||
>
|
||||
{errorAttachment && (
|
||||
@@ -225,7 +237,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
formattingToolbar={false}
|
||||
slashMenu={false}
|
||||
theme="light"
|
||||
comments={canSeeComment}
|
||||
comments={showComments}
|
||||
aria-label={t('Document editor')}
|
||||
>
|
||||
<BlockNoteSuggestionMenu />
|
||||
@@ -246,7 +258,7 @@ export const BlockNoteReader = ({
|
||||
}: BlockNoteReaderProps) => {
|
||||
const { user } = useAuth();
|
||||
const { setEditor } = useEditorStore();
|
||||
const threadStore = useComments(docId, false, user);
|
||||
const { threadStore } = useComments(docId, false, user);
|
||||
const { t } = useTranslation();
|
||||
const editor = useCreateBlockNote(
|
||||
{
|
||||
@@ -259,12 +271,16 @@ export const BlockNoteReader = ({
|
||||
provider: undefined,
|
||||
},
|
||||
schema: blockNoteSchema,
|
||||
comments: { threadStore },
|
||||
resolveUsers: async () => {
|
||||
return Promise.resolve([]);
|
||||
},
|
||||
extensions: [
|
||||
CommentsExtension({
|
||||
threadStore,
|
||||
resolveUsers: async () => {
|
||||
return Promise.resolve([]);
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
[initialContent],
|
||||
[initialContent, threadStore],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { combineByGroup, filterSuggestionItems } from '@blocknote/core';
|
||||
import { combineByGroup } from '@blocknote/core';
|
||||
import { filterSuggestionItems } from '@blocknote/core/extensions';
|
||||
import {
|
||||
DefaultReactSuggestionItem,
|
||||
SuggestionMenuController,
|
||||
|
||||
@@ -108,7 +108,7 @@ export function AIGroupButton() {
|
||||
data-test="ai-actions"
|
||||
label="AI"
|
||||
mainTooltip={t('AI Actions')}
|
||||
icon={<Icon iconName="auto_awesome" $size="l" />}
|
||||
icon={<Icon iconName="auto_awesome" $size="md" />}
|
||||
/>
|
||||
</Components.Generic.Menu.Trigger>
|
||||
<Components.Generic.Menu.Dropdown
|
||||
|
||||
@@ -6,24 +6,25 @@
|
||||
* https://github.com/TypeCellOS/BlockNote/blob/main/packages/react/src/components/FormattingToolbar/DefaultButtons/FileDownloadButton.tsx
|
||||
*/
|
||||
|
||||
import {
|
||||
BlockSchema,
|
||||
InlineContentSchema,
|
||||
StyleSchema,
|
||||
blockHasType,
|
||||
} from '@blocknote/core';
|
||||
import { blockHasType } from '@blocknote/core';
|
||||
import {
|
||||
useBlockNoteEditor,
|
||||
useComponentsContext,
|
||||
useDictionary,
|
||||
useSelectedBlocks,
|
||||
useEditorState,
|
||||
} from '@blocknote/react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { RiDownload2Fill } from 'react-icons/ri';
|
||||
|
||||
import { downloadFile, exportResolveFileUrl } from '@/docs/doc-export';
|
||||
import { isSafeUrl } from '@/utils/url';
|
||||
|
||||
import {
|
||||
DocsBlockSchema,
|
||||
DocsInlineContentSchema,
|
||||
DocsStyleSchema,
|
||||
} from '../../types';
|
||||
|
||||
export const FileDownloadButton = ({
|
||||
open,
|
||||
}: {
|
||||
@@ -33,36 +34,43 @@ export const FileDownloadButton = ({
|
||||
const Components = useComponentsContext();
|
||||
|
||||
const editor = useBlockNoteEditor<
|
||||
BlockSchema,
|
||||
InlineContentSchema,
|
||||
StyleSchema
|
||||
DocsBlockSchema,
|
||||
DocsInlineContentSchema,
|
||||
DocsStyleSchema
|
||||
>();
|
||||
|
||||
const selectedBlocks = useSelectedBlocks(editor);
|
||||
const fileBlock = useEditorState({
|
||||
editor,
|
||||
selector: ({ editor }) => {
|
||||
const selectedBlocks = editor.getSelection()?.blocks || [
|
||||
editor.getTextCursorPosition().block,
|
||||
];
|
||||
|
||||
const fileBlock = useMemo(() => {
|
||||
// Checks if only one block is selected.
|
||||
if (selectedBlocks.length !== 1) {
|
||||
return undefined;
|
||||
}
|
||||
if (selectedBlocks.length !== 1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const block = selectedBlocks[0];
|
||||
const block = selectedBlocks[0];
|
||||
|
||||
if (
|
||||
!blockHasType(block, editor, block.type, {
|
||||
url: 'string',
|
||||
name: 'string',
|
||||
})
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
blockHasType(block, editor, block.type, { url: 'string', name: 'string' })
|
||||
) {
|
||||
return block;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [editor, selectedBlocks]);
|
||||
},
|
||||
});
|
||||
|
||||
const onClick = useCallback(async () => {
|
||||
if (fileBlock && fileBlock.props.url) {
|
||||
editor.focus();
|
||||
|
||||
const url = fileBlock.props.url as string;
|
||||
const name = fileBlock.props.name as string | undefined;
|
||||
const name = fileBlock.props.name as string;
|
||||
|
||||
/**
|
||||
* If not hosted on our domain, means not a file uploaded by the user,
|
||||
@@ -110,19 +118,18 @@ export const FileDownloadButton = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const blockType = fileBlock.type as string;
|
||||
const tooltipText =
|
||||
dict.formatting_toolbar.file_download.tooltip[blockType] ||
|
||||
dict.formatting_toolbar.file_download.tooltip['file'];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Components.FormattingToolbar.Button
|
||||
className="bn-button --docs--editor-file-download-button"
|
||||
data-test="downloadfile"
|
||||
label={
|
||||
dict.formatting_toolbar.file_download.tooltip[fileBlock.type] ||
|
||||
dict.formatting_toolbar.file_download.tooltip['file']
|
||||
}
|
||||
mainTooltip={
|
||||
dict.formatting_toolbar.file_download.tooltip[fileBlock.type] ||
|
||||
dict.formatting_toolbar.file_download.tooltip['file']
|
||||
}
|
||||
label={tooltipText}
|
||||
mainTooltip={tooltipText}
|
||||
icon={<RiDownload2Fill />}
|
||||
onClick={() => void onClick()}
|
||||
/>
|
||||
|
||||
@@ -94,6 +94,7 @@ export function MarkdownButton() {
|
||||
font-family: var(--c--globals--font--families--base);
|
||||
`}
|
||||
$weight="bold"
|
||||
$size="sm"
|
||||
>
|
||||
M
|
||||
</Text>
|
||||
|
||||
@@ -90,7 +90,7 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
|
||||
}
|
||||
}, [isProviderReady, setIsSkeletonVisible]);
|
||||
|
||||
if (!isProviderReady) {
|
||||
if (!isProviderReady || provider?.configuration.name !== doc.id) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
/**
|
||||
* This file is adapted from BlockNote's AddCommentButton component
|
||||
* https://github.com/TypeCellOS/BlockNote/blob/main/packages/react/src/components/FormattingToolbar/DefaultButtons/AddCommentButton.tsx
|
||||
*/
|
||||
|
||||
import { CommentsExtension } from '@blocknote/core/comments';
|
||||
import { FormattingToolbarExtension } from '@blocknote/core/extensions';
|
||||
import {
|
||||
useBlockNoteEditor,
|
||||
useComponentsContext,
|
||||
useExtension,
|
||||
useSelectedBlocks,
|
||||
} from '@blocknote/react';
|
||||
import { useMemo } from 'react';
|
||||
@@ -22,6 +30,10 @@ export const CommentToolbarButton = () => {
|
||||
const { currentDoc } = useDocStore();
|
||||
const { t } = useTranslation();
|
||||
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
|
||||
const comments = useExtension('comments') as unknown as ReturnType<
|
||||
ReturnType<typeof CommentsExtension>
|
||||
>;
|
||||
const { store } = useExtension(FormattingToolbarExtension);
|
||||
|
||||
const editor = useBlockNoteEditor<
|
||||
DocsBlockSchema,
|
||||
@@ -35,7 +47,18 @@ export const CommentToolbarButton = () => {
|
||||
return !!selectedBlocks.find((block) => block.content !== undefined);
|
||||
}, [selectedBlocks]);
|
||||
|
||||
const focusOnInputThread = () => {
|
||||
// Use setTimeout to ensure the DOM has been updated with the new comment
|
||||
setTimeout(() => {
|
||||
const threadElement = document.querySelector<HTMLElement>(
|
||||
'.bn-thread .bn-editor',
|
||||
);
|
||||
threadElement?.focus();
|
||||
}, 400);
|
||||
};
|
||||
|
||||
if (
|
||||
!comments ||
|
||||
!show ||
|
||||
!editor.isEditable ||
|
||||
!Components ||
|
||||
@@ -49,8 +72,9 @@ export const CommentToolbarButton = () => {
|
||||
<Components.Generic.Toolbar.Button
|
||||
className="bn-button"
|
||||
onClick={() => {
|
||||
editor.comments?.startPendingComment();
|
||||
editor.formattingToolbar.closeMenu();
|
||||
comments.startPendingComment();
|
||||
store.setState(false);
|
||||
focusOnInputThread();
|
||||
}}
|
||||
aria-haspopup="dialog"
|
||||
data-test="comment-toolbar-button"
|
||||
|
||||
@@ -37,7 +37,8 @@ export class DocsThreadStore extends ThreadStore {
|
||||
if (docAuth.canSee) {
|
||||
this.awareness = awareness;
|
||||
this.awareness?.on('update', this.onAwarenessUpdate);
|
||||
void this.refreshThreads();
|
||||
|
||||
this.refreshThreads();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,9 +99,9 @@ export class DocsThreadStore extends ThreadStore {
|
||||
|
||||
// If we know the threadId, schedule a targeted refresh. Otherwise, fall back to full refresh.
|
||||
if (ping.threadId) {
|
||||
await this.refreshThread(ping.threadId);
|
||||
void this.refreshThread(ping.threadId);
|
||||
} else {
|
||||
await this.refreshThreads();
|
||||
this.refreshThreads();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -117,6 +118,21 @@ export class DocsThreadStore extends ThreadStore {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls to the bottom of a thread modal
|
||||
* @param threadId
|
||||
*/
|
||||
private scrollToBottomOfThread() {
|
||||
// Use setTimeout to ensure the DOM has been updated with the new comment
|
||||
setTimeout(() => {
|
||||
const threadElement = document.querySelector('.bn-thread');
|
||||
threadElement?.scrollBy({
|
||||
top: threadElement.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies all subscribers about the current thread state
|
||||
*/
|
||||
@@ -185,7 +201,7 @@ export class DocsThreadStore extends ThreadStore {
|
||||
const { editor } = useEditorStore.getState();
|
||||
|
||||
// Should not happen
|
||||
if (!editor) {
|
||||
if (!editor || !editor._tiptapEditor?.view?.dom) {
|
||||
console.warn('Editor to add thread not ready');
|
||||
return Promise.resolve();
|
||||
}
|
||||
@@ -265,7 +281,7 @@ export class DocsThreadStore extends ThreadStore {
|
||||
}),
|
||||
);
|
||||
|
||||
await this.refreshThreads();
|
||||
this.refreshThreads();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -283,7 +299,17 @@ export class DocsThreadStore extends ThreadStore {
|
||||
this.notifySubscribers();
|
||||
}
|
||||
|
||||
public async refreshThreads(): Promise<void> {
|
||||
public refreshThreads() {
|
||||
this.initThreads().catch((e) => {
|
||||
if (!(e instanceof APIError) || e.status !== 401) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
return;
|
||||
});
|
||||
}
|
||||
|
||||
public async initThreads(): Promise<void> {
|
||||
const response = await fetchAPI(`documents/${this.docId}/threads/`, {
|
||||
method: 'GET',
|
||||
});
|
||||
@@ -345,6 +371,10 @@ export class DocsThreadStore extends ThreadStore {
|
||||
await this.refreshThread(threadId);
|
||||
}
|
||||
this.ping(threadId);
|
||||
|
||||
// Auto-scroll to bottom of thread after adding comment
|
||||
this.scrollToBottomOfThread();
|
||||
|
||||
return serverCommentToClientComment(comment);
|
||||
};
|
||||
|
||||
@@ -405,10 +435,20 @@ export class DocsThreadStore extends ThreadStore {
|
||||
// Optimistically remove the comment locally if we have the thread
|
||||
const existing = this.threads.get(threadId);
|
||||
if (existing) {
|
||||
const updatedComments = existing.comments.filter(
|
||||
(c) => c.id !== commentId,
|
||||
);
|
||||
|
||||
// If this was the last comment, delete the thread
|
||||
if (updatedComments.length === 0) {
|
||||
await this.deleteThread({ threadId });
|
||||
return;
|
||||
}
|
||||
|
||||
const updated: ClientThreadData = {
|
||||
...existing,
|
||||
updatedAt: new Date(),
|
||||
comments: existing.comments.filter((c) => c.id !== commentId),
|
||||
comments: updatedComments,
|
||||
};
|
||||
this.upsertClientThreadData(updated);
|
||||
this.notifySubscribers();
|
||||
@@ -419,10 +459,6 @@ export class DocsThreadStore extends ThreadStore {
|
||||
this.ping(threadId);
|
||||
};
|
||||
|
||||
/**
|
||||
* UI not implemented
|
||||
* @param _options
|
||||
*/
|
||||
public deleteThread = async (_options: { threadId: string }) => {
|
||||
const response = await fetchAPI(
|
||||
`documents/${this.docId}/threads/${_options.threadId}/`,
|
||||
@@ -459,7 +495,7 @@ export class DocsThreadStore extends ThreadStore {
|
||||
);
|
||||
}
|
||||
|
||||
await this.refreshThreads();
|
||||
this.refreshThreads();
|
||||
this.ping(threadId);
|
||||
};
|
||||
|
||||
|
||||
@@ -11,7 +11,11 @@ export const cssComments = (
|
||||
.bn-thread-mark:not([data-orphan='true']),
|
||||
.bn-thread-mark-selected:not([data-orphan='true']) {
|
||||
background: ${canSeeComment ? '#EDB40066' : 'transparent'};
|
||||
color: var(--c--globals--colors--greyscale-700);
|
||||
color: var(--c--globals--colors--gray-700);
|
||||
}
|
||||
|
||||
[data-show-selection] {
|
||||
color: HighlightText;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,13 +26,16 @@ export const cssComments = (
|
||||
|
||||
// Thread modal
|
||||
.bn-thread {
|
||||
width: 400px;
|
||||
width: 100%;
|
||||
min-width: calc(min(400px, 90vw));
|
||||
max-width: calc(min(400px, 90vw));
|
||||
max-height: calc(min(500px, 60vh));
|
||||
padding: 8px;
|
||||
box-shadow: 0px 6px 18px 0px #00001229;
|
||||
margin-left: 20px;
|
||||
margin-right: 20px;
|
||||
gap: 0;
|
||||
overflow: auto;
|
||||
max-height: 500px;
|
||||
|
||||
.bn-default-styles {
|
||||
font-family: var(--c--globals--font--families--base);
|
||||
@@ -54,7 +61,7 @@ export const cssComments = (
|
||||
& .bn-editor {
|
||||
padding-left: var(--c--globals--spacings--lg);
|
||||
.bn-inline-content {
|
||||
color: var(--c--globals--colors--greyscale-700);
|
||||
color: var(--c--globals--colors--gray-700);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +72,7 @@ export const cssComments = (
|
||||
padding: var(--c--globals--spacings--0)
|
||||
var(--c--globals--spacings--st);
|
||||
background: none;
|
||||
border: 1px solid var(--c--globals--colors--greyscale-300);
|
||||
border: 1px solid var(--c--globals--colors--gray-300);
|
||||
border-radius: var(--c--globals--spacings--st);
|
||||
height: var(--c--globals--spacings--md);
|
||||
}
|
||||
@@ -103,17 +110,17 @@ export const cssComments = (
|
||||
background-color: transparent;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--c--globals--colors--greyscale-100);
|
||||
background-color: var(--c--globals--colors--gray-100);
|
||||
}
|
||||
}
|
||||
|
||||
button[role='menuitem'] svg {
|
||||
color: var(--c--globals--colors--greyscale-600);
|
||||
color: var(--c--globals--colors--gray-600);
|
||||
}
|
||||
}
|
||||
|
||||
& svg {
|
||||
color: var(--c--globals--colors--info-600);
|
||||
color: var(--c--contextuals--background--semantic--brand--primary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,15 +137,23 @@ export const cssComments = (
|
||||
padding-inline: var(--c--globals--spacings--st);
|
||||
|
||||
&[data-test='save'] {
|
||||
border: 1px solid var(--c--globals--colors--info-600);
|
||||
background: var(--c--globals--colors--info-600);
|
||||
color: white;
|
||||
border: 1px solid
|
||||
var(--c--contextuals--background--semantic--brand--primary);
|
||||
background: var(
|
||||
--c--contextuals--background--semantic--brand--primary
|
||||
);
|
||||
color: var(
|
||||
--c--contextuals--content--semantic--brand--on-brand
|
||||
);
|
||||
}
|
||||
|
||||
&[data-test='cancel'] {
|
||||
background: white;
|
||||
border: 1px solid var(--c--globals--colors--greyscale-300);
|
||||
color: var(--c--globals--colors--info-600);
|
||||
border: 1px solid
|
||||
var(--c--contextuals--border--surface--primary);
|
||||
color: var(
|
||||
--c--contextuals--background--semantic--brand--primary
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,19 +195,22 @@ export const cssComments = (
|
||||
|
||||
button {
|
||||
font-size: 0;
|
||||
background: var(--c--globals--colors--info-600);
|
||||
background: var(
|
||||
--c--contextuals--background--semantic--brand--primary
|
||||
);
|
||||
width: var(--c--globals--spacings--md);
|
||||
height: var(--c--globals--spacings--md);
|
||||
padding: var(--c--globals--spacings--0);
|
||||
|
||||
&:disabled {
|
||||
background: var(--c--globals--colors--greyscale-300);
|
||||
background: var(--c--globals--colors--gray-300);
|
||||
}
|
||||
|
||||
& .mantine-Button-label::before {
|
||||
content: '🡡';
|
||||
font-size: 13px;
|
||||
color: var(--c--globals--colors--greyscale-100);
|
||||
content: 'arrow_upward_alt';
|
||||
font-family: 'Material Symbols Outlined Variable', sans-serif;
|
||||
font-size: 18px;
|
||||
color: var(--c--contextuals--content--semantic--brand--on-brand);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { User } from '@/features/auth';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { User, avatarUrlFromName } from '@/features/auth';
|
||||
import { Doc, useProviderStore } from '@/features/docs/doc-management';
|
||||
|
||||
import { DocsThreadStore } from './DocsThreadStore';
|
||||
@@ -12,6 +14,9 @@ export function useComments(
|
||||
user: User | null | undefined,
|
||||
) {
|
||||
const { provider } = useProviderStore();
|
||||
const { t } = useTranslation();
|
||||
const { themeTokens } = useCunninghamTheme();
|
||||
|
||||
const threadStore = useMemo(() => {
|
||||
return new DocsThreadStore(
|
||||
docId,
|
||||
@@ -29,5 +34,25 @@ export function useComments(
|
||||
};
|
||||
}, [threadStore]);
|
||||
|
||||
return threadStore;
|
||||
const resolveUsers = useCallback(
|
||||
async (userIds: string[]) => {
|
||||
return Promise.resolve(
|
||||
userIds.map((encodedURIUserId) => {
|
||||
const fullName = decodeURIComponent(encodedURIUserId);
|
||||
|
||||
return {
|
||||
id: encodedURIUserId,
|
||||
username: fullName || t('Anonymous'),
|
||||
avatarUrl: avatarUrlFromName(
|
||||
fullName,
|
||||
themeTokens?.font?.families?.base,
|
||||
),
|
||||
};
|
||||
}),
|
||||
);
|
||||
},
|
||||
[t, themeTokens?.font?.families?.base],
|
||||
);
|
||||
|
||||
return { threadStore, resolveUsers };
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
InlineContentSchema,
|
||||
StyleSchema,
|
||||
defaultProps,
|
||||
insertOrUpdateBlock,
|
||||
} from '@blocknote/core';
|
||||
import { insertOrUpdateBlockForSlashMenu } from '@blocknote/core/extensions';
|
||||
import { BlockTypeSelectItem, createReactBlockSpec } from '@blocknote/react';
|
||||
import { TFunction } from 'i18next';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
@@ -97,34 +97,41 @@ const CalloutComponent = ({
|
||||
`}
|
||||
>
|
||||
<CalloutBlockStyle />
|
||||
<BoxButton
|
||||
contentEditable={false}
|
||||
onClick={toggleEmojiPicker}
|
||||
$css={css`
|
||||
font-size: 1.125rem;
|
||||
cursor: ${isEditable ? 'pointer' : 'default'};
|
||||
${isEditable &&
|
||||
`
|
||||
<Box $position="relative">
|
||||
<BoxButton
|
||||
contentEditable={false}
|
||||
onClick={toggleEmojiPicker}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape' && openEmojiPicker) {
|
||||
setOpenEmojiPicker(false);
|
||||
}
|
||||
}}
|
||||
$css={css`
|
||||
font-size: 1.125rem;
|
||||
cursor: ${isEditable ? 'pointer' : 'default'};
|
||||
${isEditable &&
|
||||
`
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
`}
|
||||
`}
|
||||
$align="center"
|
||||
$width="28px"
|
||||
$radius="4px"
|
||||
>
|
||||
{block.props.emoji}
|
||||
</BoxButton>
|
||||
`}
|
||||
$align="center"
|
||||
$width="28px"
|
||||
$radius="4px"
|
||||
>
|
||||
{block.props.emoji}
|
||||
</BoxButton>
|
||||
|
||||
{openEmojiPicker && (
|
||||
<EmojiPicker
|
||||
emojiData={emojidata}
|
||||
onClickOutside={onClickOutside}
|
||||
onEmojiSelect={onEmojiSelect}
|
||||
withOverlay={true}
|
||||
/>
|
||||
)}
|
||||
{openEmojiPicker && (
|
||||
<EmojiPicker
|
||||
emojiData={emojidata}
|
||||
onClickOutside={onClickOutside}
|
||||
onEmojiSelect={onEmojiSelect}
|
||||
withOverlay={true}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box as="p" className="inline-content" ref={contentRef} />
|
||||
</Box>
|
||||
);
|
||||
@@ -156,7 +163,7 @@ export const getCalloutReactSlashMenuItems = (
|
||||
key: 'callout',
|
||||
title: t('Callout'),
|
||||
onItemClick: () => {
|
||||
insertOrUpdateBlock(editor, {
|
||||
insertOrUpdateBlockForSlashMenu(editor, {
|
||||
type: 'callout',
|
||||
});
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user