Compare commits

..

1 Commits

Author SHA1 Message Date
Cyril
1fb0897ebe (frontend) fix tree keyboard toggle when children not yet loaded
node expansion now triggers lazy loading even without prior mouse interaction

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-16 13:17:34 +02:00
311 changed files with 9025 additions and 15473 deletions

View File

@@ -31,7 +31,7 @@ jobs:
images: lasuite/impress-backend
-
name: Login to DockerHub
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
@@ -49,7 +49,7 @@ jobs:
context: .
target: backend-production
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
@@ -67,7 +67,7 @@ jobs:
images: lasuite/impress-frontend
-
name: Login to DockerHub
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
@@ -88,7 +88,7 @@ jobs:
build-args: |
DOCKER_USER=${{ env.DOCKER_USER }}:-1000
PUBLISH_AS_MIT=false
push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
@@ -106,7 +106,7 @@ jobs:
images: lasuite/impress-y-provider
-
name: Login to DockerHub
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
if: github.event_name != 'pull_request'
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
-
name: Run trivy scan
@@ -122,7 +122,7 @@ jobs:
file: ./src/frontend/servers/y-provider/Dockerfile
target: y-provider
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
@@ -131,7 +131,7 @@ jobs:
- build-and-push-frontend
- build-and-push-backend
runs-on: ubuntu-latest
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
if: github.event_name != 'pull_request'
steps:
- uses: numerique-gouv/action-argocd-webhook-notification@main
id: notify

View File

@@ -79,7 +79,6 @@ jobs:
--check-filenames \
--ignore-words-list "Dokument,afterAll,excpt,statics" \
--skip "./git/" \
--skip "**/*.pdf" \
--skip "**/*.po" \
--skip "**/*.pot" \
--skip "**/*.json" \

View File

@@ -1,27 +0,0 @@
name: Label Preview
on:
pull_request:
types: [labeled, opened]
permissions:
pull-requests: write
jobs:
comment:
runs-on: ubuntu-latest
if: contains(github.event.pull_request.labels.*.name, 'preview')
steps:
- uses: thollander/actions-comment-pull-request@v3
with:
message: |
:rocket: Preview will be available at [https://${{ github.event.pull_request.number }}-docs.ppr-docs.beta.numerique.gouv.fr/](https://${{ github.event.pull_request.number }}-docs.ppr-docs.beta.numerique.gouv.fr/)
You can use the existing account with these credentials:
- username: `docs`
- password: `docs`
You can also create a new account if you want to.
Once this Pull Request is merged, the preview will be destroyed.
comment-tag: preview-url

7
.gitignore vendored
View File

@@ -43,10 +43,6 @@ venv.bak/
env.d/development/*.local
env.d/terraform
# Docker
compose.override.yml
docker/auth/*.local
# npm
node_modules
@@ -79,6 +75,3 @@ db.sqlite3
.vscode/
*.iml
.devcontainer
# Cursor rules
.cursorrules

View File

@@ -1,3 +1,5 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0),
@@ -6,132 +8,21 @@ and this project adheres to
## [Unreleased]
### Added
- ✨(frontend) enable ODT export for documents #1524
- ✨(frontend) improve mobile UX by showing subdocs count #1540
### Fixed
- ♿(frontend) improve accessibility:
- ♿(frontend) improve ARIA in doc grid and editor for a11y #1519
- ♿(frontend) improve accessibility and styling of summary table #1528
- ♿(frontend) add focus trap and enter key support to remove doc modal #1531
- 🐛(docx) fix image overflow by limiting width to 600px during export #1525
- 🐛(frontend) preserve @ character when esc is pressed after typing it #1512
- 🐛(frontend) fix pdf embed to use full width #1526
## [3.9.0] - 2025-11-10
### Added
- ✨(frontend) create skeleton component for DocEditor #1491
- ✨(frontend) add an EmojiPicker in the document tree and title #1381
- ✨(frontend) ajustable left panel #1456
### Changed
- ♻️(frontend) adapt custom blocks to new implementation #1375
- ♻️(backend) increase user short_name field length #1510
- 🚸(frontend) separate viewers from editors #1509
### Fixed
- 🐛(frontend) fix duplicate document entries in grid #1479
- 🐛(backend) fix trashbin list #1520
- ♿(frontend) improve accessibility:
- ♿(frontend) remove empty alt on logo due to Axe a11y error #1516
- 🐛(backend) fix s3 version_id validation #1543
- 🐛(frontend) retry check media status after page reload #1555
- 🐛(frontend) fix Interlinking memory leak #1560
- 🐛(frontend) button new doc UI fix #1557
- 🐛(frontend) interlinking UI fix #1557
## [3.8.2] - 2025-10-17
- #1354
- ✨fix tree keyboard toggle when children not yet loaded #1388
### Fixed
- 🐛(service-worker) fix sw registration and page reload logic #1500
## [3.8.1] - 2025-10-17
### Fixed
- ⚡️(backend) improve trashbin endpoint performance #1495
- 🐛(backend) manage invitation partial update without email #1494
- ♿(frontend) improve accessibility:
- ♿ add missing aria-label to add sub-doc button for accessibility #1480
- ♿ add missing aria-label to more options button on sub-docs #1481
### Removed
- 🔥(backend) remove treebeard form for the document admin #1470
## [3.8.0] - 2025-10-14
### Added
- ✨(frontend) add pdf block to the editor #1293
- ✨List and restore deleted docs #1450
### Changed
- ♻️(frontend) Refactor Auth component for improved redirection logic #1461
- ♻️(frontend) replace Arial font-family with token font #1411
- ♿(frontend) improve accessibility:
- ♿(frontend) enable enter key to open documentss #1354
- ♿(frontend) improve modal a11y: structure, labels, title #1349
- ♿improve NVDA navigation in DocShareModal #1396
- ♿ improve accessibility by adding landmark roles to layout #1394
- ♿ add document visible in list and openable via enter key #1365
- ♿ add pdf outline property to enable bookmarks display #1368
- ♿ hide decorative icons from assistive tech with aria-hidden #1404
- ♿ fix rgaa 1.9.1: convert to figure/figcaption structure #1426
- ♿ remove redundant aria-label to avoid over-accessibility #1420
- ♿ remove redundant aria-label on hidden icons and update tests #1432
- ♿ improve semantic structure and aria roles of leftpanel #1431
- ♿ add default background to left panel for better accessibility #1423
- ♿ restyle checked checkboxes: removing strikethrough #1439
- ♿ add h1 for SR on 40X pages and remove alt texts #1438
- ♿ update labels and shared document icon accessibility #1442
- 🍱(frontend) Fonts GDPR compliants #1453
- ♻️(service-worker) improve SW registration and update handling #1473
- ✨(backend) add async indexation of documents on save (or access save) #1276
- ✨(backend) add debounce mechanism to limit indexation jobs #1276
- ✨(api) add API route to search for indexed documents in Find #1276
### Fixed
- 🐛(backend) duplicate sub docs as root for reader users #1385
- ⚗️(service-worker) remove index from cache first strategy #1395
- 🐛(frontend) fix 404 page when reload 403 page #1402
- 🐛(frontend) fix legacy role computation #1376
- 🛂(frontend) block editing title when not allowed #1412
- 🐛(frontend) scroll back to top when navigate to a document #1406
- 🐛(frontend) fix export pdf emoji problem #1453
- 🐛(frontend) fix attachment download filename #1447
- 🐛(frontend) exclude h4-h6 headings from table of contents #1441
- 🔒(frontend) prevent readers from changing callout emoji #1449
- 🐛(frontend) fix overlapping placeholders in multi-column layout #1455
- 🐛(backend) filter invitation with case insensitive email #1457
- 🐛(frontend) reduce no access image size from 450 to 300 #1463
- 🐛(frontend) preserve interlink style on drag-and-drop in editor #1460
- ✨(frontend) load docs logo from public folder via url #1462
- 🔧(keycloak) Fix https required issue in dev mode #1286
## Removed
- 🔥(frontend) remove custom DividerBlock ##1375
- 🐛(backend) duplicate sub docs as root for reader users
## [3.7.0] - 2025-09-12
### Added
- ✨(api) add API route to fetch document content #1206
- ✨(frontend) doc emojis improvements #1381
- add an EmojiPicker in the document tree and document title
- remove emoji buttons in menus
### Changed
@@ -145,8 +36,6 @@ and this project adheres to
- ✨unify tab focus style for better visual consistency #1341
- ♿hide decorative icons, label menus, avoid accessible name… #1362
- ♻️(tilt) use helm dev-backend chart
- 🩹(frontend) on main pages do not display leading emoji as page icon #1381
- 🩹(frontend) handle properly emojis in interlinking #1381
### Removed
@@ -191,6 +80,7 @@ and this project adheres to
- 🐛(frontend) fix dnd conflict with tree and Blocknote #1328
- 🐛(frontend) fix display bug on homepage #1332
- 🐛link role update #1287
- 🔧(keycloak) Fix https required issue in dev mode #1286
## [3.5.0] - 2025-07-31
@@ -862,11 +752,7 @@ and this project adheres to
- ✨(frontend) Coming Soon page (#67)
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/suitenumerique/docs/compare/v3.9.0...main
[v3.9.0]: https://github.com/suitenumerique/docs/releases/v3.9.0
[v3.8.2]: https://github.com/suitenumerique/docs/releases/v3.8.2
[v3.8.1]: https://github.com/suitenumerique/docs/releases/v3.8.1
[v3.8.0]: https://github.com/suitenumerique/docs/releases/v3.8.0
[unreleased]: https://github.com/suitenumerique/docs/compare/v3.7.0...main
[v3.7.0]: https://github.com/suitenumerique/docs/releases/v3.7.0
[v3.6.0]: https://github.com/suitenumerique/docs/releases/v3.6.0
[v3.5.0]: https://github.com/suitenumerique/docs/releases/v3.5.0

View File

@@ -94,14 +94,6 @@ RUN chmod g=u /etc/passwd
# Copy installed python dependencies
COPY --from=back-builder /install /usr/local
# Link certifi certificate from a static path /cert/cacert.pem to avoid issues
# when python is upgraded and the path to the certificate changes.
# The space between print and the ( is intended otherwise the git lint is failing
RUN mkdir /cert && \
path=`python -c 'import certifi;print (certifi.where())'` && \
mv $path /cert/ && \
ln -s /cert/cacert.pem $path
# Copy impress application (see .dockerignore)
COPY ./src/backend /app/

View File

@@ -247,10 +247,6 @@ 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: \

View File

@@ -1,6 +0,0 @@
#!/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");'

View File

@@ -72,11 +72,6 @@ services:
- env.d/development/postgresql.local
ports:
- "8071:8000"
networks:
default: {}
lasuite:
aliases:
- impress
volumes:
- ./src/backend:/app
- ./data/static:/data/static
@@ -97,9 +92,6 @@ 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
@@ -115,11 +107,6 @@ 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:
@@ -230,8 +217,3 @@ services:
kc_postgresql:
condition: service_healthy
restart: true
networks:
lasuite:
name: lasuite-network
driver: bridge

View File

@@ -60,7 +60,7 @@
},
{
"username": "user-e2e-chromium",
"email": "user.test@chromium.test",
"email": "user@chromium.test",
"firstName": "E2E",
"lastName": "Chromium",
"enabled": true,
@@ -74,7 +74,7 @@
},
{
"username": "user-e2e-webkit",
"email": "user.test@webkit.test",
"email": "user@webkit.test",
"firstName": "E2E",
"lastName": "Webkit",
"enabled": true,
@@ -88,7 +88,7 @@
},
{
"username": "user-e2e-firefox",
"email": "user.test@firefox.test",
"email": "user@firefox.test",
"firstName": "E2E",
"lastName": "Firefox",
"enabled": true,

View File

@@ -12,7 +12,6 @@ flowchart TD
Back --> DB("Database (PostgreSQL)")
Back <--> Celery --> DB
Back ----> S3("Minio (S3)")
Back -- REST API --> Find
```
### Architecture decision records

View File

@@ -93,13 +93,6 @@ These are the environment variables you can set for the `impress-backend` contai
| OIDC_USERINFO_SHORTNAME_FIELD | OIDC token claims to create shortname | first_name |
| POSTHOG_KEY | Posthog key for analytics | |
| REDIS_URL | Cache url | redis://redis:6379/1 |
| SEARCH_INDEXER_CLASS | Class of the backend for document indexation & search | |
| SEARCH_INDEXER_BATCH_SIZE | Size of each batch for indexation of all documents | 100000 |
| SEARCH_INDEXER_COUNTDOWN | Minimum debounce delay of indexation jobs (in seconds) | 1 |
| SEARCH_INDEXER_URL | Find application endpoint for indexation | |
| SEARCH_INDEXER_SECRET | Token for indexation queries | |
| SEARCH_INDEXER_QUERY_URL | Find application endpoint for search | |
| SEARCH_INDEXER_QUERY_LIMIT | Maximum number of results expected from search endpoint | 50 |
| SENTRY_DSN | Sentry host | |
| SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 |
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | | false |

View File

@@ -82,7 +82,7 @@ backend:
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
extraVolumeMounts:
- name: certs
mountPath: /cert/cacert.pem
mountPath: /usr/local/lib/python3.13/site-packages/certifi/cacert.pem
subPath: cacert.pem
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false

View File

@@ -1,41 +0,0 @@
# 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)
```

View File

@@ -97,17 +97,6 @@ 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.

View File

@@ -32,24 +32,6 @@ Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom
----
# **Your logo** 📝
You can add your own logo in the header from the theme customization file.
### Settings 🔧
```shellscript
THEME_CUSTOMIZATION_FILE_PATH=<path>
```
### Example of JSON
You can activate it with the `header.logo` configuration: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
This configuration is optional. If not set, the default logo will be used.
----
# **Footer Configuration** 📝
The footer is configurable from the theme customization file.

View File

@@ -36,7 +36,6 @@ 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
@@ -50,14 +49,6 @@ LOGOUT_REDIRECT_URL=http://localhost:3000
OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
# Store OIDC tokens in the session
OIDC_STORE_ACCESS_TOKEN = True
OIDC_STORE_REFRESH_TOKEN = True # Store the encrypted refresh token in the session.
# Must be a valid Fernet key (32 url-safe base64-encoded bytes)
# To create one, use the bin/fernetkey command.
# OIDC_STORE_REFRESH_TOKEN_KEY="your-32-byte-encryption-key=="
# AI
AI_FEATURE_ENABLED=true
AI_BASE_URL=https://openaiendpoint.com
@@ -75,12 +66,3 @@ COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/
DJANGO_SERVER_TO_SERVER_API_TOKENS=server-api-token
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
# 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/"

View File

@@ -19,17 +19,13 @@
"matchPackageNames": ["redis"],
"allowedVersions": "<6.0.0"
},
{
"groupName": "allowed pylint versions",
"matchManagers": ["pep621"],
"matchPackageNames": ["pylint"],
"allowedVersions": "<4.0.0"
},
{
"enabled": false,
"groupName": "ignored js dependencies",
"matchManagers": ["npm"],
"matchPackageNames": [
"@hocuspocus/provider",
"@hocuspocus/server",
"docx",
"fetch-mock",
"node",

View File

@@ -5,6 +5,7 @@ from django.contrib.auth import admin as auth_admin
from django.utils.translation import gettext_lazy as _
from treebeard.admin import TreeAdmin
from treebeard.forms import movenodeform_factory
from . import models
@@ -156,6 +157,7 @@ class DocumentAdmin(TreeAdmin):
},
),
)
form = movenodeform_factory(models.Document)
inlines = (DocumentAccessInline,)
list_display = (
"id",

View File

@@ -90,7 +90,6 @@ class ListDocumentSerializer(serializers.ModelSerializer):
nb_accesses_direct = serializers.IntegerField(read_only=True)
user_role = serializers.SerializerMethodField(read_only=True)
abilities = serializers.SerializerMethodField(read_only=True)
deleted_at = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.Document
@@ -103,7 +102,6 @@ class ListDocumentSerializer(serializers.ModelSerializer):
"computed_link_role",
"created_at",
"creator",
"deleted_at",
"depth",
"excerpt",
"is_favorite",
@@ -126,7 +124,6 @@ class ListDocumentSerializer(serializers.ModelSerializer):
"computed_link_role",
"created_at",
"creator",
"deleted_at",
"depth",
"excerpt",
"is_favorite",
@@ -168,10 +165,6 @@ class ListDocumentSerializer(serializers.ModelSerializer):
request = self.context.get("request")
return instance.get_role(request.user) if request else None
def get_deleted_at(self, instance):
"""Return the deleted_at of the current document."""
return instance.ancestors_deleted_at
class DocumentLightSerializer(serializers.ModelSerializer):
"""Minial document serializer for nesting in document accesses."""
@@ -200,7 +193,6 @@ class DocumentSerializer(ListDocumentSerializer):
"content",
"created_at",
"creator",
"deleted_at",
"depth",
"excerpt",
"is_favorite",
@@ -224,7 +216,6 @@ class DocumentSerializer(ListDocumentSerializer):
"computed_link_role",
"created_at",
"creator",
"deleted_at",
"depth",
"is_favorite",
"link_role",
@@ -515,10 +506,6 @@ class LinkDocumentSerializer(serializers.ModelSerializer):
We expose it separately from document in order to simplify and secure access control.
"""
link_reach = serializers.ChoiceField(
choices=models.LinkReachChoices.choices, required=True
)
class Meta:
model = models.Document
fields = [
@@ -526,58 +513,6 @@ class LinkDocumentSerializer(serializers.ModelSerializer):
"link_reach",
]
def validate(self, attrs):
"""Validate that link_role and link_reach are compatible using get_select_options."""
link_reach = attrs.get("link_reach")
link_role = attrs.get("link_role")
if not link_reach:
raise serializers.ValidationError(
{"link_reach": _("This field is required.")}
)
# Get available options based on ancestors' link definition
available_options = models.LinkReachChoices.get_select_options(
**self.instance.ancestors_link_definition
)
# Validate link_reach is allowed
if link_reach not in available_options:
msg = _(
"Link reach '%(link_reach)s' is not allowed based on parent document configuration."
)
raise serializers.ValidationError(
{"link_reach": msg % {"link_reach": link_reach}}
)
# Validate link_role is compatible with link_reach
allowed_roles = available_options[link_reach]
# Restricted reach: link_role must be None
if link_reach == models.LinkReachChoices.RESTRICTED:
if link_role is not None:
raise serializers.ValidationError(
{
"link_role": (
"Cannot set link_role when link_reach is 'restricted'. "
"Link role must be null for restricted reach."
)
}
)
return attrs
# Non-restricted: link_role must be in allowed roles
if link_role not in allowed_roles:
allowed_roles_str = ", ".join(allowed_roles) if allowed_roles else "none"
raise serializers.ValidationError(
{
"link_role": (
f"Link role '{link_role}' is not allowed for link reach '{link_reach}'. "
f"Allowed roles: {allowed_roles_str}"
)
}
)
return attrs
class DocumentDuplicationSerializer(serializers.Serializer):
"""
@@ -749,9 +684,6 @@ class InvitationSerializer(serializers.ModelSerializer):
if self.instance is None:
attrs["issuer"] = user
if attrs.get("email"):
attrs["email"] = attrs["email"].lower()
return attrs
def validate_role(self, role):
@@ -889,13 +821,3 @@ class MoveDocumentSerializer(serializers.Serializer):
choices=enums.MoveNodePositionChoices.choices,
default=enums.MoveNodePositionChoices.LAST_CHILD,
)
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)

View File

@@ -21,7 +21,6 @@ from django.db.models.expressions import RawSQL
from django.db.models.functions import Left, Length
from django.http import Http404, StreamingHttpResponse
from django.urls import reverse
from django.utils.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,7 +31,6 @@ 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
@@ -49,10 +47,6 @@ 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
@@ -379,7 +373,6 @@ 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."""
@@ -630,32 +623,12 @@ class DocumentViewSet(
The selected documents are those deleted within the cutoff period defined in the
settings (see TRASHBIN_CUTOFF_DAYS), before they are considered permanently deleted.
"""
if not request.user.is_authenticated:
return self.get_response_for_queryset(self.queryset.none())
access_documents_paths = (
models.DocumentAccess.objects.select_related("document")
.filter(
db.Q(user=self.request.user) | db.Q(team__in=self.request.user.teams),
role=models.RoleChoices.OWNER,
)
.values_list("document__path", flat=True)
)
if not access_documents_paths:
return self.get_response_for_queryset(self.queryset.none())
children_clause = db.Q()
for path in access_documents_paths:
children_clause |= db.Q(path__startswith=path)
queryset = self.queryset.filter(
children_clause,
deleted_at__isnull=False,
deleted_at__gte=models.get_trashbin_cutoff(),
)
queryset = queryset.annotate_user_roles(self.request.user)
queryset = queryset.filter(user_roles__contains=[models.RoleChoices.OWNER])
return self.get_response_for_queryset(queryset)
@@ -878,47 +851,33 @@ class DocumentViewSet(
try:
current_document = (
self.queryset.select_related(None)
.only("depth", "path", "ancestors_deleted_at")
.get(pk=pk)
self.queryset.select_related(None).only("depth", "path").get(pk=pk)
)
except models.Document.DoesNotExist as excpt:
raise drf.exceptions.NotFound() from excpt
is_deleted = current_document.ancestors_deleted_at is not None
if is_deleted:
if current_document.get_role(user) != models.RoleChoices.OWNER:
raise (
drf.exceptions.PermissionDenied()
if request.user.is_authenticated
else drf.exceptions.NotAuthenticated()
)
highest_readable = current_document
ancestors = self.queryset.select_related(None).filter(pk=pk)
else:
ancestors = (
(
current_document.get_ancestors()
| self.queryset.select_related(None).filter(pk=pk)
)
.filter(ancestors_deleted_at__isnull=True)
.order_by("path")
)
# Get the highest readable ancestor
highest_readable = (
ancestors.select_related(None)
.readable_per_se(request.user)
.only("depth", "path")
.first()
ancestors = (
(
current_document.get_ancestors()
| self.queryset.select_related(None).filter(pk=pk)
)
.filter(ancestors_deleted_at__isnull=True)
.order_by("path")
)
if highest_readable is None:
raise (
drf.exceptions.PermissionDenied()
if request.user.is_authenticated
else drf.exceptions.NotAuthenticated()
)
# Get the highest readable ancestor
highest_readable = (
ancestors.select_related(None)
.readable_per_se(request.user)
.only("depth", "path")
.first()
)
if highest_readable is None:
raise (
drf.exceptions.PermissionDenied()
if request.user.is_authenticated
else drf.exceptions.NotAuthenticated()
)
paths_links_mapping = {}
ancestors_links = []
children_clause = db.Q()
@@ -1071,83 +1030,6 @@ 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):
"""
@@ -1189,7 +1071,7 @@ class DocumentViewSet(
@drf.decorators.action(
detail=True,
methods=["get", "delete"],
url_path=r"versions/(?P<version_id>[A-Za-z0-9._+\-=~]{1,1024})",
url_path="versions/(?P<version_id>[0-9a-z-]+)",
)
# pylint: disable=unused-argument
def versions_detail(self, request, pk, version_id, *args, **kwargs):
@@ -2275,7 +2157,6 @@ class ConfigView(drf.views.APIView):
"LANGUAGES",
"LANGUAGE_CODE",
"SENTRY_DSN",
"TRASHBIN_CUTOFF_DAYS",
]
dict_settings = {}
for setting in array_settings:

View File

@@ -1,19 +1,11 @@
"""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")
def ready(self):
"""
Import signals when the app is ready.
"""
# pylint: disable=import-outside-toplevel, unused-import
from . import signals # noqa: PLC0415
# name = "core"
# app_label = "core"
# verbose_name = _("impress core application")

View File

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

View File

@@ -1,19 +0,0 @@
# Generated by Django 5.2.7 on 2025-10-22 06:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0024_add_is_masked_field_to_link_trace"),
]
operations = [
migrations.AlterField(
model_name="user",
name="short_name",
field=models.CharField(
blank=True, max_length=100, null=True, verbose_name="short name"
),
),
]

View File

@@ -148,9 +148,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
)
full_name = models.CharField(_("full name"), max_length=100, null=True, blank=True)
short_name = models.CharField(
_("short name"), max_length=100, null=True, blank=True
)
short_name = models.CharField(_("short name"), max_length=20, null=True, blank=True)
email = models.EmailField(_("identity email address"), blank=True, null=True)
@@ -223,7 +221,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
Expired invitations are ignored.
"""
valid_invitations = Invitation.objects.filter(
email__iexact=self.email,
email=self.email,
created_at__gte=(
timezone.now()
- timedelta(seconds=settings.INVITATION_VALIDITY_DURATION)
@@ -432,35 +430,32 @@ 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:
self.save_content(self._content)
file_key = self.file_key
bytes_content = self._content.encode("utf-8")
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
# 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
else:
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
)
# 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):
"""
@@ -726,7 +721,7 @@ class Document(MP_Node, BaseModel):
# Characteristics that are based only on specific access
is_owner = role == RoleChoices.OWNER
is_deleted = self.ancestors_deleted_at
is_deleted = self.ancestors_deleted_at and not is_owner
is_owner_or_admin = (is_owner or role == RoleChoices.ADMIN) and not is_deleted
# Compute access roles before adding link roles because we don't
@@ -755,7 +750,6 @@ class Document(MP_Node, BaseModel):
role = RoleChoices.max(role, link_definition["link_role"])
can_get = bool(role) and not is_deleted
retrieve = can_get or is_owner
can_update = (
is_owner_or_admin or role == RoleChoices.EDITOR
) and not is_deleted
@@ -764,7 +758,7 @@ class Document(MP_Node, BaseModel):
is_owner
if self.is_root()
else (is_owner_or_admin or (user.is_authenticated and self.creator == user))
) and not is_deleted
)
ai_allow_reach_from = settings.AI_ALLOW_REACH_FROM
ai_access = any(
@@ -796,15 +790,15 @@ class Document(MP_Node, BaseModel):
"duplicate": can_get and user.is_authenticated,
"favorite": can_get and user.is_authenticated,
"link_configuration": is_owner_or_admin,
"invite_owner": is_owner and not is_deleted,
"invite_owner": is_owner,
"mask": can_get and user.is_authenticated,
"move": is_owner_or_admin and not is_deleted,
"move": is_owner_or_admin and not self.ancestors_deleted_at,
"partial_update": can_update,
"restore": is_owner,
"retrieve": retrieve,
"retrieve": can_get,
"media_auth": can_get,
"link_select_options": link_select_options,
"tree": retrieve,
"tree": can_get,
"update": can_update,
"versions_destroy": is_owner_or_admin,
"versions_list": has_access_role,
@@ -904,8 +898,7 @@ 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,
updated_at=self.updated_at,
ancestors_deleted_at=self.ancestors_deleted_at
)
@transaction.atomic

View File

@@ -1,331 +0,0 @@
"""Document search index management utilities and indexers"""
import base64
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
from core.services.converter_services import (
ServiceUnavailableError as YProviderServiceUnavailableError,
)
from core.services.converter_services import (
YdocConverter,
)
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 to_markdown(self, document):
"""
Convert document as markdown.
Returns raw text if Ydoc service is not accessible
"""
content = ""
base64_content = document.content
if base64_content is not None and len(base64_content) > 0:
# Convert using the y-provider service
try:
yprovider = YdocConverter()
result = yprovider.convert(
base64.b64decode(base64_content),
"application/vnd.yjs.doc",
"text/markdown",
)
content = result
except YProviderServiceUnavailableError as e:
logger.error(
"Error getting content for document %s: %s", document.pk, e
)
return utils.base64_yjs_to_text(base64_content)
return content
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
text_content = self.to_markdown(document)
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")),
"mimetype": "text/markdown",
"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()

View File

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

View File

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

View File

@@ -57,7 +57,7 @@ def test_authentication_getter_existing_user_via_email(
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(4): # user by sub, user by mail, update sub
with django_assert_num_queries(3): # user by sub, user by mail, update sub
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
@@ -214,7 +214,7 @@ def test_authentication_getter_existing_user_change_fields_sub(
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed
with django_assert_num_queries(3):
with django_assert_num_queries(2):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
@@ -256,7 +256,7 @@ def test_authentication_getter_existing_user_change_fields_email(
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed
with django_assert_num_queries(4):
with django_assert_num_queries(3):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)

View File

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

View File

@@ -24,30 +24,3 @@ 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()

View File

@@ -596,32 +596,6 @@ def test_api_document_invitations_create_cannot_invite_existing_users():
}
def test_api_document_invitations_create_lower_email():
"""
No matter the case, the email should be converted to lowercase.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, "owner")])
# Build an invitation to the email of an existing identity in the db
invitation_values = {
"email": "GuEst@example.com",
"role": random.choice(models.RoleChoices.values),
}
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == 201
assert response.json()["email"] == "guest@example.com"
# Update
@@ -769,37 +743,6 @@ def test_api_document_invitations_update_authenticated_unprivileged(
assert value == old_invitation_values[key]
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize("role", ["administrator", "owner"])
def test_api_document_invitations_patch(via, role, mock_user_teams):
"""Partially updating an invitation should be allowed."""
user = factories.UserFactory()
invitation = factories.InvitationFactory(role="editor")
if via == USER:
factories.UserDocumentAccessFactory(
document=invitation.document, user=user, role=role
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=invitation.document, team="lasuite", role=role
)
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/documents/{invitation.document.id!s}/invitations/{invitation.id!s}/",
{"role": "reader"},
format="json",
)
assert response.status_code == 200
invitation.refresh_from_db()
assert invitation.role == "reader"
# Delete

View File

@@ -41,7 +41,6 @@ def test_api_documents_children_list_anonymous_public_standalone(
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child1.excerpt,
"id": str(child1.id),
@@ -64,7 +63,6 @@ def test_api_documents_children_list_anonymous_public_standalone(
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child2.excerpt,
"id": str(child2.id),
@@ -117,7 +115,6 @@ def test_api_documents_children_list_anonymous_public_parent(django_assert_num_q
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"deleted_at": None,
"depth": 4,
"excerpt": child1.excerpt,
"id": str(child1.id),
@@ -140,7 +137,6 @@ def test_api_documents_children_list_anonymous_public_parent(django_assert_num_q
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"deleted_at": None,
"depth": 4,
"excerpt": child2.excerpt,
"id": str(child2.id),
@@ -212,7 +208,6 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child1.excerpt,
"id": str(child1.id),
@@ -235,7 +230,6 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child2.excerpt,
"id": str(child2.id),
@@ -293,7 +287,6 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"deleted_at": None,
"depth": 4,
"excerpt": child1.excerpt,
"id": str(child1.id),
@@ -316,7 +309,6 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"deleted_at": None,
"depth": 4,
"excerpt": child2.excerpt,
"id": str(child2.id),
@@ -401,7 +393,6 @@ def test_api_documents_children_list_authenticated_related_direct(
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child1.excerpt,
"id": str(child1.id),
@@ -424,7 +415,6 @@ def test_api_documents_children_list_authenticated_related_direct(
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child2.excerpt,
"id": str(child2.id),
@@ -485,7 +475,6 @@ def test_api_documents_children_list_authenticated_related_parent(
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"deleted_at": None,
"depth": 4,
"excerpt": child1.excerpt,
"id": str(child1.id),
@@ -508,7 +497,6 @@ def test_api_documents_children_list_authenticated_related_parent(
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"deleted_at": None,
"depth": 4,
"excerpt": child2.excerpt,
"id": str(child2.id),
@@ -621,7 +609,6 @@ def test_api_documents_children_list_authenticated_related_team_members(
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child1.excerpt,
"id": str(child1.id),
@@ -644,7 +631,6 @@ def test_api_documents_children_list_authenticated_related_team_members(
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child2.excerpt,
"id": str(child2.id),

View File

@@ -38,7 +38,6 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child1.excerpt,
"id": str(child1.id),
@@ -63,7 +62,6 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id),
"deleted_at": None,
"depth": 3,
"excerpt": grand_child.excerpt,
"id": str(grand_child.id),
@@ -86,7 +84,6 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child2.excerpt,
"id": str(child2.id),
@@ -138,7 +135,6 @@ def test_api_documents_descendants_list_anonymous_public_parent():
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"deleted_at": None,
"depth": 4,
"excerpt": child1.excerpt,
"id": str(child1.id),
@@ -161,7 +157,6 @@ def test_api_documents_descendants_list_anonymous_public_parent():
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id),
"deleted_at": None,
"depth": 5,
"excerpt": grand_child.excerpt,
"id": str(grand_child.id),
@@ -184,7 +179,6 @@ def test_api_documents_descendants_list_anonymous_public_parent():
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"deleted_at": None,
"depth": 4,
"excerpt": child2.excerpt,
"id": str(child2.id),
@@ -257,7 +251,6 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child1.excerpt,
"id": str(child1.id),
@@ -280,7 +273,6 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id),
"deleted_at": None,
"depth": 3,
"excerpt": grand_child.excerpt,
"id": str(grand_child.id),
@@ -303,7 +295,6 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child2.excerpt,
"id": str(child2.id),
@@ -361,7 +352,6 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"deleted_at": None,
"depth": 4,
"excerpt": child1.excerpt,
"id": str(child1.id),
@@ -384,7 +374,6 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id),
"deleted_at": None,
"depth": 5,
"excerpt": grand_child.excerpt,
"id": str(grand_child.id),
@@ -407,7 +396,6 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"deleted_at": None,
"depth": 4,
"excerpt": child2.excerpt,
"id": str(child2.id),
@@ -486,7 +474,6 @@ def test_api_documents_descendants_list_authenticated_related_direct():
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child1.excerpt,
"id": str(child1.id),
@@ -509,7 +496,6 @@ def test_api_documents_descendants_list_authenticated_related_direct():
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id),
"deleted_at": None,
"depth": 3,
"excerpt": grand_child.excerpt,
"id": str(grand_child.id),
@@ -532,7 +518,6 @@ def test_api_documents_descendants_list_authenticated_related_direct():
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child2.excerpt,
"id": str(child2.id),
@@ -591,7 +576,6 @@ def test_api_documents_descendants_list_authenticated_related_parent():
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"deleted_at": None,
"depth": 4,
"excerpt": child1.excerpt,
"id": str(child1.id),
@@ -614,7 +598,6 @@ def test_api_documents_descendants_list_authenticated_related_parent():
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id),
"deleted_at": None,
"depth": 5,
"excerpt": grand_child.excerpt,
"id": str(grand_child.id),
@@ -637,7 +620,6 @@ def test_api_documents_descendants_list_authenticated_related_parent():
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"deleted_at": None,
"depth": 4,
"excerpt": child2.excerpt,
"id": str(child2.id),
@@ -742,7 +724,6 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child1.excerpt,
"id": str(child1.id),
@@ -765,7 +746,6 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id),
"deleted_at": None,
"depth": 3,
"excerpt": grand_child.excerpt,
"id": str(grand_child.id),
@@ -788,7 +768,6 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child2.excerpt,
"id": str(child2.id),

View File

@@ -65,7 +65,6 @@ def test_api_document_favorite_list_authenticated_with_favorite():
"computed_link_role": document.computed_link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"deleted_at": None,
"content": document.content,
"depth": document.depth,
"excerpt": document.excerpt,

View File

@@ -133,10 +133,7 @@ def test_api_documents_link_configuration_update_authenticated_related_success(
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.AUTHENTICATED,
link_role=models.LinkRoleChoices.READER,
)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
@@ -146,10 +143,7 @@ def test_api_documents_link_configuration_update_authenticated_related_success(
)
new_document_values = serializers.LinkDocumentSerializer(
instance=factories.DocumentFactory(
link_reach=models.LinkReachChoices.PUBLIC,
link_role=models.LinkRoleChoices.EDITOR,
)
instance=factories.DocumentFactory()
).data
with mock_reset_connections(document.id):
@@ -164,240 +158,3 @@ def test_api_documents_link_configuration_update_authenticated_related_success(
document_values = serializers.LinkDocumentSerializer(instance=document).data
for key, value in document_values.items():
assert value == new_document_values[key]
def test_api_documents_link_configuration_update_role_restricted_forbidden():
"""
Test that trying to set link_role on a document with restricted link_reach
returns a validation error.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
link_role=models.LinkRoleChoices.READER,
)
factories.UserDocumentAccessFactory(
document=document, user=user, role=models.RoleChoices.OWNER
)
# Try to set a meaningful role on a restricted document
new_data = {
"link_reach": models.LinkReachChoices.RESTRICTED,
"link_role": models.LinkRoleChoices.EDITOR,
}
response = client.put(
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
new_data,
format="json",
)
assert response.status_code == 400
assert "link_role" in response.json()
assert (
"Cannot set link_role when link_reach is 'restricted'"
in response.json()["link_role"][0]
)
def test_api_documents_link_configuration_update_link_reach_required():
"""
Test that link_reach is required when updating link configuration.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.PUBLIC,
link_role=models.LinkRoleChoices.READER,
)
factories.UserDocumentAccessFactory(
document=document, user=user, role=models.RoleChoices.OWNER
)
# Try to update without providing link_reach
new_data = {"link_role": models.LinkRoleChoices.EDITOR}
response = client.put(
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
new_data,
format="json",
)
assert response.status_code == 400
assert "link_reach" in response.json()
assert "This field is required" in response.json()["link_reach"][0]
def test_api_documents_link_configuration_update_restricted_without_role_success(
mock_reset_connections, # pylint: disable=redefined-outer-name
):
"""
Test that setting link_reach to restricted without specifying link_role succeeds.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.PUBLIC,
link_role=models.LinkRoleChoices.READER,
)
factories.UserDocumentAccessFactory(
document=document, user=user, role=models.RoleChoices.OWNER
)
# Only specify link_reach, not link_role
new_data = {
"link_reach": models.LinkReachChoices.RESTRICTED,
}
with mock_reset_connections(document.id):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
new_data,
format="json",
)
assert response.status_code == 200
document.refresh_from_db()
assert document.link_reach == models.LinkReachChoices.RESTRICTED
@pytest.mark.parametrize(
"reach", [models.LinkReachChoices.PUBLIC, models.LinkReachChoices.AUTHENTICATED]
)
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
def test_api_documents_link_configuration_update_non_restricted_with_valid_role_success(
reach,
role,
mock_reset_connections, # pylint: disable=redefined-outer-name
):
"""
Test that setting non-restricted link_reach with valid link_role succeeds.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
link_role=models.LinkRoleChoices.READER,
)
factories.UserDocumentAccessFactory(
document=document, user=user, role=models.RoleChoices.OWNER
)
new_data = {
"link_reach": reach,
"link_role": role,
}
with mock_reset_connections(document.id):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
new_data,
format="json",
)
assert response.status_code == 200
document.refresh_from_db()
assert document.link_reach == reach
assert document.link_role == role
def test_api_documents_link_configuration_update_with_ancestor_constraints():
"""
Test that link configuration respects ancestor constraints using get_select_options.
This test may need adjustment based on the actual get_select_options implementation.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
parent_document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.PUBLIC,
link_role=models.LinkRoleChoices.READER,
)
child_document = factories.DocumentFactory(
parent=parent_document,
link_reach=models.LinkReachChoices.PUBLIC,
link_role=models.LinkRoleChoices.READER,
)
factories.UserDocumentAccessFactory(
document=child_document, user=user, role=models.RoleChoices.OWNER
)
# Try to set child to PUBLIC when parent is RESTRICTED
new_data = {
"link_reach": models.LinkReachChoices.RESTRICTED,
"link_role": models.LinkRoleChoices.READER,
}
response = client.put(
f"/api/v1.0/documents/{child_document.id!s}/link-configuration/",
new_data,
format="json",
)
assert response.status_code == 400
assert "link_reach" in response.json()
assert (
"Link reach 'restricted' is not allowed based on parent"
in response.json()["link_reach"][0]
)
def test_api_documents_link_configuration_update_invalid_role_for_reach_validation():
"""
Test the specific validation logic that checks if link_role is allowed for link_reach.
This tests the code section that validates allowed_roles from get_select_options.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
parent_document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.AUTHENTICATED,
link_role=models.LinkRoleChoices.EDITOR,
)
child_document = factories.DocumentFactory(
parent=parent_document,
link_reach=models.LinkReachChoices.RESTRICTED,
link_role=models.LinkRoleChoices.READER,
)
factories.UserDocumentAccessFactory(
document=child_document, user=user, role=models.RoleChoices.OWNER
)
new_data = {
"link_reach": models.LinkReachChoices.AUTHENTICATED,
"link_role": models.LinkRoleChoices.READER, # This should be rejected
}
response = client.put(
f"/api/v1.0/documents/{child_document.id!s}/link-configuration/",
new_data,
format="json",
)
assert response.status_code == 400
assert "link_role" in response.json()
error_message = response.json()["link_role"][0]
assert (
"Link role 'reader' is not allowed for link reach 'authenticated'"
in error_message
)
assert "Allowed roles: editor" in error_message

View File

@@ -69,7 +69,6 @@ def test_api_documents_list_format():
"computed_link_role": document.computed_link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"deleted_at": None,
"depth": 1,
"excerpt": document.excerpt,
"is_favorite": True,

View File

@@ -70,7 +70,6 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"deleted_at": None,
"depth": 1,
"excerpt": document.excerpt,
"is_favorite": False,
@@ -145,7 +144,6 @@ def test_api_documents_retrieve_anonymous_public_parent():
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"deleted_at": None,
"depth": 3,
"excerpt": document.excerpt,
"is_favorite": False,
@@ -254,7 +252,6 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 1,
"deleted_at": None,
"excerpt": document.excerpt,
"is_favorite": False,
"link_reach": reach,
@@ -336,7 +333,6 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 3,
"deleted_at": None,
"excerpt": document.excerpt,
"is_favorite": False,
"link_reach": document.link_reach,
@@ -450,7 +446,6 @@ def test_api_documents_retrieve_authenticated_related_direct():
"content": document.content,
"creator": str(document.creator.id),
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"deleted_at": None,
"depth": 1,
"excerpt": document.excerpt,
"is_favorite": False,
@@ -533,7 +528,6 @@ def test_api_documents_retrieve_authenticated_related_parent():
"creator": str(document.creator.id),
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"depth": 3,
"deleted_at": None,
"excerpt": document.excerpt,
"is_favorite": False,
"link_reach": "restricted",
@@ -689,7 +683,6 @@ def test_api_documents_retrieve_authenticated_related_team_members(
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"deleted_at": None,
"depth": 1,
"excerpt": document.excerpt,
"is_favorite": False,
@@ -756,7 +749,6 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"deleted_at": None,
"depth": 1,
"excerpt": document.excerpt,
"is_favorite": False,
@@ -823,7 +815,6 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"deleted_at": None,
"depth": 1,
"excerpt": document.excerpt,
"is_favorite": False,

View File

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

View File

@@ -48,11 +48,11 @@ def test_api_documents_trashbin_format():
other_users = factories.UserFactory.create_batch(3)
document = factories.DocumentFactory(
deleted_at=timezone.now(),
users=factories.UserFactory.create_batch(2),
favorited_by=[user, *other_users],
link_traces=other_users,
)
document.soft_delete()
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
response = client.get("/api/v1.0/documents/trashbin/")
@@ -70,40 +70,40 @@ def test_api_documents_trashbin_format():
assert results[0] == {
"id": str(document.id),
"abilities": {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"can_edit": False,
"children_create": False,
"children_list": False,
"collaboration_auth": False,
"descendants": False,
"cors_proxy": False,
"content": False,
"destroy": False,
"duplicate": False,
"favorite": False,
"invite_owner": False,
"link_configuration": False,
"accesses_manage": True,
"accesses_view": True,
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"can_edit": True,
"children_create": True,
"children_list": True,
"collaboration_auth": True,
"descendants": True,
"cors_proxy": True,
"content": True,
"destroy": True,
"duplicate": True,
"favorite": True,
"invite_owner": True,
"link_configuration": True,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": None,
},
"mask": False,
"media_auth": False,
"media_check": False,
"mask": True,
"media_auth": True,
"media_check": True,
"move": False, # Can't move a deleted document
"partial_update": False,
"partial_update": True,
"restore": True,
"retrieve": True,
"tree": True,
"update": False,
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
"update": True,
"versions_destroy": True,
"versions_list": True,
"versions_retrieve": True,
},
"ancestors_link_reach": None,
"ancestors_link_role": None,
@@ -113,7 +113,6 @@ def test_api_documents_trashbin_format():
"creator": str(document.creator.id),
"depth": 1,
"excerpt": document.excerpt,
"deleted_at": document.ancestors_deleted_at.isoformat().replace("+00:00", "Z"),
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses_ancestors": 0,
@@ -166,10 +165,10 @@ def test_api_documents_trashbin_authenticated_direct(django_assert_num_queries):
expected_ids = {str(document1.id), str(document2.id), str(document3.id)}
with django_assert_num_queries(11):
with django_assert_num_queries(10):
response = client.get("/api/v1.0/documents/trashbin/")
with django_assert_num_queries(5):
with django_assert_num_queries(4):
response = client.get("/api/v1.0/documents/trashbin/")
assert response.status_code == 200
@@ -208,10 +207,10 @@ def test_api_documents_trashbin_authenticated_via_team(
expected_ids = {str(deleted_document_team1.id), str(deleted_document_team2.id)}
with django_assert_num_queries(8):
with django_assert_num_queries(7):
response = client.get("/api/v1.0/documents/trashbin/")
with django_assert_num_queries(4):
with django_assert_num_queries(3):
response = client.get("/api/v1.0/documents/trashbin/")
assert response.status_code == 200
@@ -293,29 +292,3 @@ def test_api_documents_trashbin_distinct():
content = response.json()
assert len(content["results"]) == 1
assert content["results"][0]["id"] == str(document.id)
def test_api_documents_trashbin_empty_queryset_bug():
"""
Test that users with no owner role don't see documents.
"""
# Create a new user with no owner access to any document
new_user = factories.UserFactory()
client = APIClient()
client.force_login(new_user)
# Create some deleted documents owned by other users
other_user = factories.UserFactory()
item1 = factories.DocumentFactory(users=[(other_user, "owner")])
item1.soft_delete()
item2 = factories.DocumentFactory(users=[(other_user, "owner")])
item2.soft_delete()
item3 = factories.DocumentFactory(users=[(other_user, "owner")])
item3.soft_delete()
response = client.get("/api/v1.0/documents/trashbin/")
assert response.status_code == 200
content = response.json()
assert content["count"] == 0
assert len(content["results"]) == 0

View File

@@ -50,7 +50,6 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
),
"creator": str(child.creator.id),
"depth": 3,
"deleted_at": None,
"excerpt": child.excerpt,
"id": str(child.id),
"is_favorite": False,
@@ -74,7 +73,6 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 2,
"deleted_at": None,
"excerpt": document.excerpt,
"id": str(document.id),
"is_favorite": False,
@@ -98,7 +96,6 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
"created_at": sibling1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(sibling1.creator.id),
"depth": 2,
"deleted_at": None,
"excerpt": sibling1.excerpt,
"id": str(sibling1.id),
"is_favorite": False,
@@ -122,7 +119,6 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
"created_at": sibling2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(sibling2.creator.id),
"depth": 2,
"deleted_at": None,
"excerpt": sibling2.excerpt,
"id": str(sibling2.id),
"is_favorite": False,
@@ -142,7 +138,6 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(parent.creator.id),
"depth": 1,
"deleted_at": None,
"excerpt": parent.excerpt,
"id": str(parent.id),
"is_favorite": False,
@@ -215,7 +210,6 @@ def test_api_documents_tree_list_anonymous_public_parent():
),
"creator": str(child.creator.id),
"depth": 5,
"deleted_at": None,
"excerpt": child.excerpt,
"id": str(child.id),
"is_favorite": False,
@@ -239,7 +233,6 @@ def test_api_documents_tree_list_anonymous_public_parent():
),
"creator": str(document.creator.id),
"depth": 4,
"deleted_at": None,
"excerpt": document.excerpt,
"id": str(document.id),
"is_favorite": False,
@@ -267,7 +260,6 @@ def test_api_documents_tree_list_anonymous_public_parent():
),
"creator": str(document_sibling.creator.id),
"depth": 4,
"deleted_at": None,
"excerpt": document_sibling.excerpt,
"id": str(document_sibling.id),
"is_favorite": False,
@@ -289,7 +281,6 @@ def test_api_documents_tree_list_anonymous_public_parent():
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(parent.creator.id),
"depth": 3,
"deleted_at": None,
"excerpt": parent.excerpt,
"id": str(parent.id),
"is_favorite": False,
@@ -315,7 +306,6 @@ def test_api_documents_tree_list_anonymous_public_parent():
),
"creator": str(parent_sibling.creator.id),
"depth": 3,
"deleted_at": None,
"excerpt": parent_sibling.excerpt,
"id": str(parent_sibling.id),
"is_favorite": False,
@@ -337,7 +327,6 @@ def test_api_documents_tree_list_anonymous_public_parent():
"created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_parent.creator.id),
"depth": 2,
"deleted_at": None,
"excerpt": grand_parent.excerpt,
"id": str(grand_parent.id),
"is_favorite": False,
@@ -417,7 +406,6 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
),
"creator": str(child.creator.id),
"depth": 3,
"deleted_at": None,
"excerpt": child.excerpt,
"id": str(child.id),
"is_favorite": False,
@@ -439,7 +427,6 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 2,
"deleted_at": None,
"excerpt": document.excerpt,
"id": str(document.id),
"is_favorite": False,
@@ -463,7 +450,6 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
"created_at": sibling.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(sibling.creator.id),
"depth": 2,
"deleted_at": None,
"excerpt": sibling.excerpt,
"id": str(sibling.id),
"is_favorite": False,
@@ -483,7 +469,6 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(parent.creator.id),
"depth": 1,
"deleted_at": None,
"excerpt": parent.excerpt,
"id": str(parent.id),
"is_favorite": False,
@@ -561,7 +546,6 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
),
"creator": str(child.creator.id),
"depth": 5,
"deleted_at": None,
"excerpt": child.excerpt,
"id": str(child.id),
"is_favorite": False,
@@ -585,7 +569,6 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
),
"creator": str(document.creator.id),
"depth": 4,
"deleted_at": None,
"excerpt": document.excerpt,
"id": str(document.id),
"is_favorite": False,
@@ -613,7 +596,6 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
),
"creator": str(document_sibling.creator.id),
"depth": 4,
"deleted_at": None,
"excerpt": document_sibling.excerpt,
"id": str(document_sibling.id),
"is_favorite": False,
@@ -635,7 +617,6 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(parent.creator.id),
"depth": 3,
"deleted_at": None,
"excerpt": parent.excerpt,
"id": str(parent.id),
"is_favorite": False,
@@ -661,7 +642,6 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
),
"creator": str(parent_sibling.creator.id),
"depth": 3,
"deleted_at": None,
"excerpt": parent_sibling.excerpt,
"id": str(parent_sibling.id),
"is_favorite": False,
@@ -683,7 +663,6 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
"created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_parent.creator.id),
"depth": 2,
"deleted_at": None,
"excerpt": grand_parent.excerpt,
"id": str(grand_parent.id),
"is_favorite": False,
@@ -765,7 +744,6 @@ def test_api_documents_tree_list_authenticated_related_direct():
),
"creator": str(child.creator.id),
"depth": 3,
"deleted_at": None,
"excerpt": child.excerpt,
"id": str(child.id),
"is_favorite": False,
@@ -787,7 +765,6 @@ def test_api_documents_tree_list_authenticated_related_direct():
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 2,
"deleted_at": None,
"excerpt": document.excerpt,
"id": str(document.id),
"is_favorite": False,
@@ -811,7 +788,6 @@ def test_api_documents_tree_list_authenticated_related_direct():
"created_at": sibling.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(sibling.creator.id),
"depth": 2,
"deleted_at": None,
"excerpt": sibling.excerpt,
"id": str(sibling.id),
"is_favorite": False,
@@ -831,7 +807,6 @@ def test_api_documents_tree_list_authenticated_related_direct():
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(parent.creator.id),
"depth": 1,
"deleted_at": None,
"excerpt": parent.excerpt,
"id": str(parent.id),
"is_favorite": False,
@@ -913,7 +888,6 @@ def test_api_documents_tree_list_authenticated_related_parent():
),
"creator": str(child.creator.id),
"depth": 5,
"deleted_at": None,
"excerpt": child.excerpt,
"id": str(child.id),
"is_favorite": False,
@@ -937,7 +911,6 @@ def test_api_documents_tree_list_authenticated_related_parent():
),
"creator": str(document.creator.id),
"depth": 4,
"deleted_at": None,
"excerpt": document.excerpt,
"id": str(document.id),
"is_favorite": False,
@@ -965,7 +938,6 @@ def test_api_documents_tree_list_authenticated_related_parent():
),
"creator": str(document_sibling.creator.id),
"depth": 4,
"deleted_at": None,
"excerpt": document_sibling.excerpt,
"id": str(document_sibling.id),
"is_favorite": False,
@@ -987,7 +959,6 @@ def test_api_documents_tree_list_authenticated_related_parent():
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(parent.creator.id),
"depth": 3,
"deleted_at": None,
"excerpt": parent.excerpt,
"id": str(parent.id),
"is_favorite": False,
@@ -1013,7 +984,6 @@ def test_api_documents_tree_list_authenticated_related_parent():
),
"creator": str(parent_sibling.creator.id),
"depth": 3,
"deleted_at": None,
"excerpt": parent_sibling.excerpt,
"id": str(parent_sibling.id),
"is_favorite": False,
@@ -1035,7 +1005,6 @@ def test_api_documents_tree_list_authenticated_related_parent():
"created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_parent.creator.id),
"depth": 2,
"deleted_at": None,
"excerpt": grand_parent.excerpt,
"id": str(grand_parent.id),
"is_favorite": False,
@@ -1125,7 +1094,6 @@ def test_api_documents_tree_list_authenticated_related_team_members(
),
"creator": str(child.creator.id),
"depth": 3,
"deleted_at": None,
"excerpt": child.excerpt,
"id": str(child.id),
"is_favorite": False,
@@ -1147,7 +1115,6 @@ def test_api_documents_tree_list_authenticated_related_team_members(
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 2,
"deleted_at": None,
"excerpt": document.excerpt,
"id": str(document.id),
"is_favorite": False,
@@ -1171,7 +1138,6 @@ def test_api_documents_tree_list_authenticated_related_team_members(
"created_at": sibling.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(sibling.creator.id),
"depth": 2,
"deleted_at": None,
"excerpt": sibling.excerpt,
"id": str(sibling.id),
"is_favorite": False,
@@ -1191,7 +1157,6 @@ def test_api_documents_tree_list_authenticated_related_team_members(
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(parent.creator.id),
"depth": 1,
"deleted_at": None,
"excerpt": parent.excerpt,
"id": str(parent.id),
"is_favorite": False,
@@ -1205,56 +1170,3 @@ def test_api_documents_tree_list_authenticated_related_team_members(
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": access.role,
}
def test_api_documents_tree_list_deleted_document():
"""
Tree of a deleted document should only be accessible to the owner.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
parent = factories.DocumentFactory(link_reach="public")
document, _ = factories.DocumentFactory.create_batch(2, parent=parent)
factories.DocumentFactory(link_reach="public", parent=document)
document.soft_delete()
response = client.get(f"/api/v1.0/documents/{document.id!s}/tree/")
assert response.status_code == 403
def test_api_documents_tree_list_deleted_document_owner(django_assert_num_queries):
"""
Tree of a deleted document should only be accessible to the owner.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
parent = factories.DocumentFactory(link_reach="public", users=[(user, "owner")])
document, _ = factories.DocumentFactory.create_batch(2, parent=parent)
child = factories.DocumentFactory(parent=document)
document.soft_delete()
document.refresh_from_db()
child.refresh_from_db()
with django_assert_num_queries(9):
client.get(f"/api/v1.0/documents/{document.id!s}/tree/")
with django_assert_num_queries(5):
response = client.get(f"/api/v1.0/documents/{document.id!s}/tree/")
assert response.status_code == 200
content = response.json()
assert content["id"] == str(document.id)
assert content["deleted_at"] == document.deleted_at.isoformat().replace(
"+00:00", "Z"
)
assert len(content["children"]) == 1
assert content["children"][0]["id"] == str(child.id)
assert content["children"][0][
"deleted_at"
] == child.ancestors_deleted_at.isoformat().replace("+00:00", "Z")

View File

@@ -42,7 +42,6 @@ def test_api_config(is_authenticated):
response = client.get("/api/v1.0/config/")
assert response.status_code == HTTP_200_OK
assert response.json() == {
"AI_FEATURE_ENABLED": False,
"COLLABORATION_WS_URL": "http://testcollab/",
"COLLABORATION_WS_NOT_CONNECTED_READY_ONLY": True,
"CRISP_WEBSITE_ID": "123",
@@ -61,7 +60,7 @@ def test_api_config(is_authenticated):
"MEDIA_BASE_URL": "http://testserver/",
"POSTHOG_KEY": {"id": "132456", "host": "https://eu.i.posthog-test.com"},
"SENTRY_DSN": "https://sentry.test/123",
"TRASHBIN_CUTOFF_DAYS": 30,
"AI_FEATURE_ENABLED": False,
"theme_customization": {},
}
policy_list = sorted(response.headers["Content-Security-Policy"].split("; "))

View File

@@ -375,42 +375,8 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
document.soft_delete()
document.refresh_from_db()
assert document.get_abilities(user) == {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"can_edit": False,
"children_create": False,
"children_list": False,
"collaboration_auth": False,
"descendants": False,
"cors_proxy": False,
"content": False,
"destroy": False,
"duplicate": False,
"favorite": False,
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": None,
},
"mask": False,
"media_auth": False,
"media_check": False,
"move": False,
"partial_update": False,
"restore": True,
"retrieve": True,
"tree": True,
"update": False,
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
}
expected_abilities["move"] = False
assert document.get_abilities(user) == expected_abilities
@override_settings(

View File

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

View File

@@ -8,7 +8,7 @@ from django.core.exceptions import ValidationError
import pytest
from core import factories, models
from core import factories
pytestmark = pytest.mark.django_db
@@ -66,33 +66,3 @@ def test_models_users_sub_validator(sub, is_valid):
match=("Enter a valid sub. This value should be ASCII only."),
):
user.full_clean()
def test_modes_users_convert_valid_invitations():
"""
The "convert_valid_invitations" method should convert valid invitations to document accesses.
"""
email = "test@example.com"
document = factories.DocumentFactory()
other_document = factories.DocumentFactory()
invitation_document = factories.InvitationFactory(email=email, document=document)
invitation_other_document = factories.InvitationFactory(
email="Test@example.coM", document=other_document
)
other_email_invitation = factories.InvitationFactory(
email="pre_test@example.com", document=document
)
assert document.accesses.count() == 0
assert other_document.accesses.count() == 0
user = factories.UserFactory(email=email)
assert document.accesses.filter(user=user).count() == 1
assert other_document.accesses.filter(user=user).count() == 1
assert not models.Invitation.objects.filter(id=invitation_document.id).exists()
assert not models.Invitation.objects.filter(
id=invitation_other_document.id
).exists()
assert models.Invitation.objects.filter(id=other_email_invitation.id).exists()

View File

@@ -1,701 +0,0 @@
"""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.converter_services import (
ServiceUnavailableError as YProviderServiceUnavailableError,
)
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")
@patch("core.services.converter_services.YdocConverter.convert")
def test_services_search_indexers_serialize_document_returns_expected_json(
mock_convert,
):
"""
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)
markdown_content = (
f"## {document.title}\n{utils.base64_yjs_to_text(document.content)}"
)
mock_convert.return_value = markdown_content
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 mock_convert.call_count == 1
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": markdown_content,
"mimetype": "text/markdown",
"created_at": document.created_at.isoformat(),
"updated_at": document.updated_at.isoformat(),
"reach": document.link_reach,
"size": len(markdown_content),
"is_active": True,
}
@pytest.mark.usefixtures("indexer_settings")
@patch("core.services.converter_services.YdocConverter.convert")
def test_services_search_indexers_serialize_document_no_converter(
mock_convert,
):
"""
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)
mock_convert.side_effect = YProviderServiceUnavailableError()
text_content = utils.base64_yjs_to_text(document.content)
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 mock_convert.call_count == 1
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": text_content,
"mimetype": "text/markdown",
"created_at": document.created_at.isoformat(),
"updated_at": document.updated_at.isoformat(),
"reach": document.link_reach,
"size": len(text_content),
"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

View File

@@ -75,28 +75,3 @@ 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"},
}

View File

@@ -2,7 +2,6 @@
import base64
import re
from collections import defaultdict
import pycrdt
from bs4 import BeautifulSoup
@@ -10,27 +9,6 @@ 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.

View File

@@ -1,19 +1,16 @@
# 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
@@ -30,16 +27,6 @@ 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."""
@@ -61,7 +48,7 @@ class BulkQueue:
self.queue[objects[0]._meta.model.__name__] = [] # noqa: SLF001
def push(self, obj):
"""Add a model instance to queue so that it gets created in bulk."""
"""Add a model instance to queue to that it gets created in bulk."""
objects = self.queue[obj._meta.model.__name__] # noqa: SLF001
objects.append(obj)
if len(objects) > self.BATCH_SIZE:
@@ -152,19 +139,17 @@ 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))
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),
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),
)
)
document.save_content(get_ydoc_for_text(f"Content for {title:s}"))
queue.push(document)
queue.flush()

View File

@@ -99,31 +99,6 @@ 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")
@@ -515,7 +490,7 @@ class Base(Configuration):
environ_prefix=None,
)
THEME_CUSTOMIZATION_CACHE_TIMEOUT = values.IntegerValue(
THEME_CUSTOMIZATION_CACHE_TIMEOUT = values.Value(
60 * 60 * 24,
environ_name="THEME_CUSTOMIZATION_CACHE_TIMEOUT",
environ_prefix=None,

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"POT-Creation-Date: 2025-09-10 14:29+0000\n"
"PO-Revision-Date: 2025-09-12 09:50\n"
"Last-Translator: \n"
"Language-Team: Breton\n"
"Language: br_FR\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:36 core/admin.py:36
#: build/lib/core/admin.py:37 core/admin.py:37
msgid "Personal info"
msgstr "Titouroù personel"
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr "Aotreoù"
#: build/lib/core/admin.py:61 core/admin.py:61
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
msgstr "Deiziadoù a-bouez"
#: build/lib/core/admin.py:147 core/admin.py:147
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr "Gwezennadur"
@@ -50,36 +50,27 @@ msgstr "Kuzhet"
msgid "Favorite"
msgstr "Sinedoù"
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
#: build/lib/core/api/serializers.py:487 core/api/serializers.py:487
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:491 core/api/serializers.py:491
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
msgid "This field is required."
msgstr "Ar vaezienn-mañ a zo rekis."
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/serializers.py:693 core/api/serializers.py:693
#: build/lib/core/api/serializers.py:628 core/api/serializers.py:628
msgid "Body"
msgstr "Korf"
#: build/lib/core/api/serializers.py:696 core/api/serializers.py:696
#: build/lib/core/api/serializers.py:631 core/api/serializers.py:631
msgid "Body type"
msgstr "Doare korf"
#: build/lib/core/api/serializers.py:702 core/api/serializers.py:702
#: build/lib/core/api/serializers.py:637 core/api/serializers.py:637
msgid "Format"
msgstr "Stumm"
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#: build/lib/core/api/viewsets.py:965 core/api/viewsets.py:965
#, python-brace-format
msgid "copy of {title}"
msgstr "eilenn {title}"
@@ -178,247 +169,247 @@ msgstr ""
msgid "full name"
msgstr "anv klok"
#: build/lib/core/models.py:152 core/models.py:152
#: build/lib/core/models.py:151 core/models.py:151
msgid "short name"
msgstr "anv berr"
#: build/lib/core/models.py:155 core/models.py:155
#: build/lib/core/models.py:153 core/models.py:153
msgid "identity email address"
msgstr "postel identelezh"
#: build/lib/core/models.py:160 core/models.py:160
#: build/lib/core/models.py:158 core/models.py:158
msgid "admin email address"
msgstr "postel ar merour"
#: build/lib/core/models.py:167 core/models.py:167
#: build/lib/core/models.py:165 core/models.py:165
msgid "language"
msgstr "yezh"
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:166 core/models.py:166
msgid "The language in which the user wants to see the interface."
msgstr "Ar yezh a vo implijet evit etrefas an implijer."
#: build/lib/core/models.py:176 core/models.py:176
#: build/lib/core/models.py:174 core/models.py:174
msgid "The timezone in which the user wants to see times."
msgstr "Ar gwerzhid-eur a vo implijet evit etrefas an implijer."
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:177 core/models.py:177
msgid "device"
msgstr "trevnad"
#: build/lib/core/models.py:181 core/models.py:181
#: build/lib/core/models.py:179 core/models.py:179
msgid "Whether the user is a device or a real user."
msgstr "Pe vefe an implijer un aparailh pe un implijer gwirion."
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:182 core/models.py:182
msgid "staff status"
msgstr "statud ar skipailh"
#: build/lib/core/models.py:186 core/models.py:186
#: build/lib/core/models.py:184 core/models.py:184
msgid "Whether the user can log into this admin site."
msgstr "Ma c'hall an implijer kevreañ ouzh al lec'hienn verañ-mañ."
#: build/lib/core/models.py:189 core/models.py:189
#: build/lib/core/models.py:187 core/models.py:187
msgid "active"
msgstr "oberiant"
#: build/lib/core/models.py:192 core/models.py:192
#: build/lib/core/models.py:190 core/models.py:190
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Ma rank bezañ tretet an implijer-mañ evel oberiant. Diziuzit an dra-mañ e-plas dilemel kontoù."
#: build/lib/core/models.py:204 core/models.py:204
#: build/lib/core/models.py:202 core/models.py:202
msgid "user"
msgstr "implijer"
#: build/lib/core/models.py:205 core/models.py:205
#: build/lib/core/models.py:203 core/models.py:203
msgid "users"
msgstr "implijerien"
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
#: build/lib/core/models.py:359 build/lib/core/models.py:1281
#: core/models.py:359 core/models.py:1281
msgid "title"
msgstr "titl"
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:360 core/models.py:360
msgid "excerpt"
msgstr "bomm"
#: build/lib/core/models.py:411 core/models.py:411
#: build/lib/core/models.py:409 core/models.py:409
msgid "Document"
msgstr "Restr"
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:410 core/models.py:410
msgid "Documents"
msgstr "Restroù"
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
#: build/lib/core/models.py:422 build/lib/core/models.py:819 core/models.py:422
#: core/models.py:819
msgid "Untitled Document"
msgstr "Restr hep titl"
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:854 core/models.py:854
#, 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:858 core/models.py:858
#, 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:864 core/models.py:864
#, 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:964 core/models.py:964
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:965 core/models.py:965
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:971 core/models.py:971
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:994 core/models.py:994
msgid "Document favorite"
msgstr "Restr muiañ-karet"
#: build/lib/core/models.py:998 core/models.py:998
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorites"
msgstr "Restroù muiañ-karet"
#: build/lib/core/models.py:1004 core/models.py:1004
#: build/lib/core/models.py:1001 core/models.py:1001
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:1023 core/models.py:1023
msgid "Document/user relation"
msgstr "Liamm restr/implijer"
#: build/lib/core/models.py:1027 core/models.py:1027
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relations"
msgstr "Liammoù restr/implijer"
#: build/lib/core/models.py:1033 core/models.py:1033
#: build/lib/core/models.py:1030 core/models.py:1030
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:1036 core/models.py:1036
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:1370
#: core/models.py:1045 core/models.py:1370
#: build/lib/core/models.py:1042 build/lib/core/models.py:1367
#: core/models.py:1042 core/models.py:1367
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:1188 core/models.py:1188
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:1189 core/models.py:1189
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:1195 core/models.py:1195
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:1263 core/models.py:1263
#: build/lib/core/models.py:1260 core/models.py:1260
#, 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:1267 core/models.py:1267
#: build/lib/core/models.py:1264 core/models.py:1264
#, 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:1273 core/models.py:1273
#: build/lib/core/models.py:1270 core/models.py:1270
#, 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:1285 core/models.py:1285
#: build/lib/core/models.py:1282 core/models.py:1282
msgid "description"
msgstr "deskrivadur"
#: build/lib/core/models.py:1286 core/models.py:1286
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "code"
msgstr "kod"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "public"
msgstr "publik"
#: build/lib/core/models.py:1291 core/models.py:1291
#: build/lib/core/models.py:1288 core/models.py:1288
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:1297 core/models.py:1297
#: build/lib/core/models.py:1294 core/models.py:1294
msgid "Template"
msgstr "Patrom"
#: build/lib/core/models.py:1298 core/models.py:1298
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Templates"
msgstr "Patromoù"
#: build/lib/core/models.py:1351 core/models.py:1351
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Template/user relation"
msgstr "Liamm patrom/implijer"
#: build/lib/core/models.py:1352 core/models.py:1352
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relations"
msgstr "Liammoù patrom/implijer"
#: build/lib/core/models.py:1358 core/models.py:1358
#: build/lib/core/models.py:1355 core/models.py:1355
msgid "This user is already in this template."
msgstr "An implijer-mañ a zo dija er patrom-mañ."
#: build/lib/core/models.py:1364 core/models.py:1364
#: build/lib/core/models.py:1361 core/models.py:1361
msgid "This team is already in this template."
msgstr "Ar skipailh-mañ a zo dija er patrom-mañ."
#: build/lib/core/models.py:1441 core/models.py:1441
#: build/lib/core/models.py:1438 core/models.py:1438
msgid "email address"
msgstr "postel"
#: build/lib/core/models.py:1460 core/models.py:1460
#: build/lib/core/models.py:1457 core/models.py:1457
msgid "Document invitation"
msgstr "Pedadenn d'ur restr"
#: build/lib/core/models.py:1461 core/models.py:1461
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitations"
msgstr "Pedadennoù d'ur restr"
#: build/lib/core/models.py:1481 core/models.py:1481
#: build/lib/core/models.py:1478 core/models.py:1478
msgid "This email is already associated to a registered user."
msgstr "Ar postel-mañ a zo liammet ouzh un implijer enskrivet."
#: core/templates/mail/html/template.html:153
#: core/templates/mail/html/template.html:162
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr "Logo ar postel"
#: core/templates/mail/html/template.html:200
#: core/templates/mail/html/template.html:209
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr "Digeriñ"
#: core/templates/mail/html/template.html:217
#: core/templates/mail/html/template.html:226
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr " Docs, hoc'h ostilh nevez ret-holl evit aozañ, rannañ ha kenlabourat war ar restr e skipailh. "
#: core/templates/mail/html/template.html:224
#: core/templates/mail/html/template.html:233
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"POT-Creation-Date: 2025-09-10 14:29+0000\n"
"PO-Revision-Date: 2025-09-12 09:50\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de_DE\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:36 core/admin.py:36
#: build/lib/core/admin.py:37 core/admin.py:37
msgid "Personal info"
msgstr "Persönliche Daten"
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr "Berechtigungen"
#: build/lib/core/admin.py:61 core/admin.py:61
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
msgstr "Wichtige Daten"
#: build/lib/core/admin.py:147 core/admin.py:147
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr "Baumstruktur"
@@ -50,36 +50,27 @@ msgstr ""
msgid "Favorite"
msgstr "Favorit"
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
#: build/lib/core/api/serializers.py:487 core/api/serializers.py:487
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:491 core/api/serializers.py:491
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
msgid "This field is required."
msgstr ""
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/serializers.py:693 core/api/serializers.py:693
#: build/lib/core/api/serializers.py:628 core/api/serializers.py:628
msgid "Body"
msgstr "Inhalt"
#: build/lib/core/api/serializers.py:696 core/api/serializers.py:696
#: build/lib/core/api/serializers.py:631 core/api/serializers.py:631
msgid "Body type"
msgstr "Typ"
#: build/lib/core/api/serializers.py:702 core/api/serializers.py:702
#: build/lib/core/api/serializers.py:637 core/api/serializers.py:637
msgid "Format"
msgstr "Format"
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#: build/lib/core/api/viewsets.py:965 core/api/viewsets.py:965
#, python-brace-format
msgid "copy of {title}"
msgstr "Kopie von {title}"
@@ -178,247 +169,247 @@ msgstr ""
msgid "full name"
msgstr "Name"
#: build/lib/core/models.py:152 core/models.py:152
#: build/lib/core/models.py:151 core/models.py:151
msgid "short name"
msgstr "Kurzbezeichnung"
#: build/lib/core/models.py:155 core/models.py:155
#: build/lib/core/models.py:153 core/models.py:153
msgid "identity email address"
msgstr "Identitäts-E-Mail-Adresse"
#: build/lib/core/models.py:160 core/models.py:160
#: build/lib/core/models.py:158 core/models.py:158
msgid "admin email address"
msgstr "Admin E-Mail-Adresse"
#: build/lib/core/models.py:167 core/models.py:167
#: build/lib/core/models.py:165 core/models.py:165
msgid "language"
msgstr "Sprache"
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:166 core/models.py:166
msgid "The language in which the user wants to see the interface."
msgstr "Die Sprache, in der der Benutzer die Benutzeroberfläche sehen möchte."
#: build/lib/core/models.py:176 core/models.py:176
#: build/lib/core/models.py:174 core/models.py:174
msgid "The timezone in which the user wants to see times."
msgstr "Die Zeitzone, in der der Nutzer Zeiten sehen möchte."
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:177 core/models.py:177
msgid "device"
msgstr "Gerät"
#: build/lib/core/models.py:181 core/models.py:181
#: build/lib/core/models.py:179 core/models.py:179
msgid "Whether the user is a device or a real user."
msgstr "Ob der Benutzer ein Gerät oder ein echter Benutzer ist."
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:182 core/models.py:182
msgid "staff status"
msgstr "Status des Teammitgliedes"
#: build/lib/core/models.py:186 core/models.py:186
#: build/lib/core/models.py:184 core/models.py:184
msgid "Whether the user can log into this admin site."
msgstr "Gibt an, ob der Benutzer sich in diese Admin-Seite einloggen kann."
#: build/lib/core/models.py:189 core/models.py:189
#: build/lib/core/models.py:187 core/models.py:187
msgid "active"
msgstr "aktiviert"
#: build/lib/core/models.py:192 core/models.py:192
#: build/lib/core/models.py:190 core/models.py:190
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Ob dieser Benutzer als aktiviert behandelt werden soll. Deaktivieren Sie diese Option, anstatt Konten zu löschen."
#: build/lib/core/models.py:204 core/models.py:204
#: build/lib/core/models.py:202 core/models.py:202
msgid "user"
msgstr "Benutzer"
#: build/lib/core/models.py:205 core/models.py:205
#: build/lib/core/models.py:203 core/models.py:203
msgid "users"
msgstr "Benutzer"
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
#: build/lib/core/models.py:359 build/lib/core/models.py:1281
#: core/models.py:359 core/models.py:1281
msgid "title"
msgstr "Titel"
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:360 core/models.py:360
msgid "excerpt"
msgstr "Auszug"
#: build/lib/core/models.py:411 core/models.py:411
#: build/lib/core/models.py:409 core/models.py:409
msgid "Document"
msgstr "Dokument"
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:410 core/models.py:410
msgid "Documents"
msgstr "Dokumente"
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
#: build/lib/core/models.py:422 build/lib/core/models.py:819 core/models.py:422
#: core/models.py:819
msgid "Untitled Document"
msgstr "Unbenanntes Dokument"
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:854 core/models.py:854
#, 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:858 core/models.py:858
#, 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:864 core/models.py:864
#, 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:964 core/models.py:964
msgid "Document/user link trace"
msgstr "Dokument/Benutzer Linkverfolgung"
#: build/lib/core/models.py:968 core/models.py:968
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link traces"
msgstr "Dokument/Benutzer Linkverfolgung"
#: build/lib/core/models.py:974 core/models.py:974
#: build/lib/core/models.py:971 core/models.py:971
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:994 core/models.py:994
msgid "Document favorite"
msgstr "Dokumentenfavorit"
#: build/lib/core/models.py:998 core/models.py:998
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorites"
msgstr "Dokumentfavoriten"
#: build/lib/core/models.py:1004 core/models.py:1004
#: build/lib/core/models.py:1001 core/models.py:1001
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:1023 core/models.py:1023
msgid "Document/user relation"
msgstr "Dokument/Benutzerbeziehung"
#: build/lib/core/models.py:1027 core/models.py:1027
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relations"
msgstr "Dokument/Benutzerbeziehungen"
#: build/lib/core/models.py:1033 core/models.py:1033
#: build/lib/core/models.py:1030 core/models.py:1030
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:1036 core/models.py:1036
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:1370
#: core/models.py:1045 core/models.py:1370
#: build/lib/core/models.py:1042 build/lib/core/models.py:1367
#: core/models.py:1042 core/models.py:1367
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:1188 core/models.py:1188
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1192 core/models.py:1192
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1195 core/models.py:1195
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1263 core/models.py:1263
#: build/lib/core/models.py:1260 core/models.py:1260
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1267 core/models.py:1267
#: build/lib/core/models.py:1264 core/models.py:1264
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1273 core/models.py:1273
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1282 core/models.py:1282
msgid "description"
msgstr "Beschreibung"
#: build/lib/core/models.py:1286 core/models.py:1286
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "code"
msgstr "Code"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "css"
msgstr "CSS"
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "public"
msgstr "öffentlich"
#: build/lib/core/models.py:1291 core/models.py:1291
#: build/lib/core/models.py:1288 core/models.py:1288
msgid "Whether this template is public for anyone to use."
msgstr "Ob diese Vorlage für jedermann öffentlich ist."
#: build/lib/core/models.py:1297 core/models.py:1297
#: build/lib/core/models.py:1294 core/models.py:1294
msgid "Template"
msgstr "Vorlage"
#: build/lib/core/models.py:1298 core/models.py:1298
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Templates"
msgstr "Vorlagen"
#: build/lib/core/models.py:1351 core/models.py:1351
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Template/user relation"
msgstr "Vorlage/Benutzer-Beziehung"
#: build/lib/core/models.py:1352 core/models.py:1352
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relations"
msgstr "Vorlage/Benutzerbeziehungen"
#: build/lib/core/models.py:1358 core/models.py:1358
#: build/lib/core/models.py:1355 core/models.py:1355
msgid "This user is already in this template."
msgstr "Dieser Benutzer ist bereits in dieser Vorlage."
#: build/lib/core/models.py:1364 core/models.py:1364
#: build/lib/core/models.py:1361 core/models.py:1361
msgid "This team is already in this template."
msgstr "Dieses Team ist bereits in diesem Template."
#: build/lib/core/models.py:1441 core/models.py:1441
#: build/lib/core/models.py:1438 core/models.py:1438
msgid "email address"
msgstr "E-Mail-Adresse"
#: build/lib/core/models.py:1460 core/models.py:1460
#: build/lib/core/models.py:1457 core/models.py:1457
msgid "Document invitation"
msgstr "Einladung zum Dokument"
#: build/lib/core/models.py:1461 core/models.py:1461
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitations"
msgstr "Dokumenteinladungen"
#: build/lib/core/models.py:1481 core/models.py:1481
#: build/lib/core/models.py:1478 core/models.py:1478
msgid "This email is already associated to a registered user."
msgstr "Diese E-Mail ist bereits einem registrierten Benutzer zugeordnet."
#: core/templates/mail/html/template.html:153
#: core/templates/mail/html/template.html:162
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr "Logo-E-Mail"
#: core/templates/mail/html/template.html:200
#: core/templates/mail/html/template.html:209
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr "Öffnen"
#: core/templates/mail/html/template.html:217
#: core/templates/mail/html/template.html:226
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr " Docs, Ihr neues unentbehrliches Werkzeug für die Organisation, den Austausch und die Zusammenarbeit in Ihren Dokumenten als Team. "
#: core/templates/mail/html/template.html:224
#: core/templates/mail/html/template.html:233
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"POT-Creation-Date: 2025-09-10 14:29+0000\n"
"PO-Revision-Date: 2025-09-12 09:50\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:36 core/admin.py:36
#: build/lib/core/admin.py:37 core/admin.py:37
msgid "Personal info"
msgstr ""
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr ""
#: build/lib/core/admin.py:61 core/admin.py:61
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
msgstr ""
#: build/lib/core/admin.py:147 core/admin.py:147
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr ""
@@ -50,36 +50,27 @@ msgstr ""
msgid "Favorite"
msgstr ""
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
#: build/lib/core/api/serializers.py:487 core/api/serializers.py:487
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:491 core/api/serializers.py:491
msgid "You have been granted ownership of a new document:"
msgstr ""
#: build/lib/core/api/serializers.py:536 core/api/serializers.py:536
msgid "This field is required."
msgstr ""
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/serializers.py:693 core/api/serializers.py:693
#: build/lib/core/api/serializers.py:628 core/api/serializers.py:628
msgid "Body"
msgstr ""
#: build/lib/core/api/serializers.py:696 core/api/serializers.py:696
#: build/lib/core/api/serializers.py:631 core/api/serializers.py:631
msgid "Body type"
msgstr ""
#: build/lib/core/api/serializers.py:702 core/api/serializers.py:702
#: build/lib/core/api/serializers.py:637 core/api/serializers.py:637
msgid "Format"
msgstr ""
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#: build/lib/core/api/viewsets.py:965 core/api/viewsets.py:965
#, python-brace-format
msgid "copy of {title}"
msgstr ""
@@ -178,247 +169,247 @@ msgstr ""
msgid "full name"
msgstr ""
#: build/lib/core/models.py:152 core/models.py:152
#: build/lib/core/models.py:151 core/models.py:151
msgid "short name"
msgstr ""
#: build/lib/core/models.py:155 core/models.py:155
#: build/lib/core/models.py:153 core/models.py:153
msgid "identity email address"
msgstr ""
#: build/lib/core/models.py:160 core/models.py:160
#: build/lib/core/models.py:158 core/models.py:158
msgid "admin email address"
msgstr ""
#: build/lib/core/models.py:167 core/models.py:167
#: build/lib/core/models.py:165 core/models.py:165
msgid "language"
msgstr ""
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:166 core/models.py:166
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/core/models.py:176 core/models.py:176
#: build/lib/core/models.py:174 core/models.py:174
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:177 core/models.py:177
msgid "device"
msgstr ""
#: build/lib/core/models.py:181 core/models.py:181
#: build/lib/core/models.py:179 core/models.py:179
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:182 core/models.py:182
msgid "staff status"
msgstr ""
#: build/lib/core/models.py:186 core/models.py:186
#: build/lib/core/models.py:184 core/models.py:184
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/core/models.py:189 core/models.py:189
#: build/lib/core/models.py:187 core/models.py:187
msgid "active"
msgstr ""
#: build/lib/core/models.py:192 core/models.py:192
#: build/lib/core/models.py:190 core/models.py:190
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: build/lib/core/models.py:204 core/models.py:204
#: build/lib/core/models.py:202 core/models.py:202
msgid "user"
msgstr ""
#: build/lib/core/models.py:205 core/models.py:205
#: build/lib/core/models.py:203 core/models.py:203
msgid "users"
msgstr ""
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
#: build/lib/core/models.py:359 build/lib/core/models.py:1281
#: core/models.py:359 core/models.py:1281
msgid "title"
msgstr ""
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:360 core/models.py:360
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:411 core/models.py:411
#: build/lib/core/models.py:409 core/models.py:409
msgid "Document"
msgstr ""
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:410 core/models.py:410
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
#: build/lib/core/models.py:422 build/lib/core/models.py:819 core/models.py:422
#: core/models.py:819
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:854 core/models.py:854
#, 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:858 core/models.py:858
#, 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:864 core/models.py:864
#, 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:964 core/models.py:964
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:968 core/models.py:968
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:974 core/models.py:974
#: build/lib/core/models.py:971 core/models.py:971
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:994 core/models.py:994
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:998 core/models.py:998
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1004 core/models.py:1004
#: build/lib/core/models.py:1001 core/models.py:1001
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:1023 core/models.py:1023
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1027 core/models.py:1027
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1033 core/models.py:1033
#: build/lib/core/models.py:1030 core/models.py:1030
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1039 core/models.py:1039
#: build/lib/core/models.py:1036 core/models.py:1036
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
#: build/lib/core/models.py:1042 build/lib/core/models.py:1367
#: core/models.py:1042 core/models.py:1367
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:1188 core/models.py:1188
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1192 core/models.py:1192
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1195 core/models.py:1195
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1263 core/models.py:1263
#: build/lib/core/models.py:1260 core/models.py:1260
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1267 core/models.py:1267
#: build/lib/core/models.py:1264 core/models.py:1264
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1273 core/models.py:1273
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1282 core/models.py:1282
msgid "description"
msgstr ""
#: build/lib/core/models.py:1286 core/models.py:1286
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "code"
msgstr ""
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "css"
msgstr ""
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "public"
msgstr ""
#: build/lib/core/models.py:1291 core/models.py:1291
#: build/lib/core/models.py:1288 core/models.py:1288
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1297 core/models.py:1297
#: build/lib/core/models.py:1294 core/models.py:1294
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1298 core/models.py:1298
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1351 core/models.py:1351
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1352 core/models.py:1352
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1358 core/models.py:1358
#: build/lib/core/models.py:1355 core/models.py:1355
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1364 core/models.py:1364
#: build/lib/core/models.py:1361 core/models.py:1361
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1441 core/models.py:1441
#: build/lib/core/models.py:1438 core/models.py:1438
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1460 core/models.py:1460
#: build/lib/core/models.py:1457 core/models.py:1457
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1461 core/models.py:1461
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1481 core/models.py:1481
#: build/lib/core/models.py:1478 core/models.py:1478
msgid "This email is already associated to a registered user."
msgstr ""
#: core/templates/mail/html/template.html:153
#: core/templates/mail/html/template.html:162
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr ""
#: core/templates/mail/html/template.html:200
#: core/templates/mail/html/template.html:209
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr ""
#: core/templates/mail/html/template.html:217
#: core/templates/mail/html/template.html:226
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr ""
#: core/templates/mail/html/template.html:224
#: core/templates/mail/html/template.html:233
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"POT-Creation-Date: 2025-09-10 14:29+0000\n"
"PO-Revision-Date: 2025-09-12 09:50\n"
"Last-Translator: \n"
"Language-Team: Spanish\n"
"Language: es_ES\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:36 core/admin.py:36
#: build/lib/core/admin.py:37 core/admin.py:37
msgid "Personal info"
msgstr "Información Personal"
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr "Permisos"
#: build/lib/core/admin.py:61 core/admin.py:61
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
msgstr "Fechas importantes"
#: build/lib/core/admin.py:147 core/admin.py:147
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr "Estructura en árbol"
@@ -50,36 +50,27 @@ msgstr ""
msgid "Favorite"
msgstr "Favorito"
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
#: build/lib/core/api/serializers.py:487 core/api/serializers.py:487
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:491 core/api/serializers.py:491
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
msgid "This field is required."
msgstr ""
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/serializers.py:693 core/api/serializers.py:693
#: build/lib/core/api/serializers.py:628 core/api/serializers.py:628
msgid "Body"
msgstr "Cuerpo"
#: build/lib/core/api/serializers.py:696 core/api/serializers.py:696
#: build/lib/core/api/serializers.py:631 core/api/serializers.py:631
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:637 core/api/serializers.py:637
msgid "Format"
msgstr "Formato"
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#: build/lib/core/api/viewsets.py:965 core/api/viewsets.py:965
#, python-brace-format
msgid "copy of {title}"
msgstr "copia de {title}"
@@ -178,247 +169,247 @@ msgstr "Obligatorio. 255 caracteres o menos. Solo caracteres ASCII."
msgid "full name"
msgstr "nombre completo"
#: build/lib/core/models.py:152 core/models.py:152
#: build/lib/core/models.py:151 core/models.py:151
msgid "short name"
msgstr "nombre abreviado"
#: build/lib/core/models.py:155 core/models.py:155
#: build/lib/core/models.py:153 core/models.py:153
msgid "identity email address"
msgstr "correo electrónico de identidad"
#: build/lib/core/models.py:160 core/models.py:160
#: build/lib/core/models.py:158 core/models.py:158
msgid "admin email address"
msgstr "correo electrónico del administrador"
#: build/lib/core/models.py:167 core/models.py:167
#: build/lib/core/models.py:165 core/models.py:165
msgid "language"
msgstr "idioma"
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:166 core/models.py:166
msgid "The language in which the user wants to see the interface."
msgstr "El idioma en el que el usuario desea ver la interfaz."
#: build/lib/core/models.py:176 core/models.py:176
#: build/lib/core/models.py:174 core/models.py:174
msgid "The timezone in which the user wants to see times."
msgstr "La zona horaria en la que el usuario quiere ver los tiempos."
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:177 core/models.py:177
msgid "device"
msgstr "dispositivo"
#: build/lib/core/models.py:181 core/models.py:181
#: build/lib/core/models.py:179 core/models.py:179
msgid "Whether the user is a device or a real user."
msgstr "Si el usuario es un dispositivo o un usuario real."
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:182 core/models.py:182
msgid "staff status"
msgstr "rol en el equipo"
#: build/lib/core/models.py:186 core/models.py:186
#: build/lib/core/models.py:184 core/models.py:184
msgid "Whether the user can log into this admin site."
msgstr "Si el usuario puede iniciar sesión en esta página web de administración."
#: build/lib/core/models.py:189 core/models.py:189
#: build/lib/core/models.py:187 core/models.py:187
msgid "active"
msgstr "activo"
#: build/lib/core/models.py:192 core/models.py:192
#: build/lib/core/models.py:190 core/models.py:190
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Si este usuario debe ser considerado como activo. Deseleccionar en lugar de eliminar cuentas."
#: build/lib/core/models.py:204 core/models.py:204
#: build/lib/core/models.py:202 core/models.py:202
msgid "user"
msgstr "usuario"
#: build/lib/core/models.py:205 core/models.py:205
#: build/lib/core/models.py:203 core/models.py:203
msgid "users"
msgstr "usuarios"
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
#: build/lib/core/models.py:359 build/lib/core/models.py:1281
#: core/models.py:359 core/models.py:1281
msgid "title"
msgstr "título"
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:360 core/models.py:360
msgid "excerpt"
msgstr "resumen"
#: build/lib/core/models.py:411 core/models.py:411
#: build/lib/core/models.py:409 core/models.py:409
msgid "Document"
msgstr "Documento"
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:410 core/models.py:410
msgid "Documents"
msgstr "Documentos"
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
#: build/lib/core/models.py:422 build/lib/core/models.py:819 core/models.py:422
#: core/models.py:819
msgid "Untitled Document"
msgstr "Documento sin título"
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:854 core/models.py:854
#, 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:858 core/models.py:858
#, 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:864 core/models.py:864
#, 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:964 core/models.py:964
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:965 core/models.py:965
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:971 core/models.py:971
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:994 core/models.py:994
msgid "Document favorite"
msgstr "Documento favorito"
#: build/lib/core/models.py:998 core/models.py:998
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorites"
msgstr "Documentos favoritos"
#: build/lib/core/models.py:1004 core/models.py:1004
#: build/lib/core/models.py:1001 core/models.py:1001
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:1023 core/models.py:1023
msgid "Document/user relation"
msgstr "Relación documento/usuario"
#: build/lib/core/models.py:1027 core/models.py:1027
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relations"
msgstr "Relaciones documento/usuario"
#: build/lib/core/models.py:1033 core/models.py:1033
#: build/lib/core/models.py:1030 core/models.py:1030
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:1036 core/models.py:1036
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:1370
#: core/models.py:1045 core/models.py:1370
#: build/lib/core/models.py:1042 build/lib/core/models.py:1367
#: core/models.py:1042 core/models.py:1367
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:1188 core/models.py:1188
msgid "Document ask for access"
msgstr "Solicitud de acceso"
#: build/lib/core/models.py:1192 core/models.py:1192
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for accesses"
msgstr "Solicitud de accesos"
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1195 core/models.py:1195
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:1263 core/models.py:1263
#: build/lib/core/models.py:1260 core/models.py:1260
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "¡{name} desea acceder a un documento!"
#: build/lib/core/models.py:1267 core/models.py:1267
#: build/lib/core/models.py:1264 core/models.py:1264
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} desea acceso al siguiente documento:"
#: build/lib/core/models.py:1273 core/models.py:1273
#: build/lib/core/models.py:1270 core/models.py:1270
#, 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:1285 core/models.py:1285
#: build/lib/core/models.py:1282 core/models.py:1282
msgid "description"
msgstr "descripción"
#: build/lib/core/models.py:1286 core/models.py:1286
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "code"
msgstr "código"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "public"
msgstr "público"
#: build/lib/core/models.py:1291 core/models.py:1291
#: build/lib/core/models.py:1288 core/models.py:1288
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:1297 core/models.py:1297
#: build/lib/core/models.py:1294 core/models.py:1294
msgid "Template"
msgstr "Plantilla"
#: build/lib/core/models.py:1298 core/models.py:1298
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Templates"
msgstr "Plantillas"
#: build/lib/core/models.py:1351 core/models.py:1351
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Template/user relation"
msgstr "Relación plantilla/usuario"
#: build/lib/core/models.py:1352 core/models.py:1352
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relations"
msgstr "Relaciones plantilla/usuario"
#: build/lib/core/models.py:1358 core/models.py:1358
#: build/lib/core/models.py:1355 core/models.py:1355
msgid "This user is already in this template."
msgstr "Este usuario ya forma parte de la plantilla."
#: build/lib/core/models.py:1364 core/models.py:1364
#: build/lib/core/models.py:1361 core/models.py:1361
msgid "This team is already in this template."
msgstr "Este equipo ya se encuentra en esta plantilla."
#: build/lib/core/models.py:1441 core/models.py:1441
#: build/lib/core/models.py:1438 core/models.py:1438
msgid "email address"
msgstr "dirección de correo electrónico"
#: build/lib/core/models.py:1460 core/models.py:1460
#: build/lib/core/models.py:1457 core/models.py:1457
msgid "Document invitation"
msgstr "Invitación al documento"
#: build/lib/core/models.py:1461 core/models.py:1461
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitations"
msgstr "Invitaciones a documentos"
#: build/lib/core/models.py:1481 core/models.py:1481
#: build/lib/core/models.py:1478 core/models.py:1478
msgid "This email is already associated to a registered user."
msgstr "Este correo electrónico está asociado a un usuario registrado."
#: core/templates/mail/html/template.html:153
#: core/templates/mail/html/template.html:162
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr "Logo de correo electrónico"
#: core/templates/mail/html/template.html:200
#: core/templates/mail/html/template.html:209
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr "Abrir"
#: core/templates/mail/html/template.html:217
#: core/templates/mail/html/template.html:226
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr "Docs, su nueva herramienta esencial para organizar, compartir y colaborar en sus documentos como equipo."
#: core/templates/mail/html/template.html:224
#: core/templates/mail/html/template.html:233
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"POT-Creation-Date: 2025-09-10 14:29+0000\n"
"PO-Revision-Date: 2025-09-12 09:50\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:36 core/admin.py:36
#: build/lib/core/admin.py:37 core/admin.py:37
msgid "Personal info"
msgstr "Infos Personnelles"
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr "Permissions"
#: build/lib/core/admin.py:61 core/admin.py:61
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
msgstr "Dates importantes"
#: build/lib/core/admin.py:147 core/admin.py:147
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr "Arborescence"
@@ -50,36 +50,27 @@ msgstr "Masqué"
msgid "Favorite"
msgstr "Favoris"
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
#: build/lib/core/api/serializers.py:487 core/api/serializers.py:487
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:491 core/api/serializers.py:491
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
msgid "This field is required."
msgstr "Ce champ est obligatoire."
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr "La portée du lien '%(link_reach)s' n'est pas autorisée en fonction de la configuration du document parent."
#: build/lib/core/api/serializers.py:693 core/api/serializers.py:693
#: build/lib/core/api/serializers.py:628 core/api/serializers.py:628
msgid "Body"
msgstr "Corps"
#: build/lib/core/api/serializers.py:696 core/api/serializers.py:696
#: build/lib/core/api/serializers.py:631 core/api/serializers.py:631
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:637 core/api/serializers.py:637
msgid "Format"
msgstr "Format"
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#: build/lib/core/api/viewsets.py:965 core/api/viewsets.py:965
#, python-brace-format
msgid "copy of {title}"
msgstr "copie de {title}"
@@ -178,247 +169,247 @@ msgstr "Obligatoire. 255 caractères ou moins. Caractères ASCII uniquement."
msgid "full name"
msgstr "nom complet"
#: build/lib/core/models.py:152 core/models.py:152
#: build/lib/core/models.py:151 core/models.py:151
msgid "short name"
msgstr "nom court"
#: build/lib/core/models.py:155 core/models.py:155
#: build/lib/core/models.py:153 core/models.py:153
msgid "identity email address"
msgstr "adresse e-mail d'identité"
#: build/lib/core/models.py:160 core/models.py:160
#: build/lib/core/models.py:158 core/models.py:158
msgid "admin email address"
msgstr "adresse e-mail de l'administrateur"
#: build/lib/core/models.py:167 core/models.py:167
#: build/lib/core/models.py:165 core/models.py:165
msgid "language"
msgstr "langue"
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:166 core/models.py:166
msgid "The language in which the user wants to see the interface."
msgstr "La langue dans laquelle l'utilisateur veut voir l'interface."
#: build/lib/core/models.py:176 core/models.py:176
#: build/lib/core/models.py:174 core/models.py:174
msgid "The timezone in which the user wants to see times."
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:177 core/models.py:177
msgid "device"
msgstr "appareil"
#: build/lib/core/models.py:181 core/models.py:181
#: build/lib/core/models.py:179 core/models.py:179
msgid "Whether the user is a device or a real user."
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:182 core/models.py:182
msgid "staff status"
msgstr "statut d'équipe"
#: build/lib/core/models.py:186 core/models.py:186
#: build/lib/core/models.py:184 core/models.py:184
msgid "Whether the user can log into this admin site."
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
#: build/lib/core/models.py:189 core/models.py:189
#: build/lib/core/models.py:187 core/models.py:187
msgid "active"
msgstr "actif"
#: build/lib/core/models.py:192 core/models.py:192
#: build/lib/core/models.py:190 core/models.py:190
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Si cet utilisateur doit être traité comme actif. Désélectionnez ceci au lieu de supprimer des comptes."
#: build/lib/core/models.py:204 core/models.py:204
#: build/lib/core/models.py:202 core/models.py:202
msgid "user"
msgstr "utilisateur"
#: build/lib/core/models.py:205 core/models.py:205
#: build/lib/core/models.py:203 core/models.py:203
msgid "users"
msgstr "utilisateurs"
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
#: build/lib/core/models.py:359 build/lib/core/models.py:1281
#: core/models.py:359 core/models.py:1281
msgid "title"
msgstr "titre"
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:360 core/models.py:360
msgid "excerpt"
msgstr "extrait"
#: build/lib/core/models.py:411 core/models.py:411
#: build/lib/core/models.py:409 core/models.py:409
msgid "Document"
msgstr "Document"
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:410 core/models.py:410
msgid "Documents"
msgstr "Documents"
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
#: build/lib/core/models.py:422 build/lib/core/models.py:819 core/models.py:422
#: core/models.py:819
msgid "Untitled Document"
msgstr "Document sans titre"
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:854 core/models.py:854
#, 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:858 core/models.py:858
#, 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:864 core/models.py:864
#, 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:964 core/models.py:964
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:965 core/models.py:965
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:971 core/models.py:971
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:994 core/models.py:994
msgid "Document favorite"
msgstr "Document favori"
#: build/lib/core/models.py:998 core/models.py:998
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorites"
msgstr "Documents favoris"
#: build/lib/core/models.py:1004 core/models.py:1004
#: build/lib/core/models.py:1001 core/models.py:1001
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:1023 core/models.py:1023
msgid "Document/user relation"
msgstr "Relation document/utilisateur"
#: build/lib/core/models.py:1027 core/models.py:1027
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relations"
msgstr "Relations document/utilisateur"
#: build/lib/core/models.py:1033 core/models.py:1033
#: build/lib/core/models.py:1030 core/models.py:1030
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:1036 core/models.py:1036
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:1370
#: core/models.py:1045 core/models.py:1370
#: build/lib/core/models.py:1042 build/lib/core/models.py:1367
#: core/models.py:1042 core/models.py:1367
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:1188 core/models.py:1188
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:1189 core/models.py:1189
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:1195 core/models.py:1195
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:1263 core/models.py:1263
#: build/lib/core/models.py:1260 core/models.py:1260
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} souhaiterait accéder au document suivant !"
#: build/lib/core/models.py:1267 core/models.py:1267
#: build/lib/core/models.py:1264 core/models.py:1264
#, 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:1273 core/models.py:1273
#: build/lib/core/models.py:1270 core/models.py:1270
#, 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:1285 core/models.py:1285
#: build/lib/core/models.py:1282 core/models.py:1282
msgid "description"
msgstr "description"
#: build/lib/core/models.py:1286 core/models.py:1286
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "code"
msgstr "code"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "css"
msgstr "CSS"
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "public"
msgstr "public"
#: build/lib/core/models.py:1291 core/models.py:1291
#: build/lib/core/models.py:1288 core/models.py:1288
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:1297 core/models.py:1297
#: build/lib/core/models.py:1294 core/models.py:1294
msgid "Template"
msgstr "Modèle"
#: build/lib/core/models.py:1298 core/models.py:1298
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Templates"
msgstr "Modèles"
#: build/lib/core/models.py:1351 core/models.py:1351
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Template/user relation"
msgstr "Relation modèle/utilisateur"
#: build/lib/core/models.py:1352 core/models.py:1352
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relations"
msgstr "Relations modèle/utilisateur"
#: build/lib/core/models.py:1358 core/models.py:1358
#: build/lib/core/models.py:1355 core/models.py:1355
msgid "This user is already in this template."
msgstr "Cet utilisateur est déjà dans ce modèle."
#: build/lib/core/models.py:1364 core/models.py:1364
#: build/lib/core/models.py:1361 core/models.py:1361
msgid "This team is already in this template."
msgstr "Cette équipe est déjà modèle."
#: build/lib/core/models.py:1441 core/models.py:1441
#: build/lib/core/models.py:1438 core/models.py:1438
msgid "email address"
msgstr "adresse e-mail"
#: build/lib/core/models.py:1460 core/models.py:1460
#: build/lib/core/models.py:1457 core/models.py:1457
msgid "Document invitation"
msgstr "Invitation à un document"
#: build/lib/core/models.py:1461 core/models.py:1461
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitations"
msgstr "Invitations à un document"
#: build/lib/core/models.py:1481 core/models.py:1481
#: build/lib/core/models.py:1478 core/models.py:1478
msgid "This email is already associated to a registered user."
msgstr "Cette adresse email est déjà associée à un utilisateur inscrit."
#: core/templates/mail/html/template.html:153
#: core/templates/mail/html/template.html:162
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr "Logo de l'e-mail"
#: core/templates/mail/html/template.html:200
#: core/templates/mail/html/template.html:209
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr "Ouvrir"
#: core/templates/mail/html/template.html:217
#: core/templates/mail/html/template.html:226
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr " Docs, votre nouvel outil incontournable pour organiser, partager et collaborer sur vos documents en équipe. "
#: core/templates/mail/html/template.html:224
#: core/templates/mail/html/template.html:233
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"POT-Creation-Date: 2025-09-10 14:29+0000\n"
"PO-Revision-Date: 2025-09-12 09:50\n"
"Last-Translator: \n"
"Language-Team: Italian\n"
"Language: it_IT\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:36 core/admin.py:36
#: build/lib/core/admin.py:37 core/admin.py:37
msgid "Personal info"
msgstr "Informazioni personali"
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr "Permessi"
#: build/lib/core/admin.py:61 core/admin.py:61
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
msgstr "Date importanti"
#: build/lib/core/admin.py:147 core/admin.py:147
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr "Struttura ad albero"
@@ -50,36 +50,27 @@ msgstr ""
msgid "Favorite"
msgstr "Preferiti"
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
#: build/lib/core/api/serializers.py:487 core/api/serializers.py:487
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:491 core/api/serializers.py:491
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
msgid "This field is required."
msgstr ""
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/serializers.py:693 core/api/serializers.py:693
#: build/lib/core/api/serializers.py:628 core/api/serializers.py:628
msgid "Body"
msgstr "Corpo"
#: build/lib/core/api/serializers.py:696 core/api/serializers.py:696
#: build/lib/core/api/serializers.py:631 core/api/serializers.py:631
msgid "Body type"
msgstr ""
#: build/lib/core/api/serializers.py:702 core/api/serializers.py:702
#: build/lib/core/api/serializers.py:637 core/api/serializers.py:637
msgid "Format"
msgstr "Formato"
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#: build/lib/core/api/viewsets.py:965 core/api/viewsets.py:965
#, python-brace-format
msgid "copy of {title}"
msgstr "copia di {title}"
@@ -178,247 +169,247 @@ msgstr ""
msgid "full name"
msgstr "nome completo"
#: build/lib/core/models.py:152 core/models.py:152
#: build/lib/core/models.py:151 core/models.py:151
msgid "short name"
msgstr "nome"
#: build/lib/core/models.py:155 core/models.py:155
#: build/lib/core/models.py:153 core/models.py:153
msgid "identity email address"
msgstr "indirizzo email di identità"
#: build/lib/core/models.py:160 core/models.py:160
#: build/lib/core/models.py:158 core/models.py:158
msgid "admin email address"
msgstr "Indirizzo email dell'amministratore"
#: build/lib/core/models.py:167 core/models.py:167
#: build/lib/core/models.py:165 core/models.py:165
msgid "language"
msgstr "lingua"
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:166 core/models.py:166
msgid "The language in which the user wants to see the interface."
msgstr "La lingua in cui l'utente vuole vedere l'interfaccia."
#: build/lib/core/models.py:176 core/models.py:176
#: build/lib/core/models.py:174 core/models.py:174
msgid "The timezone in which the user wants to see times."
msgstr "Il fuso orario in cui l'utente vuole vedere gli orari."
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:177 core/models.py:177
msgid "device"
msgstr "dispositivo"
#: build/lib/core/models.py:181 core/models.py:181
#: build/lib/core/models.py:179 core/models.py:179
msgid "Whether the user is a device or a real user."
msgstr "Se l'utente è un dispositivo o un utente reale."
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:182 core/models.py:182
msgid "staff status"
msgstr "stato del personale"
#: build/lib/core/models.py:186 core/models.py:186
#: build/lib/core/models.py:184 core/models.py:184
msgid "Whether the user can log into this admin site."
msgstr "Indica se l'utente può accedere a questo sito amministratore."
#: build/lib/core/models.py:189 core/models.py:189
#: build/lib/core/models.py:187 core/models.py:187
msgid "active"
msgstr "attivo"
#: build/lib/core/models.py:192 core/models.py:192
#: build/lib/core/models.py:190 core/models.py:190
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Indica se questo utente deve essere trattato come attivo. Deseleziona invece di eliminare gli account."
#: build/lib/core/models.py:204 core/models.py:204
#: build/lib/core/models.py:202 core/models.py:202
msgid "user"
msgstr "utente"
#: build/lib/core/models.py:205 core/models.py:205
#: build/lib/core/models.py:203 core/models.py:203
msgid "users"
msgstr "utenti"
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
#: build/lib/core/models.py:359 build/lib/core/models.py:1281
#: core/models.py:359 core/models.py:1281
msgid "title"
msgstr "titolo"
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:360 core/models.py:360
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:411 core/models.py:411
#: build/lib/core/models.py:409 core/models.py:409
msgid "Document"
msgstr "Documento"
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:410 core/models.py:410
msgid "Documents"
msgstr "Documenti"
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
#: build/lib/core/models.py:422 build/lib/core/models.py:819 core/models.py:422
#: core/models.py:819
msgid "Untitled Document"
msgstr "Documento senza titolo"
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:854 core/models.py:854
#, 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:858 core/models.py:858
#, 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:864 core/models.py:864
#, 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:964 core/models.py:964
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:968 core/models.py:968
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:974 core/models.py:974
#: build/lib/core/models.py:971 core/models.py:971
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:994 core/models.py:994
msgid "Document favorite"
msgstr "Documento preferito"
#: build/lib/core/models.py:998 core/models.py:998
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorites"
msgstr "Documenti preferiti"
#: build/lib/core/models.py:1004 core/models.py:1004
#: build/lib/core/models.py:1001 core/models.py:1001
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:1023 core/models.py:1023
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1027 core/models.py:1027
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1033 core/models.py:1033
#: build/lib/core/models.py:1030 core/models.py:1030
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:1036 core/models.py:1036
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:1370
#: core/models.py:1045 core/models.py:1370
#: build/lib/core/models.py:1042 build/lib/core/models.py:1367
#: core/models.py:1042 core/models.py:1367
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:1188 core/models.py:1188
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1192 core/models.py:1192
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1195 core/models.py:1195
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1263 core/models.py:1263
#: build/lib/core/models.py:1260 core/models.py:1260
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1267 core/models.py:1267
#: build/lib/core/models.py:1264 core/models.py:1264
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1273 core/models.py:1273
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1282 core/models.py:1282
msgid "description"
msgstr "descrizione"
#: build/lib/core/models.py:1286 core/models.py:1286
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "code"
msgstr "code"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "public"
msgstr "pubblico"
#: build/lib/core/models.py:1291 core/models.py:1291
#: build/lib/core/models.py:1288 core/models.py:1288
msgid "Whether this template is public for anyone to use."
msgstr "Indica se questo modello è pubblico per chiunque."
#: build/lib/core/models.py:1297 core/models.py:1297
#: build/lib/core/models.py:1294 core/models.py:1294
msgid "Template"
msgstr "Modello"
#: build/lib/core/models.py:1298 core/models.py:1298
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Templates"
msgstr "Modelli"
#: build/lib/core/models.py:1351 core/models.py:1351
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1352 core/models.py:1352
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1358 core/models.py:1358
#: build/lib/core/models.py:1355 core/models.py:1355
msgid "This user is already in this template."
msgstr "Questo utente è già in questo modello."
#: build/lib/core/models.py:1364 core/models.py:1364
#: build/lib/core/models.py:1361 core/models.py:1361
msgid "This team is already in this template."
msgstr "Questo team è già in questo modello."
#: build/lib/core/models.py:1441 core/models.py:1441
#: build/lib/core/models.py:1438 core/models.py:1438
msgid "email address"
msgstr "indirizzo e-mail"
#: build/lib/core/models.py:1460 core/models.py:1460
#: build/lib/core/models.py:1457 core/models.py:1457
msgid "Document invitation"
msgstr "Invito al documento"
#: build/lib/core/models.py:1461 core/models.py:1461
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitations"
msgstr "Inviti al documento"
#: build/lib/core/models.py:1481 core/models.py:1481
#: build/lib/core/models.py:1478 core/models.py:1478
msgid "This email is already associated to a registered user."
msgstr "Questa email è già associata a un utente registrato."
#: core/templates/mail/html/template.html:153
#: core/templates/mail/html/template.html:162
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr "Logo e-mail"
#: core/templates/mail/html/template.html:200
#: core/templates/mail/html/template.html:209
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr "Apri"
#: core/templates/mail/html/template.html:217
#: core/templates/mail/html/template.html:226
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr ""
#: core/templates/mail/html/template.html:224
#: core/templates/mail/html/template.html:233
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"POT-Creation-Date: 2025-09-10 14:29+0000\n"
"PO-Revision-Date: 2025-09-12 09:50\n"
"Last-Translator: \n"
"Language-Team: Dutch\n"
"Language: nl_NL\n"
@@ -17,22 +17,22 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:36 core/admin.py:36
#: build/lib/core/admin.py:37 core/admin.py:37
msgid "Personal info"
msgstr "Persoonlijke informatie"
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr "Machtigingen"
msgstr "Toestemmingen"
#: build/lib/core/admin.py:61 core/admin.py:61
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
msgstr "Belangrijke data"
msgstr "Belangrijke datums"
#: build/lib/core/admin.py:147 core/admin.py:147
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr "Boomstructuur"
msgstr "Document structuur"
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
msgid "Title"
@@ -40,46 +40,37 @@ msgstr "Titel"
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
msgid "Creator is me"
msgstr "Ik ben eigenaar"
msgstr "Ik ben Eigenaar"
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
msgid "Masked"
msgstr "Gemaskeerd"
msgstr ""
#: build/lib/core/api/filters.py:67 core/api/filters.py:67
msgid "Favorite"
msgstr "Favoriet"
msgstr "Favoriete"
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
#: build/lib/core/api/serializers.py:487 core/api/serializers.py:487
msgid "A new document was created on your behalf!"
msgstr "Een nieuw document is namens u gemaakt!"
msgstr "Een nieuw document was gecreëerd voor u!"
#: build/lib/core/api/serializers.py:500 core/api/serializers.py:500
#: build/lib/core/api/serializers.py:491 core/api/serializers.py:491
msgid "You have been granted ownership of a new document:"
msgstr "U heeft eigenaarschap van een nieuw document gekregen:"
msgstr "U heeft eigenaarschap van een nieuw document:"
#: build/lib/core/api/serializers.py:536 core/api/serializers.py:536
msgid "This field is required."
msgstr "Dit veld is verplicht."
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr "Link bereik '%(link_reach)s' is niet toegestaan op basis van bovenliggende documentconfiguratie."
#: build/lib/core/api/serializers.py:693 core/api/serializers.py:693
#: build/lib/core/api/serializers.py:628 core/api/serializers.py:628
msgid "Body"
msgstr "Text"
#: build/lib/core/api/serializers.py:696 core/api/serializers.py:696
#: build/lib/core/api/serializers.py:631 core/api/serializers.py:631
msgid "Body type"
msgstr "Text type"
#: build/lib/core/api/serializers.py:702 core/api/serializers.py:702
#: build/lib/core/api/serializers.py:637 core/api/serializers.py:637
msgid "Format"
msgstr "Formaat"
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#: build/lib/core/api/viewsets.py:965 core/api/viewsets.py:965
#, python-brace-format
msgid "copy of {title}"
msgstr "kopie van {title}"
@@ -92,11 +83,11 @@ msgstr "Lezer"
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
#: core/choices.py:43
msgid "Editor"
msgstr "Redacteur"
msgstr "Bewerker"
#: build/lib/core/choices.py:44 core/choices.py:44
msgid "Administrator"
msgstr "Beheerder"
msgstr "Administrator"
#: build/lib/core/choices.py:45 core/choices.py:45
msgid "Owner"
@@ -104,7 +95,7 @@ msgstr "Eigenaar"
#: build/lib/core/choices.py:56 core/choices.py:56
msgid "Restricted"
msgstr "Beperkt"
msgstr "Niet toegestaan"
#: build/lib/core/choices.py:60 core/choices.py:60
msgid "Authenticated"
@@ -148,11 +139,11 @@ msgstr "primaire sleutel voor dossier als UUID"
#: build/lib/core/models.py:87 core/models.py:87
msgid "created on"
msgstr "gecreëerd op"
msgstr "gemaakt op"
#: build/lib/core/models.py:88 core/models.py:88
msgid "date and time at which a record was created"
msgstr "datum en tijd waarop dossier is gecreeërd"
msgstr "datum en tijd wanneer dossier was gecreëerd"
#: build/lib/core/models.py:93 core/models.py:93
msgid "updated on"
@@ -164,7 +155,7 @@ msgstr "datum en tijd waarop dossier laatst was gewijzigd"
#: build/lib/core/models.py:130 core/models.py:130
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
msgstr "Wij konden geen gebruiker vinden met dit id, maar de email is al geassocieerd met een geregistreerde gebruiker."
msgstr "Wij konden geen gebruiker vinden met deze id, maar de email is al geassocieerd met een geregistreerde gebruiker."
#: build/lib/core/models.py:141 core/models.py:141
msgid "sub"
@@ -172,253 +163,253 @@ msgstr "id"
#: build/lib/core/models.py:142 core/models.py:142
msgid "Required. 255 characters or fewer. ASCII characters only."
msgstr "Vereist. 255 tekens of minder. Alleen ASCII tekens."
msgstr ""
#: build/lib/core/models.py:150 core/models.py:150
msgid "full name"
msgstr "volledige naam"
#: build/lib/core/models.py:152 core/models.py:152
#: build/lib/core/models.py:151 core/models.py:151
msgid "short name"
msgstr "gebruikersnaam"
#: build/lib/core/models.py:155 core/models.py:155
#: build/lib/core/models.py:153 core/models.py:153
msgid "identity email address"
msgstr "identiteit emailadres"
msgstr "identiteit email adres"
#: build/lib/core/models.py:160 core/models.py:160
#: build/lib/core/models.py:158 core/models.py:158
msgid "admin email address"
msgstr "admin emailadres"
msgstr "admin email adres"
#: build/lib/core/models.py:167 core/models.py:167
#: build/lib/core/models.py:165 core/models.py:165
msgid "language"
msgstr "taal"
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:166 core/models.py:166
msgid "The language in which the user wants to see the interface."
msgstr "De taal waarin de gebruiker de interface wil zien."
msgstr "De taal waarin de gebruiker de interface wilt zien."
#: build/lib/core/models.py:176 core/models.py:176
#: build/lib/core/models.py:174 core/models.py:174
msgid "The timezone in which the user wants to see times."
msgstr "De tijdzone waarin de gebruiker de tijden wil zien."
msgstr "De tijdzone waarin de gebruiker de tijden wilt zien."
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:177 core/models.py:177
msgid "device"
msgstr "apparaat"
#: build/lib/core/models.py:181 core/models.py:181
#: build/lib/core/models.py:179 core/models.py:179
msgid "Whether the user is a device or a real user."
msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:182 core/models.py:182
msgid "staff status"
msgstr "beheerder status"
#: build/lib/core/models.py:186 core/models.py:186
#: build/lib/core/models.py:184 core/models.py:184
msgid "Whether the user can log into this admin site."
msgstr "Of de gebruiker kan inloggen in het beheer gedeelte."
msgstr "Of de gebruiker kan inloggen in het admin gedeelte."
#: build/lib/core/models.py:189 core/models.py:189
#: build/lib/core/models.py:187 core/models.py:187
msgid "active"
msgstr "actief"
#: build/lib/core/models.py:192 core/models.py:192
#: build/lib/core/models.py:190 core/models.py:190
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Of een gebruiker als actief moet worden beschouwd. Deselecteer dit in plaats van het account te deleten."
#: build/lib/core/models.py:204 core/models.py:204
#: build/lib/core/models.py:202 core/models.py:202
msgid "user"
msgstr "gebruiker"
#: build/lib/core/models.py:205 core/models.py:205
#: build/lib/core/models.py:203 core/models.py:203
msgid "users"
msgstr "gebruikers"
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
#: build/lib/core/models.py:359 build/lib/core/models.py:1281
#: core/models.py:359 core/models.py:1281
msgid "title"
msgstr "titel"
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:360 core/models.py:360
msgid "excerpt"
msgstr "uittreksel"
#: build/lib/core/models.py:411 core/models.py:411
#: build/lib/core/models.py:409 core/models.py:409
msgid "Document"
msgstr "Document"
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:410 core/models.py:410
msgid "Documents"
msgstr "Documenten"
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
#: build/lib/core/models.py:422 build/lib/core/models.py:819 core/models.py:422
#: core/models.py:819
msgid "Untitled Document"
msgstr "Naamloos Document"
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:854 core/models.py:854
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} heeft een document met u gedeeld!"
msgstr "{name} heeft een document met gedeeld!"
#: build/lib/core/models.py:861 core/models.py:861
#: build/lib/core/models.py:858 core/models.py:858
#, 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:864 core/models.py:864
#, 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:964 core/models.py:964
msgid "Document/user link trace"
msgstr "Document/gebruiker link"
msgstr "Document/gebruiker url"
#: build/lib/core/models.py:968 core/models.py:968
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link traces"
msgstr "Document/gebruiker link"
msgstr "Document/gebruiker url"
#: build/lib/core/models.py:974 core/models.py:974
#: build/lib/core/models.py:971 core/models.py:971
msgid "A link trace already exists for this document/user."
msgstr "Een link bestaat al voor dit document/deze gebruiker."
msgstr "Een url bestaat al voor dit document/deze gebruiker."
#: build/lib/core/models.py:997 core/models.py:997
#: build/lib/core/models.py:994 core/models.py:994
msgid "Document favorite"
msgstr "Document favoriet"
#: build/lib/core/models.py:998 core/models.py:998
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorites"
msgstr "Document favorieten"
#: build/lib/core/models.py:1004 core/models.py:1004
#: build/lib/core/models.py:1001 core/models.py:1001
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Dit document is al in gebruik als favoriet door dezelfde gebruiker."
msgstr "Dit document is al in gebruik als favoriete door dezelfde gebruiker."
#: build/lib/core/models.py:1026 core/models.py:1026
#: build/lib/core/models.py:1023 core/models.py:1023
msgid "Document/user relation"
msgstr "Document/gebruiker relatie"
#: build/lib/core/models.py:1027 core/models.py:1027
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relations"
msgstr "Document/gebruiker relaties"
#: build/lib/core/models.py:1033 core/models.py:1033
#: build/lib/core/models.py:1030 core/models.py:1030
msgid "This user is already in this document."
msgstr "De gebruiker bestaat al in dit document."
msgstr "De gebruiker is al in dit document."
#: build/lib/core/models.py:1039 core/models.py:1039
#: build/lib/core/models.py:1036 core/models.py:1036
msgid "This team is already in this document."
msgstr "Dit team bestaat al in dit document."
msgstr "Het team is al in dit document."
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
#: build/lib/core/models.py:1042 build/lib/core/models.py:1367
#: core/models.py:1042 core/models.py:1367
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:1188 core/models.py:1188
msgid "Document ask for access"
msgstr "Document verzoekt om toegang"
msgstr ""
#: build/lib/core/models.py:1192 core/models.py:1192
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for accesses"
msgstr "Document verzoekt om toegangen"
msgstr ""
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1195 core/models.py:1195
msgid "This user has already asked for access to this document."
msgstr "Deze gebruiker heeft al om toegang tot dit document gevraagd."
msgstr ""
#: build/lib/core/models.py:1263 core/models.py:1263
#: build/lib/core/models.py:1260 core/models.py:1260
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} verzoekt toegang tot een document!"
msgstr ""
#: build/lib/core/models.py:1267 core/models.py:1267
#: build/lib/core/models.py:1264 core/models.py:1264
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} verzoekt toegang tot het volgende document:"
msgstr ""
#: build/lib/core/models.py:1273 core/models.py:1273
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} verzoekt toegang tot het document: {title}"
msgstr ""
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1282 core/models.py:1282
msgid "description"
msgstr "omschrijving"
#: build/lib/core/models.py:1286 core/models.py:1286
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "code"
msgstr "code"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "public"
msgstr "publiek"
#: build/lib/core/models.py:1291 core/models.py:1291
#: build/lib/core/models.py:1288 core/models.py:1288
msgid "Whether this template is public for anyone to use."
msgstr "Of dit sjabloon door iedereen publiekelijk te gebruiken is."
msgstr "Of dit template als publiek is en door iedereen te gebruiken is."
#: build/lib/core/models.py:1297 core/models.py:1297
#: build/lib/core/models.py:1294 core/models.py:1294
msgid "Template"
msgstr "Sjabloon"
msgstr "Template"
#: build/lib/core/models.py:1298 core/models.py:1298
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Templates"
msgstr "Sjabloon"
msgstr "Templates"
#: build/lib/core/models.py:1351 core/models.py:1351
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Template/user relation"
msgstr "Sjabloon/gebruiker relatie"
msgstr "Template/gebruiker relatie"
#: build/lib/core/models.py:1352 core/models.py:1352
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relations"
msgstr "Sjabloon/gebruiker relaties"
msgstr "Template/gebruiker relaties"
#: build/lib/core/models.py:1358 core/models.py:1358
#: build/lib/core/models.py:1355 core/models.py:1355
msgid "This user is already in this template."
msgstr "De gebruiker bestaat al in dit sjabloon."
msgstr "De gebruiker bestaat al in dit template."
#: build/lib/core/models.py:1364 core/models.py:1364
#: build/lib/core/models.py:1361 core/models.py:1361
msgid "This team is already in this template."
msgstr "Het team bestaat al in dit sjabloon."
msgstr "Het team bestaat al in dit template."
#: build/lib/core/models.py:1441 core/models.py:1441
#: build/lib/core/models.py:1438 core/models.py:1438
msgid "email address"
msgstr "e-mailadres"
msgstr "email adres"
#: build/lib/core/models.py:1460 core/models.py:1460
#: build/lib/core/models.py:1457 core/models.py:1457
msgid "Document invitation"
msgstr "Document uitnodiging"
#: build/lib/core/models.py:1461 core/models.py:1461
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitations"
msgstr "Document uitnodigingen"
#: build/lib/core/models.py:1481 core/models.py:1481
#: build/lib/core/models.py:1478 core/models.py:1478
msgid "This email is already associated to a registered user."
msgstr "Deze email is al geassocieerd met een geregistreerde gebruiker."
#: core/templates/mail/html/template.html:153
#: core/templates/mail/html/template.html:162
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr "Logo email"
#: core/templates/mail/html/template.html:200
#: core/templates/mail/html/template.html:209
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr "Open"
#: core/templates/mail/html/template.html:217
#: core/templates/mail/html/template.html:226
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr " Docs, jouw nieuwe essentiële tool voor het organiseren, delen en collaboreren van documenten als team. "
#: core/templates/mail/html/template.html:224
#: core/templates/mail/html/template.html:233
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"POT-Creation-Date: 2025-09-10 14:29+0000\n"
"PO-Revision-Date: 2025-09-12 09:50\n"
"Last-Translator: \n"
"Language-Team: Portuguese\n"
"Language: pt_PT\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:36 core/admin.py:36
#: build/lib/core/admin.py:37 core/admin.py:37
msgid "Personal info"
msgstr "Informações Pessoais"
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr "Permissões"
#: build/lib/core/admin.py:61 core/admin.py:61
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
msgstr "Datas importantes"
#: build/lib/core/admin.py:147 core/admin.py:147
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr "Estrutura de árvore"
@@ -50,36 +50,27 @@ msgstr ""
msgid "Favorite"
msgstr "Favorito"
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
#: build/lib/core/api/serializers.py:487 core/api/serializers.py:487
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:491 core/api/serializers.py:491
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
msgid "This field is required."
msgstr ""
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/serializers.py:693 core/api/serializers.py:693
#: build/lib/core/api/serializers.py:628 core/api/serializers.py:628
msgid "Body"
msgstr "Corpo"
#: build/lib/core/api/serializers.py:696 core/api/serializers.py:696
#: build/lib/core/api/serializers.py:631 core/api/serializers.py:631
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:637 core/api/serializers.py:637
msgid "Format"
msgstr "Formato"
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#: build/lib/core/api/viewsets.py:965 core/api/viewsets.py:965
#, python-brace-format
msgid "copy of {title}"
msgstr "cópia de {title}"
@@ -178,247 +169,247 @@ msgstr ""
msgid "full name"
msgstr ""
#: build/lib/core/models.py:152 core/models.py:152
#: build/lib/core/models.py:151 core/models.py:151
msgid "short name"
msgstr ""
#: build/lib/core/models.py:155 core/models.py:155
#: build/lib/core/models.py:153 core/models.py:153
msgid "identity email address"
msgstr ""
#: build/lib/core/models.py:160 core/models.py:160
#: build/lib/core/models.py:158 core/models.py:158
msgid "admin email address"
msgstr ""
#: build/lib/core/models.py:167 core/models.py:167
#: build/lib/core/models.py:165 core/models.py:165
msgid "language"
msgstr ""
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:166 core/models.py:166
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/core/models.py:176 core/models.py:176
#: build/lib/core/models.py:174 core/models.py:174
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:177 core/models.py:177
msgid "device"
msgstr ""
#: build/lib/core/models.py:181 core/models.py:181
#: build/lib/core/models.py:179 core/models.py:179
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:182 core/models.py:182
msgid "staff status"
msgstr ""
#: build/lib/core/models.py:186 core/models.py:186
#: build/lib/core/models.py:184 core/models.py:184
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/core/models.py:189 core/models.py:189
#: build/lib/core/models.py:187 core/models.py:187
msgid "active"
msgstr ""
#: build/lib/core/models.py:192 core/models.py:192
#: build/lib/core/models.py:190 core/models.py:190
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: build/lib/core/models.py:204 core/models.py:204
#: build/lib/core/models.py:202 core/models.py:202
msgid "user"
msgstr ""
#: build/lib/core/models.py:205 core/models.py:205
#: build/lib/core/models.py:203 core/models.py:203
msgid "users"
msgstr ""
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
#: build/lib/core/models.py:359 build/lib/core/models.py:1281
#: core/models.py:359 core/models.py:1281
msgid "title"
msgstr ""
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:360 core/models.py:360
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:411 core/models.py:411
#: build/lib/core/models.py:409 core/models.py:409
msgid "Document"
msgstr ""
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:410 core/models.py:410
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
#: build/lib/core/models.py:422 build/lib/core/models.py:819 core/models.py:422
#: core/models.py:819
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:854 core/models.py:854
#, 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:858 core/models.py:858
#, 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:864 core/models.py:864
#, 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:964 core/models.py:964
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:968 core/models.py:968
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:974 core/models.py:974
#: build/lib/core/models.py:971 core/models.py:971
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:994 core/models.py:994
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:998 core/models.py:998
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1004 core/models.py:1004
#: build/lib/core/models.py:1001 core/models.py:1001
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:1023 core/models.py:1023
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1027 core/models.py:1027
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1033 core/models.py:1033
#: build/lib/core/models.py:1030 core/models.py:1030
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1039 core/models.py:1039
#: build/lib/core/models.py:1036 core/models.py:1036
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
#: build/lib/core/models.py:1042 build/lib/core/models.py:1367
#: core/models.py:1042 core/models.py:1367
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:1188 core/models.py:1188
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1192 core/models.py:1192
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1195 core/models.py:1195
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1263 core/models.py:1263
#: build/lib/core/models.py:1260 core/models.py:1260
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1267 core/models.py:1267
#: build/lib/core/models.py:1264 core/models.py:1264
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1273 core/models.py:1273
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1282 core/models.py:1282
msgid "description"
msgstr ""
#: build/lib/core/models.py:1286 core/models.py:1286
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "code"
msgstr ""
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "css"
msgstr ""
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "public"
msgstr ""
#: build/lib/core/models.py:1291 core/models.py:1291
#: build/lib/core/models.py:1288 core/models.py:1288
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1297 core/models.py:1297
#: build/lib/core/models.py:1294 core/models.py:1294
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1298 core/models.py:1298
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1351 core/models.py:1351
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1352 core/models.py:1352
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1358 core/models.py:1358
#: build/lib/core/models.py:1355 core/models.py:1355
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1364 core/models.py:1364
#: build/lib/core/models.py:1361 core/models.py:1361
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1441 core/models.py:1441
#: build/lib/core/models.py:1438 core/models.py:1438
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1460 core/models.py:1460
#: build/lib/core/models.py:1457 core/models.py:1457
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1461 core/models.py:1461
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1481 core/models.py:1481
#: build/lib/core/models.py:1478 core/models.py:1478
msgid "This email is already associated to a registered user."
msgstr ""
#: core/templates/mail/html/template.html:153
#: core/templates/mail/html/template.html:162
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr ""
#: core/templates/mail/html/template.html:200
#: core/templates/mail/html/template.html:209
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr ""
#: core/templates/mail/html/template.html:217
#: core/templates/mail/html/template.html:226
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr ""
#: core/templates/mail/html/template.html:224
#: core/templates/mail/html/template.html:233
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"POT-Creation-Date: 2025-09-10 14:29+0000\n"
"PO-Revision-Date: 2025-09-12 09:50\n"
"Last-Translator: \n"
"Language-Team: Russian\n"
"Language: ru_RU\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:36 core/admin.py:36
#: build/lib/core/admin.py:37 core/admin.py:37
msgid "Personal info"
msgstr "Личная информация"
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr "Разрешения"
#: build/lib/core/admin.py:61 core/admin.py:61
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
msgstr "Важные даты"
#: build/lib/core/admin.py:147 core/admin.py:147
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr "Древовидная структура"
@@ -50,36 +50,27 @@ msgstr "Скрытый"
msgid "Favorite"
msgstr "Избранное"
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
#: build/lib/core/api/serializers.py:487 core/api/serializers.py:487
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:491 core/api/serializers.py:491
msgid "You have been granted ownership of a new document:"
msgstr "Вы назначены владельцем для нового документа:"
#: build/lib/core/api/serializers.py:536 core/api/serializers.py:536
msgid "This field is required."
msgstr "Это поле обязательное."
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr "Доступ по ссылке '%(link_reach)s' запрещён в соответствии с настройками родительского документа."
#: build/lib/core/api/serializers.py:693 core/api/serializers.py:693
#: build/lib/core/api/serializers.py:628 core/api/serializers.py:628
msgid "Body"
msgstr "Текст сообщения"
#: build/lib/core/api/serializers.py:696 core/api/serializers.py:696
#: build/lib/core/api/serializers.py:631 core/api/serializers.py:631
msgid "Body type"
msgstr "Тип сообщения"
#: build/lib/core/api/serializers.py:702 core/api/serializers.py:702
#: build/lib/core/api/serializers.py:637 core/api/serializers.py:637
msgid "Format"
msgstr "Формат"
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#: build/lib/core/api/viewsets.py:965 core/api/viewsets.py:965
#, python-brace-format
msgid "copy of {title}"
msgstr "копия {title}"
@@ -178,247 +169,247 @@ msgstr "Обязательно. 255 символов или меньше. Тол
msgid "full name"
msgstr "полное имя"
#: build/lib/core/models.py:152 core/models.py:152
#: build/lib/core/models.py:151 core/models.py:151
msgid "short name"
msgstr "короткое имя"
#: build/lib/core/models.py:155 core/models.py:155
#: build/lib/core/models.py:153 core/models.py:153
msgid "identity email address"
msgstr "личный адрес электронной почты"
#: build/lib/core/models.py:160 core/models.py:160
#: build/lib/core/models.py:158 core/models.py:158
msgid "admin email address"
msgstr "e-mail администратора"
#: build/lib/core/models.py:167 core/models.py:167
#: build/lib/core/models.py:165 core/models.py:165
msgid "language"
msgstr "язык"
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:166 core/models.py:166
msgid "The language in which the user wants to see the interface."
msgstr "Язык, на котором пользователь хочет видеть интерфейс."
#: build/lib/core/models.py:176 core/models.py:176
#: build/lib/core/models.py:174 core/models.py:174
msgid "The timezone in which the user wants to see times."
msgstr "Часовой пояс, в котором пользователь хочет видеть время."
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:177 core/models.py:177
msgid "device"
msgstr "устройство"
#: build/lib/core/models.py:181 core/models.py:181
#: build/lib/core/models.py:179 core/models.py:179
msgid "Whether the user is a device or a real user."
msgstr "Пользователь является устройством или человеком."
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:182 core/models.py:182
msgid "staff status"
msgstr "статус сотрудника"
#: build/lib/core/models.py:186 core/models.py:186
#: build/lib/core/models.py:184 core/models.py:184
msgid "Whether the user can log into this admin site."
msgstr "Может ли пользователь войти на этот административный сайт."
#: build/lib/core/models.py:189 core/models.py:189
#: build/lib/core/models.py:187 core/models.py:187
msgid "active"
msgstr "активный"
#: build/lib/core/models.py:192 core/models.py:192
#: build/lib/core/models.py:190 core/models.py:190
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Должен ли пользователь рассматриваться как активный. Альтернатива удалению учётных записей."
#: build/lib/core/models.py:204 core/models.py:204
#: build/lib/core/models.py:202 core/models.py:202
msgid "user"
msgstr "пользователь"
#: build/lib/core/models.py:205 core/models.py:205
#: build/lib/core/models.py:203 core/models.py:203
msgid "users"
msgstr "пользователи"
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
#: build/lib/core/models.py:359 build/lib/core/models.py:1281
#: core/models.py:359 core/models.py:1281
msgid "title"
msgstr "заголовок"
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:360 core/models.py:360
msgid "excerpt"
msgstr "отрывок"
#: build/lib/core/models.py:411 core/models.py:411
#: build/lib/core/models.py:409 core/models.py:409
msgid "Document"
msgstr "Документ"
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:410 core/models.py:410
msgid "Documents"
msgstr "Документы"
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
#: build/lib/core/models.py:422 build/lib/core/models.py:819 core/models.py:422
#: core/models.py:819
msgid "Untitled Document"
msgstr "Безымянный документ"
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:854 core/models.py:854
#, 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:858 core/models.py:858
#, 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:864 core/models.py:864
#, 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:964 core/models.py:964
msgid "Document/user link trace"
msgstr "Трассировка связи документ/пользователь"
#: build/lib/core/models.py:968 core/models.py:968
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link traces"
msgstr "Трассировка связей документ/пользователь"
#: build/lib/core/models.py:974 core/models.py:974
#: build/lib/core/models.py:971 core/models.py:971
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:994 core/models.py:994
msgid "Document favorite"
msgstr "Избранный документ"
#: build/lib/core/models.py:998 core/models.py:998
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorites"
msgstr "Избранные документы"
#: build/lib/core/models.py:1004 core/models.py:1004
#: build/lib/core/models.py:1001 core/models.py:1001
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:1023 core/models.py:1023
msgid "Document/user relation"
msgstr "Отношение документ/пользователь"
#: build/lib/core/models.py:1027 core/models.py:1027
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relations"
msgstr "Отношения документ/пользователь"
#: build/lib/core/models.py:1033 core/models.py:1033
#: build/lib/core/models.py:1030 core/models.py:1030
msgid "This user is already in this document."
msgstr "Этот пользователь уже имеет доступ к этому документу."
#: build/lib/core/models.py:1039 core/models.py:1039
#: build/lib/core/models.py:1036 core/models.py:1036
msgid "This team is already in this document."
msgstr "Эта команда уже имеет доступ к этому документу."
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
#: build/lib/core/models.py:1042 build/lib/core/models.py:1367
#: core/models.py:1042 core/models.py:1367
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:1188 core/models.py:1188
msgid "Document ask for access"
msgstr "Документ запрашивает доступ"
#: build/lib/core/models.py:1192 core/models.py:1192
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for accesses"
msgstr "Документ запрашивает доступы"
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1195 core/models.py:1195
msgid "This user has already asked for access to this document."
msgstr "Этот пользователь уже запросил доступ к этому документу."
#: build/lib/core/models.py:1263 core/models.py:1263
#: build/lib/core/models.py:1260 core/models.py:1260
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} хочет получить доступ к документу!"
#: build/lib/core/models.py:1267 core/models.py:1267
#: build/lib/core/models.py:1264 core/models.py:1264
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} хочет получить доступ к следующему документу:"
#: build/lib/core/models.py:1273 core/models.py:1273
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} запрашивает доступ к документу: {title}"
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1282 core/models.py:1282
msgid "description"
msgstr "описание"
#: build/lib/core/models.py:1286 core/models.py:1286
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "code"
msgstr "код"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "public"
msgstr "доступно всем"
#: build/lib/core/models.py:1291 core/models.py:1291
#: build/lib/core/models.py:1288 core/models.py:1288
msgid "Whether this template is public for anyone to use."
msgstr "Этот шаблон доступен всем пользователям."
#: build/lib/core/models.py:1297 core/models.py:1297
#: build/lib/core/models.py:1294 core/models.py:1294
msgid "Template"
msgstr "Шаблон"
#: build/lib/core/models.py:1298 core/models.py:1298
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Templates"
msgstr "Шаблоны"
#: build/lib/core/models.py:1351 core/models.py:1351
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Template/user relation"
msgstr "Отношение шаблон/пользователь"
#: build/lib/core/models.py:1352 core/models.py:1352
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relations"
msgstr "Отношения шаблон/пользователь"
#: build/lib/core/models.py:1358 core/models.py:1358
#: build/lib/core/models.py:1355 core/models.py:1355
msgid "This user is already in this template."
msgstr "Этот пользователь уже указан в этом шаблоне."
#: build/lib/core/models.py:1364 core/models.py:1364
#: build/lib/core/models.py:1361 core/models.py:1361
msgid "This team is already in this template."
msgstr "Эта команда уже указана в этом шаблоне."
#: build/lib/core/models.py:1441 core/models.py:1441
#: build/lib/core/models.py:1438 core/models.py:1438
msgid "email address"
msgstr "адрес электронной почты"
#: build/lib/core/models.py:1460 core/models.py:1460
#: build/lib/core/models.py:1457 core/models.py:1457
msgid "Document invitation"
msgstr "Приглашение для документа"
#: build/lib/core/models.py:1461 core/models.py:1461
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitations"
msgstr "Приглашения для документов"
#: build/lib/core/models.py:1481 core/models.py:1481
#: build/lib/core/models.py:1478 core/models.py:1478
msgid "This email is already associated to a registered user."
msgstr "Этот адрес уже связан с зарегистрированным пользователем."
#: core/templates/mail/html/template.html:153
#: core/templates/mail/html/template.html:162
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr "Логотип email"
#: core/templates/mail/html/template.html:200
#: core/templates/mail/html/template.html:209
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr "Открыть"
#: core/templates/mail/html/template.html:217
#: core/templates/mail/html/template.html:226
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr " Docs, ваш новый инструмент для организации и совместного использования документов в вашей команде. "
#: core/templates/mail/html/template.html:224
#: core/templates/mail/html/template.html:233
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"POT-Creation-Date: 2025-09-10 14:29+0000\n"
"PO-Revision-Date: 2025-09-12 09:50\n"
"Last-Translator: \n"
"Language-Team: Slovenian\n"
"Language: sl_SI\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:36 core/admin.py:36
#: build/lib/core/admin.py:37 core/admin.py:37
msgid "Personal info"
msgstr "Osebni podatki"
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr "Dovoljenja"
#: build/lib/core/admin.py:61 core/admin.py:61
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
msgstr "Pomembni datumi"
#: build/lib/core/admin.py:147 core/admin.py:147
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr "Drevesna struktura"
@@ -50,36 +50,27 @@ msgstr ""
msgid "Favorite"
msgstr "Priljubljena"
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
#: build/lib/core/api/serializers.py:487 core/api/serializers.py:487
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:491 core/api/serializers.py:491
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
msgid "This field is required."
msgstr ""
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/serializers.py:693 core/api/serializers.py:693
#: build/lib/core/api/serializers.py:628 core/api/serializers.py:628
msgid "Body"
msgstr "Telo"
#: build/lib/core/api/serializers.py:696 core/api/serializers.py:696
#: build/lib/core/api/serializers.py:631 core/api/serializers.py:631
msgid "Body type"
msgstr "Vrsta telesa"
#: build/lib/core/api/serializers.py:702 core/api/serializers.py:702
#: build/lib/core/api/serializers.py:637 core/api/serializers.py:637
msgid "Format"
msgstr "Oblika"
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#: build/lib/core/api/viewsets.py:965 core/api/viewsets.py:965
#, python-brace-format
msgid "copy of {title}"
msgstr ""
@@ -178,247 +169,247 @@ msgstr ""
msgid "full name"
msgstr "polno ime"
#: build/lib/core/models.py:152 core/models.py:152
#: build/lib/core/models.py:151 core/models.py:151
msgid "short name"
msgstr "kratko ime"
#: build/lib/core/models.py:155 core/models.py:155
#: build/lib/core/models.py:153 core/models.py:153
msgid "identity email address"
msgstr "elektronski naslov identitete"
#: build/lib/core/models.py:160 core/models.py:160
#: build/lib/core/models.py:158 core/models.py:158
msgid "admin email address"
msgstr "elektronski naslov skrbnika"
#: build/lib/core/models.py:167 core/models.py:167
#: build/lib/core/models.py:165 core/models.py:165
msgid "language"
msgstr "jezik"
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:166 core/models.py:166
msgid "The language in which the user wants to see the interface."
msgstr "Jezik, v katerem uporabnik želi videti vmesnik."
#: build/lib/core/models.py:176 core/models.py:176
#: build/lib/core/models.py:174 core/models.py:174
msgid "The timezone in which the user wants to see times."
msgstr "Časovni pas, v katerem želi uporabnik videti uro."
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:177 core/models.py:177
msgid "device"
msgstr "naprava"
#: build/lib/core/models.py:181 core/models.py:181
#: build/lib/core/models.py:179 core/models.py:179
msgid "Whether the user is a device or a real user."
msgstr "Ali je uporabnik naprava ali pravi uporabnik."
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:182 core/models.py:182
msgid "staff status"
msgstr "kadrovski status"
#: build/lib/core/models.py:186 core/models.py:186
#: build/lib/core/models.py:184 core/models.py:184
msgid "Whether the user can log into this admin site."
msgstr "Ali se uporabnik lahko prijavi na to skrbniško mesto."
#: build/lib/core/models.py:189 core/models.py:189
#: build/lib/core/models.py:187 core/models.py:187
msgid "active"
msgstr "aktivni"
#: build/lib/core/models.py:192 core/models.py:192
#: build/lib/core/models.py:190 core/models.py:190
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Ali je treba tega uporabnika obravnavati kot aktivnega. Namesto brisanja računov počistite to izbiro."
#: build/lib/core/models.py:204 core/models.py:204
#: build/lib/core/models.py:202 core/models.py:202
msgid "user"
msgstr "uporabnik"
#: build/lib/core/models.py:205 core/models.py:205
#: build/lib/core/models.py:203 core/models.py:203
msgid "users"
msgstr "uporabniki"
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
#: build/lib/core/models.py:359 build/lib/core/models.py:1281
#: core/models.py:359 core/models.py:1281
msgid "title"
msgstr "naslov"
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:360 core/models.py:360
msgid "excerpt"
msgstr "odlomek"
#: build/lib/core/models.py:411 core/models.py:411
#: build/lib/core/models.py:409 core/models.py:409
msgid "Document"
msgstr "Dokument"
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:410 core/models.py:410
msgid "Documents"
msgstr "Dokumenti"
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
#: build/lib/core/models.py:422 build/lib/core/models.py:819 core/models.py:422
#: core/models.py:819
msgid "Untitled Document"
msgstr "Dokument brez naslova"
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:854 core/models.py:854
#, 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:858 core/models.py:858
#, 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:864 core/models.py:864
#, 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:964 core/models.py:964
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:965 core/models.py:965
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:971 core/models.py:971
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:994 core/models.py:994
msgid "Document favorite"
msgstr "Priljubljeni dokument"
#: build/lib/core/models.py:998 core/models.py:998
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorites"
msgstr "Priljubljeni dokumenti"
#: build/lib/core/models.py:1004 core/models.py:1004
#: build/lib/core/models.py:1001 core/models.py:1001
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:1023 core/models.py:1023
msgid "Document/user relation"
msgstr "Odnos dokument/uporabnik"
#: build/lib/core/models.py:1027 core/models.py:1027
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relations"
msgstr "Odnosi dokument/uporabnik"
#: build/lib/core/models.py:1033 core/models.py:1033
#: build/lib/core/models.py:1030 core/models.py:1030
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:1036 core/models.py:1036
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:1370
#: core/models.py:1045 core/models.py:1370
#: build/lib/core/models.py:1042 build/lib/core/models.py:1367
#: core/models.py:1042 core/models.py:1367
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:1188 core/models.py:1188
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1192 core/models.py:1192
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1195 core/models.py:1195
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1263 core/models.py:1263
#: build/lib/core/models.py:1260 core/models.py:1260
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1267 core/models.py:1267
#: build/lib/core/models.py:1264 core/models.py:1264
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1273 core/models.py:1273
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1282 core/models.py:1282
msgid "description"
msgstr "opis"
#: build/lib/core/models.py:1286 core/models.py:1286
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "code"
msgstr "koda"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "public"
msgstr "javno"
#: build/lib/core/models.py:1291 core/models.py:1291
#: build/lib/core/models.py:1288 core/models.py:1288
msgid "Whether this template is public for anyone to use."
msgstr "Ali je ta predloga javna za uporabo."
#: build/lib/core/models.py:1297 core/models.py:1297
#: build/lib/core/models.py:1294 core/models.py:1294
msgid "Template"
msgstr "Predloga"
#: build/lib/core/models.py:1298 core/models.py:1298
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Templates"
msgstr "Predloge"
#: build/lib/core/models.py:1351 core/models.py:1351
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Template/user relation"
msgstr "Odnos predloga/uporabnik"
#: build/lib/core/models.py:1352 core/models.py:1352
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relations"
msgstr "Odnosi med predlogo in uporabnikom"
#: build/lib/core/models.py:1358 core/models.py:1358
#: build/lib/core/models.py:1355 core/models.py:1355
msgid "This user is already in this template."
msgstr "Ta uporabnik je že v tej predlogi."
#: build/lib/core/models.py:1364 core/models.py:1364
#: build/lib/core/models.py:1361 core/models.py:1361
msgid "This team is already in this template."
msgstr "Ta ekipa je že v tej predlogi."
#: build/lib/core/models.py:1441 core/models.py:1441
#: build/lib/core/models.py:1438 core/models.py:1438
msgid "email address"
msgstr "elektronski naslov"
#: build/lib/core/models.py:1460 core/models.py:1460
#: build/lib/core/models.py:1457 core/models.py:1457
msgid "Document invitation"
msgstr "Vabilo na dokument"
#: build/lib/core/models.py:1461 core/models.py:1461
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitations"
msgstr "Vabila na dokument"
#: build/lib/core/models.py:1481 core/models.py:1481
#: build/lib/core/models.py:1478 core/models.py:1478
msgid "This email is already associated to a registered user."
msgstr "Ta e-poštni naslov je že povezan z registriranim uporabnikom."
#: core/templates/mail/html/template.html:153
#: core/templates/mail/html/template.html:162
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr "E-pošta z logotipom"
#: core/templates/mail/html/template.html:200
#: core/templates/mail/html/template.html:209
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr "Odpri"
#: core/templates/mail/html/template.html:217
#: core/templates/mail/html/template.html:226
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr " Dokumenti, vaše novo bistveno orodje za organiziranje, skupno rabo in skupinsko sodelovanje pri dokumentih. "
#: core/templates/mail/html/template.html:224
#: core/templates/mail/html/template.html:233
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"POT-Creation-Date: 2025-09-10 14:29+0000\n"
"PO-Revision-Date: 2025-09-12 09:50\n"
"Last-Translator: \n"
"Language-Team: Swedish\n"
"Language: sv_SE\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:36 core/admin.py:36
#: build/lib/core/admin.py:37 core/admin.py:37
msgid "Personal info"
msgstr "Personuppgifter"
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr "Behörigheter"
#: build/lib/core/admin.py:61 core/admin.py:61
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
msgstr "Viktiga datum"
#: build/lib/core/admin.py:147 core/admin.py:147
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr ""
@@ -50,36 +50,27 @@ msgstr ""
msgid "Favorite"
msgstr "Favoriter"
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
#: build/lib/core/api/serializers.py:487 core/api/serializers.py:487
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:491 core/api/serializers.py:491
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
msgid "This field is required."
msgstr ""
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/serializers.py:693 core/api/serializers.py:693
#: build/lib/core/api/serializers.py:628 core/api/serializers.py:628
msgid "Body"
msgstr ""
#: build/lib/core/api/serializers.py:696 core/api/serializers.py:696
#: build/lib/core/api/serializers.py:631 core/api/serializers.py:631
msgid "Body type"
msgstr ""
#: build/lib/core/api/serializers.py:702 core/api/serializers.py:702
#: build/lib/core/api/serializers.py:637 core/api/serializers.py:637
msgid "Format"
msgstr "Format"
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#: build/lib/core/api/viewsets.py:965 core/api/viewsets.py:965
#, python-brace-format
msgid "copy of {title}"
msgstr ""
@@ -178,247 +169,247 @@ msgstr ""
msgid "full name"
msgstr ""
#: build/lib/core/models.py:152 core/models.py:152
#: build/lib/core/models.py:151 core/models.py:151
msgid "short name"
msgstr ""
#: build/lib/core/models.py:155 core/models.py:155
#: build/lib/core/models.py:153 core/models.py:153
msgid "identity email address"
msgstr ""
#: build/lib/core/models.py:160 core/models.py:160
#: build/lib/core/models.py:158 core/models.py:158
msgid "admin email address"
msgstr ""
#: build/lib/core/models.py:167 core/models.py:167
#: build/lib/core/models.py:165 core/models.py:165
msgid "language"
msgstr ""
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:166 core/models.py:166
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/core/models.py:176 core/models.py:176
#: build/lib/core/models.py:174 core/models.py:174
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:177 core/models.py:177
msgid "device"
msgstr ""
#: build/lib/core/models.py:181 core/models.py:181
#: build/lib/core/models.py:179 core/models.py:179
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:182 core/models.py:182
msgid "staff status"
msgstr ""
#: build/lib/core/models.py:186 core/models.py:186
#: build/lib/core/models.py:184 core/models.py:184
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/core/models.py:189 core/models.py:189
#: build/lib/core/models.py:187 core/models.py:187
msgid "active"
msgstr "aktiv"
#: build/lib/core/models.py:192 core/models.py:192
#: build/lib/core/models.py:190 core/models.py:190
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: build/lib/core/models.py:204 core/models.py:204
#: build/lib/core/models.py:202 core/models.py:202
msgid "user"
msgstr ""
#: build/lib/core/models.py:205 core/models.py:205
#: build/lib/core/models.py:203 core/models.py:203
msgid "users"
msgstr ""
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
#: build/lib/core/models.py:359 build/lib/core/models.py:1281
#: core/models.py:359 core/models.py:1281
msgid "title"
msgstr ""
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:360 core/models.py:360
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:411 core/models.py:411
#: build/lib/core/models.py:409 core/models.py:409
msgid "Document"
msgstr ""
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:410 core/models.py:410
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
#: build/lib/core/models.py:422 build/lib/core/models.py:819 core/models.py:422
#: core/models.py:819
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:854 core/models.py:854
#, 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:858 core/models.py:858
#, 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:864 core/models.py:864
#, 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:964 core/models.py:964
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:968 core/models.py:968
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:974 core/models.py:974
#: build/lib/core/models.py:971 core/models.py:971
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:994 core/models.py:994
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:998 core/models.py:998
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1004 core/models.py:1004
#: build/lib/core/models.py:1001 core/models.py:1001
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:1023 core/models.py:1023
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1027 core/models.py:1027
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1033 core/models.py:1033
#: build/lib/core/models.py:1030 core/models.py:1030
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1039 core/models.py:1039
#: build/lib/core/models.py:1036 core/models.py:1036
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
#: build/lib/core/models.py:1042 build/lib/core/models.py:1367
#: core/models.py:1042 core/models.py:1367
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:1188 core/models.py:1188
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1192 core/models.py:1192
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1195 core/models.py:1195
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1263 core/models.py:1263
#: build/lib/core/models.py:1260 core/models.py:1260
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1267 core/models.py:1267
#: build/lib/core/models.py:1264 core/models.py:1264
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1273 core/models.py:1273
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1282 core/models.py:1282
msgid "description"
msgstr ""
#: build/lib/core/models.py:1286 core/models.py:1286
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "code"
msgstr ""
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "css"
msgstr ""
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "public"
msgstr ""
#: build/lib/core/models.py:1291 core/models.py:1291
#: build/lib/core/models.py:1288 core/models.py:1288
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1297 core/models.py:1297
#: build/lib/core/models.py:1294 core/models.py:1294
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1298 core/models.py:1298
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1351 core/models.py:1351
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1352 core/models.py:1352
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1358 core/models.py:1358
#: build/lib/core/models.py:1355 core/models.py:1355
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1364 core/models.py:1364
#: build/lib/core/models.py:1361 core/models.py:1361
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1441 core/models.py:1441
#: build/lib/core/models.py:1438 core/models.py:1438
msgid "email address"
msgstr "e-postadress"
#: build/lib/core/models.py:1460 core/models.py:1460
#: build/lib/core/models.py:1457 core/models.py:1457
msgid "Document invitation"
msgstr "Bjud in dokument"
#: build/lib/core/models.py:1461 core/models.py:1461
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitations"
msgstr "Inbjudningar dokument"
#: build/lib/core/models.py:1481 core/models.py:1481
#: build/lib/core/models.py:1478 core/models.py:1478
msgid "This email is already associated to a registered user."
msgstr "Denna e-postadress är redan associerad med en registrerad användare."
#: core/templates/mail/html/template.html:153
#: core/templates/mail/html/template.html:162
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr "Logotyp e-post"
#: core/templates/mail/html/template.html:200
#: core/templates/mail/html/template.html:209
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr "Öppna"
#: core/templates/mail/html/template.html:217
#: core/templates/mail/html/template.html:226
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr ""
#: core/templates/mail/html/template.html:224
#: core/templates/mail/html/template.html:233
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"POT-Creation-Date: 2025-09-10 14:29+0000\n"
"PO-Revision-Date: 2025-09-12 09:50\n"
"Last-Translator: \n"
"Language-Team: Turkish\n"
"Language: tr_TR\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:36 core/admin.py:36
#: build/lib/core/admin.py:37 core/admin.py:37
msgid "Personal info"
msgstr ""
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr ""
#: build/lib/core/admin.py:61 core/admin.py:61
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
msgstr ""
#: build/lib/core/admin.py:147 core/admin.py:147
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr ""
@@ -50,36 +50,27 @@ msgstr ""
msgid "Favorite"
msgstr ""
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
#: build/lib/core/api/serializers.py:487 core/api/serializers.py:487
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:491 core/api/serializers.py:491
msgid "You have been granted ownership of a new document:"
msgstr ""
#: build/lib/core/api/serializers.py:536 core/api/serializers.py:536
msgid "This field is required."
msgstr ""
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/serializers.py:693 core/api/serializers.py:693
#: build/lib/core/api/serializers.py:628 core/api/serializers.py:628
msgid "Body"
msgstr ""
#: build/lib/core/api/serializers.py:696 core/api/serializers.py:696
#: build/lib/core/api/serializers.py:631 core/api/serializers.py:631
msgid "Body type"
msgstr ""
#: build/lib/core/api/serializers.py:702 core/api/serializers.py:702
#: build/lib/core/api/serializers.py:637 core/api/serializers.py:637
msgid "Format"
msgstr ""
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#: build/lib/core/api/viewsets.py:965 core/api/viewsets.py:965
#, python-brace-format
msgid "copy of {title}"
msgstr ""
@@ -178,247 +169,247 @@ msgstr ""
msgid "full name"
msgstr ""
#: build/lib/core/models.py:152 core/models.py:152
#: build/lib/core/models.py:151 core/models.py:151
msgid "short name"
msgstr ""
#: build/lib/core/models.py:155 core/models.py:155
#: build/lib/core/models.py:153 core/models.py:153
msgid "identity email address"
msgstr ""
#: build/lib/core/models.py:160 core/models.py:160
#: build/lib/core/models.py:158 core/models.py:158
msgid "admin email address"
msgstr ""
#: build/lib/core/models.py:167 core/models.py:167
#: build/lib/core/models.py:165 core/models.py:165
msgid "language"
msgstr ""
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:166 core/models.py:166
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/core/models.py:176 core/models.py:176
#: build/lib/core/models.py:174 core/models.py:174
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:177 core/models.py:177
msgid "device"
msgstr ""
#: build/lib/core/models.py:181 core/models.py:181
#: build/lib/core/models.py:179 core/models.py:179
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:182 core/models.py:182
msgid "staff status"
msgstr ""
#: build/lib/core/models.py:186 core/models.py:186
#: build/lib/core/models.py:184 core/models.py:184
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/core/models.py:189 core/models.py:189
#: build/lib/core/models.py:187 core/models.py:187
msgid "active"
msgstr ""
#: build/lib/core/models.py:192 core/models.py:192
#: build/lib/core/models.py:190 core/models.py:190
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: build/lib/core/models.py:204 core/models.py:204
#: build/lib/core/models.py:202 core/models.py:202
msgid "user"
msgstr ""
#: build/lib/core/models.py:205 core/models.py:205
#: build/lib/core/models.py:203 core/models.py:203
msgid "users"
msgstr ""
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
#: build/lib/core/models.py:359 build/lib/core/models.py:1281
#: core/models.py:359 core/models.py:1281
msgid "title"
msgstr ""
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:360 core/models.py:360
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:411 core/models.py:411
#: build/lib/core/models.py:409 core/models.py:409
msgid "Document"
msgstr ""
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:410 core/models.py:410
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
#: build/lib/core/models.py:422 build/lib/core/models.py:819 core/models.py:422
#: core/models.py:819
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:854 core/models.py:854
#, 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:858 core/models.py:858
#, 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:864 core/models.py:864
#, 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:964 core/models.py:964
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:968 core/models.py:968
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:974 core/models.py:974
#: build/lib/core/models.py:971 core/models.py:971
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:994 core/models.py:994
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:998 core/models.py:998
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1004 core/models.py:1004
#: build/lib/core/models.py:1001 core/models.py:1001
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:1023 core/models.py:1023
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1027 core/models.py:1027
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1033 core/models.py:1033
#: build/lib/core/models.py:1030 core/models.py:1030
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1039 core/models.py:1039
#: build/lib/core/models.py:1036 core/models.py:1036
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
#: build/lib/core/models.py:1042 build/lib/core/models.py:1367
#: core/models.py:1042 core/models.py:1367
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:1188 core/models.py:1188
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1192 core/models.py:1192
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1195 core/models.py:1195
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1263 core/models.py:1263
#: build/lib/core/models.py:1260 core/models.py:1260
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1267 core/models.py:1267
#: build/lib/core/models.py:1264 core/models.py:1264
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1273 core/models.py:1273
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1282 core/models.py:1282
msgid "description"
msgstr ""
#: build/lib/core/models.py:1286 core/models.py:1286
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "code"
msgstr ""
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "css"
msgstr ""
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "public"
msgstr ""
#: build/lib/core/models.py:1291 core/models.py:1291
#: build/lib/core/models.py:1288 core/models.py:1288
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1297 core/models.py:1297
#: build/lib/core/models.py:1294 core/models.py:1294
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1298 core/models.py:1298
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1351 core/models.py:1351
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1352 core/models.py:1352
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1358 core/models.py:1358
#: build/lib/core/models.py:1355 core/models.py:1355
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1364 core/models.py:1364
#: build/lib/core/models.py:1361 core/models.py:1361
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1441 core/models.py:1441
#: build/lib/core/models.py:1438 core/models.py:1438
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1460 core/models.py:1460
#: build/lib/core/models.py:1457 core/models.py:1457
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1461 core/models.py:1461
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1481 core/models.py:1481
#: build/lib/core/models.py:1478 core/models.py:1478
msgid "This email is already associated to a registered user."
msgstr ""
#: core/templates/mail/html/template.html:153
#: core/templates/mail/html/template.html:162
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr ""
#: core/templates/mail/html/template.html:200
#: core/templates/mail/html/template.html:209
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr ""
#: core/templates/mail/html/template.html:217
#: core/templates/mail/html/template.html:226
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr ""
#: core/templates/mail/html/template.html:224
#: core/templates/mail/html/template.html:233
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"POT-Creation-Date: 2025-09-10 14:29+0000\n"
"PO-Revision-Date: 2025-09-12 09:50\n"
"Last-Translator: \n"
"Language-Team: Ukrainian\n"
"Language: uk_UA\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:36 core/admin.py:36
#: build/lib/core/admin.py:37 core/admin.py:37
msgid "Personal info"
msgstr "Особисті дані"
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr "Дозволи"
#: build/lib/core/admin.py:61 core/admin.py:61
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
msgstr "Важливі дати"
#: build/lib/core/admin.py:147 core/admin.py:147
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr "Ієрархічна структура"
@@ -50,36 +50,27 @@ msgstr "Приховано"
msgid "Favorite"
msgstr "Обране"
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
#: build/lib/core/api/serializers.py:487 core/api/serializers.py:487
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:491 core/api/serializers.py:491
msgid "You have been granted ownership of a new document:"
msgstr "Ви тепер є власником нового документа:"
#: build/lib/core/api/serializers.py:536 core/api/serializers.py:536
msgid "This field is required."
msgstr "Це поле є обов’язковим."
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr "Доступ до посилання '%(link_reach)s' заборонено на основі конфігурації батьківського документа."
#: build/lib/core/api/serializers.py:693 core/api/serializers.py:693
#: build/lib/core/api/serializers.py:628 core/api/serializers.py:628
msgid "Body"
msgstr "Вміст"
#: build/lib/core/api/serializers.py:696 core/api/serializers.py:696
#: build/lib/core/api/serializers.py:631 core/api/serializers.py:631
msgid "Body type"
msgstr "Тип вмісту"
#: build/lib/core/api/serializers.py:702 core/api/serializers.py:702
#: build/lib/core/api/serializers.py:637 core/api/serializers.py:637
msgid "Format"
msgstr "Формат"
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#: build/lib/core/api/viewsets.py:965 core/api/viewsets.py:965
#, python-brace-format
msgid "copy of {title}"
msgstr "копія {title}"
@@ -178,247 +169,247 @@ msgstr "Обов'язкове. 255 символів або менше. Тіль
msgid "full name"
msgstr "повне ім'я"
#: build/lib/core/models.py:152 core/models.py:152
#: build/lib/core/models.py:151 core/models.py:151
msgid "short name"
msgstr "коротке ім'я"
#: build/lib/core/models.py:155 core/models.py:155
#: build/lib/core/models.py:153 core/models.py:153
msgid "identity email address"
msgstr "адреса електронної пошти особи"
#: build/lib/core/models.py:160 core/models.py:160
#: build/lib/core/models.py:158 core/models.py:158
msgid "admin email address"
msgstr "електронна адреса адміністратора"
#: build/lib/core/models.py:167 core/models.py:167
#: build/lib/core/models.py:165 core/models.py:165
msgid "language"
msgstr "мова"
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:166 core/models.py:166
msgid "The language in which the user wants to see the interface."
msgstr "Мова, якою користувач хоче бачити інтерфейс."
#: build/lib/core/models.py:176 core/models.py:176
#: build/lib/core/models.py:174 core/models.py:174
msgid "The timezone in which the user wants to see times."
msgstr "Часовий пояс, в якому користувач хоче бачити час."
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:177 core/models.py:177
msgid "device"
msgstr "пристрій"
#: build/lib/core/models.py:181 core/models.py:181
#: build/lib/core/models.py:179 core/models.py:179
msgid "Whether the user is a device or a real user."
msgstr "Чи є користувач пристроєм чи реальним користувачем."
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:182 core/models.py:182
msgid "staff status"
msgstr "статус співробітника"
#: build/lib/core/models.py:186 core/models.py:186
#: build/lib/core/models.py:184 core/models.py:184
msgid "Whether the user can log into this admin site."
msgstr "Чи може користувач увійти на цей сайт адміністратора."
#: build/lib/core/models.py:189 core/models.py:189
#: build/lib/core/models.py:187 core/models.py:187
msgid "active"
msgstr "активний"
#: build/lib/core/models.py:192 core/models.py:192
#: build/lib/core/models.py:190 core/models.py:190
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Чи слід ставитися до цього користувача як до активного. Зніміть вибір замість видалення облікового запису."
#: build/lib/core/models.py:204 core/models.py:204
#: build/lib/core/models.py:202 core/models.py:202
msgid "user"
msgstr "користувач"
#: build/lib/core/models.py:205 core/models.py:205
#: build/lib/core/models.py:203 core/models.py:203
msgid "users"
msgstr "користувачі"
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
#: build/lib/core/models.py:359 build/lib/core/models.py:1281
#: core/models.py:359 core/models.py:1281
msgid "title"
msgstr "заголовок"
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:360 core/models.py:360
msgid "excerpt"
msgstr "уривок"
#: build/lib/core/models.py:411 core/models.py:411
#: build/lib/core/models.py:409 core/models.py:409
msgid "Document"
msgstr "Документ"
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:410 core/models.py:410
msgid "Documents"
msgstr "Документи"
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
#: build/lib/core/models.py:422 build/lib/core/models.py:819 core/models.py:422
#: core/models.py:819
msgid "Untitled Document"
msgstr "Документ без назви"
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:854 core/models.py:854
#, 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:858 core/models.py:858
#, 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:864 core/models.py:864
#, 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:964 core/models.py:964
msgid "Document/user link trace"
msgstr "Трасування посилання Документ/користувач"
#: build/lib/core/models.py:968 core/models.py:968
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link traces"
msgstr "Трасування посилань Документ/користувач"
#: build/lib/core/models.py:974 core/models.py:974
#: build/lib/core/models.py:971 core/models.py:971
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:994 core/models.py:994
msgid "Document favorite"
msgstr "Обраний документ"
#: build/lib/core/models.py:998 core/models.py:998
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorites"
msgstr "Обрані документи"
#: build/lib/core/models.py:1004 core/models.py:1004
#: build/lib/core/models.py:1001 core/models.py:1001
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:1023 core/models.py:1023
msgid "Document/user relation"
msgstr "Відносини документ/користувач"
#: build/lib/core/models.py:1027 core/models.py:1027
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relations"
msgstr "Відносини документ/користувач"
#: build/lib/core/models.py:1033 core/models.py:1033
#: build/lib/core/models.py:1030 core/models.py:1030
msgid "This user is already in this document."
msgstr "Цей користувач вже має доступ до цього документу."
#: build/lib/core/models.py:1039 core/models.py:1039
#: build/lib/core/models.py:1036 core/models.py:1036
msgid "This team is already in this document."
msgstr "Ця команда вже має доступ до цього документа."
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
#: build/lib/core/models.py:1042 build/lib/core/models.py:1367
#: core/models.py:1042 core/models.py:1367
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:1188 core/models.py:1188
msgid "Document ask for access"
msgstr "Запит доступу до документа"
#: build/lib/core/models.py:1192 core/models.py:1192
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for accesses"
msgstr "Запит доступу для документа"
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1195 core/models.py:1195
msgid "This user has already asked for access to this document."
msgstr "Цей користувач вже попросив доступ до цього документа."
#: build/lib/core/models.py:1263 core/models.py:1263
#: build/lib/core/models.py:1260 core/models.py:1260
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} хоче отримати доступ до документа!"
#: build/lib/core/models.py:1267 core/models.py:1267
#: build/lib/core/models.py:1264 core/models.py:1264
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} бажає отримати доступ до наступного документа:"
#: build/lib/core/models.py:1273 core/models.py:1273
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} запитує доступ до документа: {title}"
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1282 core/models.py:1282
msgid "description"
msgstr "опис"
#: build/lib/core/models.py:1286 core/models.py:1286
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "code"
msgstr "код"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "public"
msgstr "публічне"
#: build/lib/core/models.py:1291 core/models.py:1291
#: build/lib/core/models.py:1288 core/models.py:1288
msgid "Whether this template is public for anyone to use."
msgstr "Чи є цей шаблон публічним для будь-кого користувача."
#: build/lib/core/models.py:1297 core/models.py:1297
#: build/lib/core/models.py:1294 core/models.py:1294
msgid "Template"
msgstr "Шаблон"
#: build/lib/core/models.py:1298 core/models.py:1298
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Templates"
msgstr "Шаблони"
#: build/lib/core/models.py:1351 core/models.py:1351
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Template/user relation"
msgstr "Відношення шаблон/користувач"
#: build/lib/core/models.py:1352 core/models.py:1352
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relations"
msgstr "Відношення шаблон/користувач"
#: build/lib/core/models.py:1358 core/models.py:1358
#: build/lib/core/models.py:1355 core/models.py:1355
msgid "This user is already in this template."
msgstr "Цей користувач вже має доступ до цього шаблону."
#: build/lib/core/models.py:1364 core/models.py:1364
#: build/lib/core/models.py:1361 core/models.py:1361
msgid "This team is already in this template."
msgstr "Ця команда вже має доступ до цього шаблону."
#: build/lib/core/models.py:1441 core/models.py:1441
#: build/lib/core/models.py:1438 core/models.py:1438
msgid "email address"
msgstr "електронна адреса"
#: build/lib/core/models.py:1460 core/models.py:1460
#: build/lib/core/models.py:1457 core/models.py:1457
msgid "Document invitation"
msgstr "Запрошення до редагування документа"
#: build/lib/core/models.py:1461 core/models.py:1461
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitations"
msgstr "Запрошення до редагування документів"
#: build/lib/core/models.py:1481 core/models.py:1481
#: build/lib/core/models.py:1478 core/models.py:1478
msgid "This email is already associated to a registered user."
msgstr "Ця електронна пошта вже пов'язана з зареєстрованим користувачем."
#: core/templates/mail/html/template.html:153
#: core/templates/mail/html/template.html:162
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr "Логотип пошти"
#: core/templates/mail/html/template.html:200
#: core/templates/mail/html/template.html:209
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr "Відкрити"
#: core/templates/mail/html/template.html:217
#: core/templates/mail/html/template.html:226
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr " Docs, ваш новий важливий інструмент для організації, обміну та командної співпраці над вашими документами. "
#: core/templates/mail/html/template.html:224
#: core/templates/mail/html/template.html:233
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-23 11:01+0000\n"
"PO-Revision-Date: 2025-11-10 09:54\n"
"POT-Creation-Date: 2025-09-10 14:29+0000\n"
"PO-Revision-Date: 2025-09-12 09:50\n"
"Last-Translator: \n"
"Language-Team: Chinese Simplified\n"
"Language: zh_CN\n"
@@ -17,20 +17,20 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:36 core/admin.py:36
#: build/lib/core/admin.py:37 core/admin.py:37
msgid "Personal info"
msgstr "个人信息"
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr "权限"
#: build/lib/core/admin.py:61 core/admin.py:61
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
msgstr "重要日期"
#: build/lib/core/admin.py:147 core/admin.py:147
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr "树状结构"
@@ -44,42 +44,33 @@ msgstr "创建者是我"
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
msgid "Masked"
msgstr "已屏蔽"
msgstr ""
#: build/lib/core/api/filters.py:67 core/api/filters.py:67
msgid "Favorite"
msgstr "收藏"
#: build/lib/core/api/serializers.py:496 core/api/serializers.py:496
#: build/lib/core/api/serializers.py:487 core/api/serializers.py:487
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:491 core/api/serializers.py:491
msgid "You have been granted ownership of a new document:"
msgstr "您已被授予新文档的所有权:"
#: build/lib/core/api/serializers.py:536 core/api/serializers.py:536
msgid "This field is required."
msgstr "必填字段。"
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/serializers.py:693 core/api/serializers.py:693
#: build/lib/core/api/serializers.py:628 core/api/serializers.py:628
msgid "Body"
msgstr "正文"
#: build/lib/core/api/serializers.py:696 core/api/serializers.py:696
#: build/lib/core/api/serializers.py:631 core/api/serializers.py:631
msgid "Body type"
msgstr "正文类型"
#: build/lib/core/api/serializers.py:702 core/api/serializers.py:702
#: build/lib/core/api/serializers.py:637 core/api/serializers.py:637
msgid "Format"
msgstr "格式"
#: build/lib/core/api/viewsets.py:1003 core/api/viewsets.py:1003
#: build/lib/core/api/viewsets.py:965 core/api/viewsets.py:965
#, python-brace-format
msgid "copy of {title}"
msgstr "{title} 的副本"
@@ -172,253 +163,253 @@ msgstr "sub"
#: build/lib/core/models.py:142 core/models.py:142
msgid "Required. 255 characters or fewer. ASCII characters only."
msgstr "必填项。限255个字符以内。仅支持ASCII字符。"
msgstr ""
#: build/lib/core/models.py:150 core/models.py:150
msgid "full name"
msgstr "全名"
#: build/lib/core/models.py:152 core/models.py:152
#: build/lib/core/models.py:151 core/models.py:151
msgid "short name"
msgstr "简称"
#: build/lib/core/models.py:155 core/models.py:155
#: build/lib/core/models.py:153 core/models.py:153
msgid "identity email address"
msgstr "身份电子邮件地址"
#: build/lib/core/models.py:160 core/models.py:160
#: build/lib/core/models.py:158 core/models.py:158
msgid "admin email address"
msgstr "管理员电子邮件地址"
#: build/lib/core/models.py:167 core/models.py:167
#: build/lib/core/models.py:165 core/models.py:165
msgid "language"
msgstr "语言"
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:166 core/models.py:166
msgid "The language in which the user wants to see the interface."
msgstr "用户希望看到的界面语言。"
#: build/lib/core/models.py:176 core/models.py:176
#: build/lib/core/models.py:174 core/models.py:174
msgid "The timezone in which the user wants to see times."
msgstr "用户查看时间希望的时区。"
#: build/lib/core/models.py:179 core/models.py:179
#: build/lib/core/models.py:177 core/models.py:177
msgid "device"
msgstr "设备"
#: build/lib/core/models.py:181 core/models.py:181
#: build/lib/core/models.py:179 core/models.py:179
msgid "Whether the user is a device or a real user."
msgstr "用户是设备还是真实用户。"
#: build/lib/core/models.py:184 core/models.py:184
#: build/lib/core/models.py:182 core/models.py:182
msgid "staff status"
msgstr "员工状态"
#: build/lib/core/models.py:186 core/models.py:186
#: build/lib/core/models.py:184 core/models.py:184
msgid "Whether the user can log into this admin site."
msgstr "用户是否可以登录该管理员站点。"
#: build/lib/core/models.py:189 core/models.py:189
#: build/lib/core/models.py:187 core/models.py:187
msgid "active"
msgstr "激活"
#: build/lib/core/models.py:192 core/models.py:192
#: build/lib/core/models.py:190 core/models.py:190
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "是否应将此用户视为活跃用户。取消选择此选项而不是删除账户。"
#: build/lib/core/models.py:204 core/models.py:204
#: build/lib/core/models.py:202 core/models.py:202
msgid "user"
msgstr "用户"
#: build/lib/core/models.py:205 core/models.py:205
#: build/lib/core/models.py:203 core/models.py:203
msgid "users"
msgstr "个用户"
#: build/lib/core/models.py:361 build/lib/core/models.py:1284
#: core/models.py:361 core/models.py:1284
#: build/lib/core/models.py:359 build/lib/core/models.py:1281
#: core/models.py:359 core/models.py:1281
msgid "title"
msgstr "标题"
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:360 core/models.py:360
msgid "excerpt"
msgstr "摘要"
#: build/lib/core/models.py:411 core/models.py:411
#: build/lib/core/models.py:409 core/models.py:409
msgid "Document"
msgstr "文档"
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:410 core/models.py:410
msgid "Documents"
msgstr "个文档"
#: build/lib/core/models.py:424 build/lib/core/models.py:822 core/models.py:424
#: core/models.py:822
#: build/lib/core/models.py:422 build/lib/core/models.py:819 core/models.py:422
#: core/models.py:819
msgid "Untitled Document"
msgstr "未命名文档"
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:854 core/models.py:854
#, 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:858 core/models.py:858
#, 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:864 core/models.py:864
#, 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:964 core/models.py:964
msgid "Document/user link trace"
msgstr "文档/用户链接跟踪"
#: build/lib/core/models.py:968 core/models.py:968
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link traces"
msgstr "个文档/用户链接跟踪"
#: build/lib/core/models.py:974 core/models.py:974
#: build/lib/core/models.py:971 core/models.py:971
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:994 core/models.py:994
msgid "Document favorite"
msgstr "文档收藏"
#: build/lib/core/models.py:998 core/models.py:998
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorites"
msgstr "文档收藏夹"
#: build/lib/core/models.py:1004 core/models.py:1004
#: build/lib/core/models.py:1001 core/models.py:1001
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:1023 core/models.py:1023
msgid "Document/user relation"
msgstr "文档/用户关系"
#: build/lib/core/models.py:1027 core/models.py:1027
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relations"
msgstr "文档/用户关系集"
#: build/lib/core/models.py:1033 core/models.py:1033
#: build/lib/core/models.py:1030 core/models.py:1030
msgid "This user is already in this document."
msgstr "该用户已在此文档中。"
#: build/lib/core/models.py:1039 core/models.py:1039
#: build/lib/core/models.py:1036 core/models.py:1036
msgid "This team is already in this document."
msgstr "该团队已在此文档中。"
#: build/lib/core/models.py:1045 build/lib/core/models.py:1370
#: core/models.py:1045 core/models.py:1370
#: build/lib/core/models.py:1042 build/lib/core/models.py:1367
#: core/models.py:1042 core/models.py:1367
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:1188 core/models.py:1188
msgid "Document ask for access"
msgstr "文档需要访问权限"
msgstr ""
#: build/lib/core/models.py:1192 core/models.py:1192
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for accesses"
msgstr "文档需要访问权限"
msgstr ""
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1195 core/models.py:1195
msgid "This user has already asked for access to this document."
msgstr "用户已申请该文档的访问权限。"
msgstr ""
#: build/lib/core/models.py:1263 core/models.py:1263
#: build/lib/core/models.py:1260 core/models.py:1260
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} 申请访问文档!"
msgstr ""
#: build/lib/core/models.py:1267 core/models.py:1267
#: build/lib/core/models.py:1264 core/models.py:1264
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} 申请访问以下文档:"
msgstr ""
#: build/lib/core/models.py:1273 core/models.py:1273
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name}申请文档:{title}的访问权限"
msgstr ""
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1282 core/models.py:1282
msgid "description"
msgstr "说明"
#: build/lib/core/models.py:1286 core/models.py:1286
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "code"
msgstr "代码"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1289 core/models.py:1289
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "public"
msgstr "公开"
#: build/lib/core/models.py:1291 core/models.py:1291
#: build/lib/core/models.py:1288 core/models.py:1288
msgid "Whether this template is public for anyone to use."
msgstr "该模板是否公开供任何人使用。"
#: build/lib/core/models.py:1297 core/models.py:1297
#: build/lib/core/models.py:1294 core/models.py:1294
msgid "Template"
msgstr "模板"
#: build/lib/core/models.py:1298 core/models.py:1298
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Templates"
msgstr "模板"
#: build/lib/core/models.py:1351 core/models.py:1351
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Template/user relation"
msgstr "模板/用户关系"
#: build/lib/core/models.py:1352 core/models.py:1352
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relations"
msgstr "模板/用户关系集"
#: build/lib/core/models.py:1358 core/models.py:1358
#: build/lib/core/models.py:1355 core/models.py:1355
msgid "This user is already in this template."
msgstr "该用户已在此模板中。"
#: build/lib/core/models.py:1364 core/models.py:1364
#: build/lib/core/models.py:1361 core/models.py:1361
msgid "This team is already in this template."
msgstr "该团队已在此模板中。"
#: build/lib/core/models.py:1441 core/models.py:1441
#: build/lib/core/models.py:1438 core/models.py:1438
msgid "email address"
msgstr "电子邮件地址"
#: build/lib/core/models.py:1460 core/models.py:1460
#: build/lib/core/models.py:1457 core/models.py:1457
msgid "Document invitation"
msgstr "文档邀请"
#: build/lib/core/models.py:1461 core/models.py:1461
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitations"
msgstr "文档邀请"
#: build/lib/core/models.py:1481 core/models.py:1481
#: build/lib/core/models.py:1478 core/models.py:1478
msgid "This email is already associated to a registered user."
msgstr "此电子邮件已经与现有注册用户关联。"
#: core/templates/mail/html/template.html:153
#: core/templates/mail/html/template.html:162
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr "徽标邮件"
#: core/templates/mail/html/template.html:200
#: core/templates/mail/html/template.html:209
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr "打开"
#: core/templates/mail/html/template.html:217
#: core/templates/mail/html/template.html:226
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr " Docs——您的全新必备工具帮助团队组织、共享和协作处理文档。 "
#: core/templates/mail/html/template.html:224
#: core/templates/mail/html/template.html:233
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "3.9.0"
version = "3.7.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,42 +25,42 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"beautifulsoup4==4.14.2",
"boto3==1.40.59",
"Brotli==1.2.0",
"beautifulsoup4==4.13.4",
"boto3==1.39.4",
"Brotli==1.1.0",
"celery[redis]==5.5.3",
"django-configurations==2.5.1",
"django-cors-headers==4.9.0",
"django-cors-headers==4.7.0",
"django-countries==7.6.1",
"django-csp==4.0",
"django-filter==25.2",
"django-lasuite[all]==0.0.16",
"django-filter==25.1",
"django-lasuite[all]==0.0.14",
"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.6",
"django-treebeard==4.7.1",
"djangorestframework==3.16.1",
"djangorestframework==3.16.0",
"drf_spectacular==0.28.0",
"dockerflow==2024.4.2",
"easy_thumbnails==2.10.1",
"easy_thumbnails==2.10",
"factory_boy==3.3.3",
"gunicorn==23.0.0",
"jsonschema==4.25.1",
"lxml==6.0.2",
"markdown==3.9",
"jsonschema==4.24.0",
"lxml==6.0.0",
"markdown==3.8.2",
"mozilla-django-oidc==4.0.1",
"nested-multipart-parser==1.6.0",
"openai==2.6.1",
"psycopg[binary]==3.2.12",
"pycrdt==0.12.42",
"nested-multipart-parser==1.5.0",
"openai==1.95.0",
"psycopg[binary]==3.2.9",
"pycrdt==0.12.25",
"PyJWT==2.10.1",
"python-magic==0.4.27",
"redis<6.0.0",
"requests==2.32.5",
"sentry-sdk==2.42.1",
"whitenoise==6.11.0",
"requests==2.32.4",
"sentry-sdk==2.32.0",
"whitenoise==6.9.0",
]
[project.urls]
@@ -73,21 +73,21 @@ dependencies = [
dev = [
"django-extensions==4.1",
"django-test-migrations==1.5.0",
"drf-spectacular-sidecar==2025.10.1",
"freezegun==1.5.5",
"drf-spectacular-sidecar==2025.7.1",
"freezegun==1.5.2",
"ipdb==0.13.13",
"ipython==9.6.0",
"pyfakefs==5.10.0",
"ipython==9.4.0",
"pyfakefs==5.9.1",
"pylint-django==2.6.1",
"pylint<4.0.0",
"pytest-cov==7.0.0",
"pylint==3.3.7",
"pytest-cov==6.2.1",
"pytest-django==4.11.1",
"pytest==8.4.2",
"pytest==8.4.1",
"pytest-icdiff==0.9",
"pytest-xdist==3.8.0",
"responses==0.25.8",
"ruff==0.14.2",
"types-requests==2.32.4.20250913",
"responses==0.25.7",
"ruff==0.12.2",
"types-requests==2.32.4.20250611",
]
[tool.setuptools]

View File

@@ -50,13 +50,7 @@ ENV NEXT_PUBLIC_PUBLISH_AS_MIT=${PUBLISH_AS_MIT}
RUN yarn build
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:alpine3.22 AS frontend-production
# Upgrade system packages to install security updates
USER root
RUN apk update && \
apk upgrade && \
rm -rf /var/cache/apk/*
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
# Un-privileged user running the application
ARG DOCKER_USER

View File

@@ -89,8 +89,8 @@ test.describe('Doc Create: Not logged', () => {
const data = {
title,
content: markdown,
sub: `user.test@${browserName}.test`,
email: `user.test@${browserName}.test`,
sub: `user@${browserName}.test`,
email: `user@${browserName}.test`,
};
const newDoc = await request.post(

View File

@@ -1,7 +1,7 @@
/* eslint-disable playwright/no-conditional-expect */
import path from 'path';
import { expect, test } from '@playwright/test';
import { chromium, expect, test } from '@playwright/test';
import cs from 'convert-stream';
import {
@@ -11,13 +11,7 @@ import {
overrideConfig,
verifyDocName,
} from './utils-common';
import { getEditor, openSuggestionMenu, writeInEditor } from './utils-editor';
import { connectOtherUserToDoc, updateShareLink } from './utils-share';
import {
createRootSubPage,
getTreeRow,
navigateToPageFromTree,
} from './utils-sub-pages';
import { createRootSubPage } from './utils-sub-pages';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -92,7 +86,8 @@ test.describe('Doc Editor', () => {
// Is connected
let framesentPromise = webSocket.waitForEvent('framesent');
await writeInEditor({ page, text: 'Hello World' });
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
let framesent = await framesentPromise;
expect(framesent.payload).not.toBeNull();
@@ -105,7 +100,7 @@ test.describe('Doc Editor', () => {
const wsClosePromise = webSocket.waitForEvent('close');
await selectVisibility.click();
await page.getByRole('menuitem', { name: 'Connected' }).click();
await page.getByLabel('Connected').click();
// Assert that the doc reconnects to the ws
const wsClose = await wsClosePromise;
@@ -243,7 +238,17 @@ test.describe('Doc Editor', () => {
test('it cannot edit if viewer', async ({ page }) => {
await mockedDocument(page, {
user_role: 'reader',
abilities: {
destroy: false, // Means not owner
link_configuration: false,
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
accesses_manage: false, // Means not admin
update: false,
partial_update: false, // Means not editor
retrieve: true,
},
});
await goToGridDoc(page);
@@ -252,9 +257,6 @@ test.describe('Doc Editor', () => {
await expect(card).toBeVisible();
await expect(card.getByText('Reader')).toBeVisible();
const editor = page.locator('.ProseMirror');
await expect(editor).toHaveAttribute('contenteditable', 'false');
});
test('it adds an image to the doc editor', async ({ page, browserName }) => {
@@ -494,7 +496,7 @@ test.describe('Doc Editor', () => {
if (request.method().includes('GET')) {
await route.fulfill({
json: {
status: requestCount > 1 ? 'ready' : 'processing',
status: requestCount ? 'ready' : 'processing',
file: '/anything.html',
},
});
@@ -510,7 +512,10 @@ test.describe('Doc Editor', () => {
await verifyDocName(page, randomDoc);
const editor = await openSuggestionMenu({ page });
const editor = page.locator('.ProseMirror.bn-editor');
await editor.click();
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Embedded file').click();
await page.getByText('Upload file').click();
@@ -518,12 +523,6 @@ test.describe('Doc Editor', () => {
await fileChooser.setFiles(path.join(__dirname, 'assets/test.html'));
await expect(editor.getByText('Analyzing file...')).toBeVisible();
// To be sure the retry happens even after a page reload
await page.reload();
await expect(editor.getByText('Analyzing file...')).toBeVisible();
// The retry takes a few seconds
await expect(editor.getByText('test.html')).toBeVisible({
timeout: 7000,
@@ -571,7 +570,20 @@ test.describe('Doc Editor', () => {
await page.getByRole('button', { name: 'Share' }).click();
await updateShareLink(page, 'Public', 'Editing');
await page.getByTestId('doc-visibility').click();
await page
.getByRole('menuitem', {
name: 'Public',
})
.click();
await expect(
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.getByTestId('doc-access-mode').click();
await page.getByRole('menuitem', { name: 'Editing' }).click();
// Close the modal
await page.getByRole('button', { name: 'close' }).first().click();
@@ -595,12 +607,17 @@ test.describe('Doc Editor', () => {
* We open another browser that will connect to the collaborative server
* and will block the current browser to edit the doc.
*/
const { otherPage } = await connectOtherUserToDoc({
browserName,
docUrl: urlChildDoc,
docTitle: childTitle,
withoutSignIn: true,
const otherBrowser = await chromium.launch({ headless: true });
const otherContext = await otherBrowser.newContext({
locale: 'en-US',
timezoneId: 'Europe/Paris',
permissions: [],
storageState: {
cookies: [],
origins: [],
},
});
const otherPage = await otherContext.newPage();
const webSocketPromise = otherPage.waitForEvent(
'websocket',
@@ -641,11 +658,6 @@ test.describe('Doc Editor', () => {
await expect(editor).toHaveAttribute('contenteditable', 'false');
await expect(
page.getByRole('textbox', { name: 'Document title' }),
).toBeHidden();
await expect(page.getByRole('heading', { name: childTitle })).toBeVisible();
await page.goto(urlParentDoc);
await verifyDocName(page, parentTitle);
@@ -662,11 +674,6 @@ test.describe('Doc Editor', () => {
await expect(editor).toHaveAttribute('contenteditable', 'true');
await expect(
page.getByRole('textbox', { name: 'Document title' }),
).toContainText(childTitle);
await expect(page.getByRole('heading', { name: childTitle })).toBeHidden();
await expect(
card.getByText('Others are editing. Your network prevent changes.'),
).toBeHidden();
@@ -675,7 +682,9 @@ test.describe('Doc Editor', () => {
test('it checks if callout custom block', async ({ page, browserName }) => {
await createDoc(page, 'doc-toolbar', browserName, 1);
await openSuggestionMenu({ page });
const editor = page.locator('.ProseMirror');
await editor.click();
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Add a callout block').click();
const calloutBlock = page
@@ -686,9 +695,10 @@ test.describe('Doc Editor', () => {
await calloutBlock.locator('.inline-content').fill('example text');
await expect(
page.locator('.bn-block-content[data-content-type="callout"]').first(),
).toHaveAttribute('data-background-color', 'yellow');
await expect(page.locator('.bn-block').first()).toHaveAttribute(
'data-background-color',
'yellow',
);
const emojiButton = calloutBlock.getByRole('button');
await expect(emojiButton).toHaveText('💡');
@@ -712,9 +722,10 @@ test.describe('Doc Editor', () => {
await page.locator('.mantine-Menu-dropdown > button').last().click();
await page.locator('.bn-color-picker-dropdown > button').last().click();
await expect(
page.locator('.bn-block-content[data-content-type="callout"]').first(),
).toHaveAttribute('data-background-color', 'pink');
await expect(page.locator('.bn-block').first()).toHaveAttribute(
'data-background-color',
'pink',
);
});
test('it checks interlink feature', async ({ page, browserName }) => {
@@ -738,13 +749,7 @@ test.describe('Doc Editor', () => {
await verifyDocName(page, docChild2);
const treeRow = await getTreeRow(page, docChild2);
await treeRow.locator('.--docs--doc-icon').click();
await page.getByRole('button', { name: '😀' }).first().click();
await navigateToPageFromTree({ page, title: docChild1 });
await openSuggestionMenu({ page });
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Link a doc').first().click();
const input = page.locator(
@@ -758,148 +763,39 @@ test.describe('Doc Editor', () => {
await expect(searchContainer.getByText(docChild1)).toBeVisible();
await expect(searchContainer.getByText(docChild2)).toBeVisible();
const searchContainerRow = searchContainer
.getByRole('option')
.filter({
hasText: docChild2,
})
.first();
await expect(searchContainerRow).toContainText('😀');
await expect(searchContainerRow.locator('svg').first()).toBeHidden();
await input.pressSequentially('-child');
await expect(searchContainer.getByText(docChild1)).toBeVisible();
await expect(searchContainer.getByText(docChild2)).toBeVisible();
await expect(searchContainer.getByText(randomDoc)).toBeHidden();
await page.keyboard.press('ArrowDown');
// use keydown to select the second result
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Enter');
// Wait for the search container to disappear, indicating selection was made
await expect(searchContainer).toBeHidden();
// Wait for the interlink to be created and rendered
const editor = await getEditor({ page });
const interlinkChild2 = editor.getByRole('button', {
name: docChild2,
const interlink = page.getByRole('link', {
name: 'child-2',
});
await expect(interlinkChild2).toBeVisible({ timeout: 10000 });
await expect(interlinkChild2).toContainText('😀');
await expect(interlinkChild2.locator('svg').first()).toBeHidden();
await interlinkChild2.click();
await expect(interlink).toBeVisible();
await interlink.click();
await verifyDocName(page, docChild2);
});
test('it checks interlink shortcut @', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-interlink', browserName, 1);
await verifyDocName(page, randomDoc);
const editor = page.locator('.bn-block-outer').last();
await editor.click();
await page.keyboard.press('@');
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 page.keyboard.press('@');
await page.keyboard.press('Escape');
await expect(editor.getByText('@')).toBeVisible();
});
test('it checks multiple big doc scroll to the top', async ({
page,
browserName,
}) => {
const [randomDoc] = await createDoc(page, 'doc-scroll', browserName, 1);
for (let i = 0; i < 15; i++) {
await page.keyboard.press('Enter');
await writeInEditor({ page, text: 'Hello Parent ' + i });
}
const editor = await getEditor({ page });
await expect(
editor.getByText('Hello Parent 1', { exact: true }),
).not.toBeInViewport();
await expect(editor.getByText('Hello Parent 14')).toBeInViewport();
const { name: docChild } = await createRootSubPage(
page,
browserName,
'doc-scroll-child',
);
for (let i = 0; i < 15; i++) {
await page.keyboard.press('Enter');
await writeInEditor({ page, text: 'Hello Child ' + i });
}
await expect(
editor.getByText('Hello Child 1', { exact: true }),
).not.toBeInViewport();
await expect(editor.getByText('Hello Child 14')).toBeInViewport();
await navigateToPageFromTree({ page, title: randomDoc });
await expect(
editor.getByText('Hello Parent 1', { exact: true }),
).toBeInViewport();
await expect(editor.getByText('Hello Parent 14')).not.toBeInViewport();
await navigateToPageFromTree({ page, title: docChild });
await expect(
editor.getByText('Hello Child 1', { exact: true }),
).toBeInViewport();
await expect(editor.getByText('Hello Child 14')).not.toBeInViewport();
});
test('it embeds PDF', async ({ page, browserName }) => {
await createDoc(page, 'doc-toolbar', browserName, 1);
await openSuggestionMenu({ page });
await page.getByText('Embed a PDF file').click();
const pdfBlock = page.locator('div[data-content-type="pdf"]').first();
await expect(pdfBlock).toBeVisible();
await page.getByText(/Add (PDF|file)/).click();
const fileChooserPromise = page.waitForEvent('filechooser');
const downloadPromise = page.waitForEvent('download');
await page.getByText(/Upload (PDF|file)/).click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(path.join(__dirname, 'assets/test-pdf.pdf'));
// Wait for the media-check to be processed
await page.waitForTimeout(1000);
const pdfEmbed = page
.locator('.--docs--editor-container embed.bn-visual-media')
.first();
// Check src of pdf
expect(await pdfEmbed.getAttribute('src')).toMatch(
/http:\/\/localhost:8083\/media\/.*\/attachments\/.*.pdf/,
);
await expect(pdfEmbed).toHaveAttribute('type', 'application/pdf');
await expect(pdfEmbed).toHaveAttribute('role', 'presentation');
// Check download with original filename
await page.locator('.bn-block-content[data-content-type="pdf"]').click();
await page.locator('[data-test="downloadfile"]').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('test-pdf.pdf');
page.locator(
"span[data-inline-content-type='interlinkingSearchInline'] input",
),
).toBeVisible();
});
});

View File

@@ -2,15 +2,15 @@ import path from 'path';
import { expect, test } from '@playwright/test';
import cs from 'convert-stream';
import { PDFParse } from 'pdf-parse';
import pdf from 'pdf-parse';
import {
TestLanguage,
createDoc,
randomName,
verifyDocName,
waitForLanguageSwitch,
} from './utils-common';
import { openSuggestionMenu, writeInEditor } from './utils-editor';
import { createRootSubPage } from './utils-sub-pages';
test.beforeEach(async ({ page }) => {
@@ -31,7 +31,7 @@ test.describe('Doc Export', () => {
await expect(page.getByTestId('modal-export-title')).toBeVisible();
await expect(
page.getByText('Download your document in a .docx, .odt or .pdf format.'),
page.getByText('Download your document in a .docx or .pdf format.'),
).toBeVisible();
await expect(
page.getByRole('combobox', { name: 'Template' }),
@@ -58,16 +58,20 @@ test.describe('Doc Export', () => {
await verifyDocName(page, randomDoc);
const editor = await writeInEditor({ page, text: 'Hello' });
const editor = page.locator('.ProseMirror.bn-editor');
await editor.click();
await editor.locator('.bn-block-outer').last().fill('Hello');
await page.keyboard.press('Enter');
await openSuggestionMenu({ page });
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Page Break').click();
await expect(
editor.locator('div[data-content-type="pageBreak"]'),
).toBeVisible();
await expect(editor.locator('.bn-page-break')).toBeVisible();
await writeInEditor({ page, text: 'World' });
await page.keyboard.press('Enter');
await editor.locator('.bn-block-outer').last().fill('World');
await page
.getByRole('button', {
@@ -85,16 +89,10 @@ test.describe('Doc Export', () => {
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfParse = new PDFParse({ data: pdfBuffer });
const pdfInfo = await pdfParse.getInfo();
const pdfText = await pdfParse.getText();
const pdfData = await pdf(pdfBuffer);
expect(pdfInfo.total).toBe(2);
expect(pdfText.pages).toStrictEqual([
{ text: 'Hello', num: 1 },
{ text: 'World', num: 2 },
]);
expect(pdfInfo?.info.Title).toBe(randomDoc);
expect(pdfData.numpages).toBe(2);
expect(pdfData.text).toContain('\n\nHello\n\nWorld'); // This is the doc text
});
test('it exports the doc to docx', async ({ page, browserName }) => {
@@ -142,51 +140,6 @@ test.describe('Doc Export', () => {
expect(download.suggestedFilename()).toBe(`${randomDoc}.docx`);
});
test('it exports the doc to odt', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-editor-odt', browserName, 1);
await verifyDocName(page, randomDoc);
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World ODT');
await page.keyboard.press('Enter');
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Resizable image with caption').click();
const fileChooserPromise = page.waitForEvent('filechooser');
await page.getByText('Upload image').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(path.join(__dirname, 'assets/test.svg'));
const image = page
.locator('.--docs--editor-container img.bn-visual-media')
.first();
await expect(image).toBeVisible();
await page
.getByRole('button', {
name: 'Export the document',
})
.click();
await page.getByRole('combobox', { name: 'Format' }).click();
await page.getByRole('option', { name: 'Odt' }).click();
await expect(page.getByTestId('doc-export-download-button')).toBeVisible();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.odt`);
});
void page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDoc}.odt`);
});
/**
* This test tell us that the export to pdf is working with images
* but it does not tell us if the images are being displayed correctly
@@ -199,13 +152,11 @@ test.describe('Doc Export', () => {
await verifyDocName(page, randomDoc);
await writeInEditor({
page,
text: 'Hello World 😃🎉🚀🙋‍♀️🧑🏿‍❤️‍💋‍🧑🏾',
});
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
await page.keyboard.press('Enter');
await openSuggestionMenu({ page });
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Resizable image with caption').click();
const fileChooserPromise = page.waitForEvent('filechooser');
@@ -268,10 +219,10 @@ test.describe('Doc Export', () => {
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfExport = await pdf(pdfBuffer);
const pdfText = pdfExport.text;
const pdfParse = new PDFParse({ data: pdfBuffer });
const pdfText = await pdfParse.getText();
expect(pdfText.text).toContain('Hello World');
expect(pdfText).toContain('Hello World');
});
test('it exports the doc with quotes', async ({ page, browserName }) => {
@@ -314,9 +265,52 @@ test.describe('Doc Export', () => {
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfParse = new PDFParse({ data: pdfBuffer });
const pdfText = await pdfParse.getText();
expect(pdfText.text).toContain('Hello World');
const pdfData = await pdf(pdfBuffer);
expect(pdfData.text).toContain('Hello World'); // This is the pdf text
});
/**
* We cannot assert the line break is visible in the pdf, but we can assert the
* line break is visible in the editor and that the pdf is generated.
*/
test('it exports the doc with divider', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'export-divider', browserName, 1);
const editor = page.locator('.ProseMirror');
await editor.click();
await editor.fill('Hello World');
// Trigger slash menu to show menu
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Add a horizontal line').click();
await expect(
editor.locator('.bn-block-content[data-content-type="divider"]'),
).toBeVisible();
await page
.getByRole('button', {
name: 'Export the document',
})
.click();
await expect(
page.getByTestId('doc-open-modal-download-button'),
).toBeVisible();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
});
void page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfData = await pdf(pdfBuffer);
expect(pdfData.text).toContain('Hello World');
});
test('it exports the doc with multi columns', async ({
@@ -369,33 +363,46 @@ test.describe('Doc Export', () => {
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfParse = new PDFParse({ data: pdfBuffer });
const pdfText = await pdfParse.getText();
expect(pdfText.text).toContain('Column 1');
expect(pdfText.text).toContain('Column 2');
expect(pdfText.text).toContain('Column 3');
const pdfData = await pdf(pdfBuffer);
expect(pdfData.text).toContain('Column 1');
expect(pdfData.text).toContain('Column 2');
expect(pdfData.text).toContain('Column 3');
});
test('it injects the correct language attribute into PDF export', async ({
page,
browserName,
}) => {
const [randomDocFrench] = await createDoc(
page,
'doc-language-export-french',
browserName,
1,
);
await waitForLanguageSwitch(page, TestLanguage.French);
// Wait for the page to be ready after language switch
await page.waitForLoadState('domcontentloaded');
await writeInEditor({
page,
text: 'Contenu de test pour export en français',
});
const header = page.locator('header').first();
await header.locator('h1').getByText('Docs').click();
const randomDocFrench = randomName(
'doc-language-export-french',
browserName,
1,
)[0];
await page
.getByRole('button', {
name: 'Nouveau doc',
})
.click();
const input = page.locator('.--docs--doc-title-input[role="textbox"]');
await expect(input).toBeVisible();
await expect(input).toHaveText('', { timeout: 10000 });
await input.click();
await input.fill(randomDocFrench);
await input.blur();
const editor = page.locator('.ProseMirror.bn-editor');
await editor.click();
await editor.fill('Contenu de test pour export en français');
await page
.getByRole('button', {
@@ -403,10 +410,6 @@ test.describe('Doc Export', () => {
})
.click();
await expect(
page.getByTestId('doc-open-modal-download-button'),
).toBeVisible();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDocFrench}.pdf`);
});
@@ -461,7 +464,7 @@ test.describe('Doc Export', () => {
// Search the interlinking link in the editor (not in the document tree)
const editor = page.locator('.ProseMirror.bn-editor');
const interlink = editor.getByRole('button', {
const interlink = editor.getByRole('link', {
name: randomDoc,
});
@@ -483,72 +486,8 @@ test.describe('Doc Export', () => {
expect(download.suggestedFilename()).toBe(`${docChild}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfParse = new PDFParse({ data: pdfBuffer });
const pdfText = await pdfParse.getText();
expect(pdfText.text).toContain(randomDoc);
});
const pdfData = await pdf(pdfBuffer);
test('it exports the doc with interlinking to odt', async ({
page,
browserName,
}) => {
const [randomDoc] = await createDoc(
page,
'export-interlinking-odt',
browserName,
1,
);
await verifyDocName(page, randomDoc);
const { name: docChild } = await createRootSubPage(
page,
browserName,
'export-interlink-child-odt',
);
await verifyDocName(page, docChild);
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Link a doc').first().click();
const input = page.locator(
"span[data-inline-content-type='interlinkingSearchInline'] input",
);
const searchContainer = page.locator('.quick-search-container');
await input.fill('export-interlink');
await expect(searchContainer).toBeVisible();
await expect(searchContainer.getByText(randomDoc)).toBeVisible();
// We are in docChild, we want to create a link to randomDoc (parent)
await searchContainer.getByText(randomDoc).click();
// Search the interlinking link in the editor (not in the document tree)
const editor = page.locator('.ProseMirror.bn-editor');
const interlink = editor.getByRole('button', {
name: randomDoc,
});
await expect(interlink).toBeVisible();
await page
.getByRole('button', {
name: 'Export the document',
})
.click();
await page.getByRole('combobox', { name: 'Format' }).click();
await page.getByRole('option', { name: 'Odt' }).click();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${docChild}.odt`);
});
void page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${docChild}.odt`);
expect(pdfData.text).toContain(randomDoc);
});
});

View File

@@ -106,7 +106,7 @@ test.describe('Doc grid dnd', () => {
await expect(dragOverlay).toBeVisible();
await expect(dragOverlay).toHaveText(
'You must be at least the administrator of the target document',
'You must be at least the editor of the target document',
);
await page.mouse.up();

View File

@@ -1,7 +1,6 @@
import { expect, test } from '@playwright/test';
import { createDoc, getGridRow, verifyDocName } from './utils-common';
import { addNewMember, connectOtherUserToDoc } from './utils-share';
import { createDoc, getGridRow } from './utils-common';
type SmallDoc = {
id: string;
@@ -12,7 +11,7 @@ test.describe('Documents Grid mobile', () => {
test.use({ viewport: { width: 500, height: 1200 } });
test('it checks the grid when mobile', async ({ page }) => {
await page.route(/.*\/documents\/.*/, async (route) => {
await page.route('**/documents/**', async (route) => {
const request = route.request();
if (request.method().includes('GET') && request.url().includes('page=')) {
await route.fulfill({
@@ -29,7 +28,7 @@ test.describe('Documents Grid mobile', () => {
id: '8c1e047a-24e7-4a80-942b-8e9c7ab43e1f',
user: {
id: '7380f42f-02eb-4ad5-b8f0-037a0e66066d',
email: 'test.test@test.test',
email: 'test@test.test',
full_name: 'John Doe',
short_name: 'John',
},
@@ -118,7 +117,7 @@ test.describe('Document grid item options', () => {
await page.getByText('push_pin').click();
// Check is pinned
await expect(row.getByTestId('doc-pinned-icon')).toBeVisible();
await expect(row.locator('[data-testid^="doc-pinned-"]')).toBeVisible();
const leftPanelFavorites = page.getByTestId('left-panel-favorites');
await expect(leftPanelFavorites.getByText(docTitle)).toBeVisible();
@@ -127,22 +126,20 @@ test.describe('Document grid item options', () => {
await page.getByText('Unpin').click();
// Check is unpinned
await expect(row.getByTestId('doc-pinned-icon')).toBeHidden();
await expect(row.locator('[data-testid^="doc-pinned-"]')).toBeHidden();
await expect(leftPanelFavorites.getByText(docTitle)).toBeHidden();
});
test('it deletes the document', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, `delete doc`, browserName);
await verifyDocName(page, docTitle);
await page.goto('/');
await expect(page.getByText(docTitle)).toBeVisible();
const row = await getGridRow(page, docTitle);
await row.getByText(`more_horiz`).click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('menuitem', { name: 'Remove' }).click();
await expect(
page.getByRole('heading', { name: 'Delete a doc' }),
@@ -164,7 +161,7 @@ test.describe('Document grid item options', () => {
test("it checks if the delete option is disabled if we don't have the destroy capability", async ({
page,
}) => {
await page.route(/.*\/api\/v1.0\/documents\/\?page=1/, async (route) => {
await page.route('*/**/api/v1.0/documents/?page=1', async (route) => {
await route.fulfill({
json: {
results: [
@@ -195,68 +192,90 @@ test.describe('Document grid item options', () => {
});
await page.goto('/');
const button = page
.getByTestId(`docs-grid-actions-button-mocked-document-id`)
.first();
const button = page.getByTestId(
`docs-grid-actions-button-mocked-document-id`,
);
await expect(button).toBeVisible();
await button.click();
const removeButton = page
.getByTestId(`docs-grid-actions-remove-mocked-document-id`)
.first();
const removeButton = page.getByTestId(
`docs-grid-actions-remove-mocked-document-id`,
);
await expect(removeButton).toBeVisible();
await removeButton.isDisabled();
});
});
test.describe('Documents filters', () => {
test('it checks the left panel filters', async ({ page, browserName }) => {
test('it checks the prebuild left panel filters', async ({ page }) => {
void page.goto('/');
// Create my doc
const [docName] = await createDoc(page, 'my-doc', browserName, 1);
await verifyDocName(page, docName);
// Another user create a doc and share it with me
const { cleanup, otherPage, otherBrowserName } =
await connectOtherUserToDoc({
browserName,
docUrl: '/',
});
const [docShareName] = await createDoc(
otherPage,
'my-share-doc',
otherBrowserName,
1,
);
await verifyDocName(otherPage, docShareName);
await otherPage.getByRole('button', { name: 'Share' }).click();
await addNewMember(otherPage, 0, 'Editor', browserName);
// Let's check the filters
await page.getByRole('button', { name: 'Back to homepage' }).click();
const row = await getGridRow(page, docName);
const rowShare = await getGridRow(page, docShareName);
// All Docs
await expect(row).toBeVisible();
await expect(rowShare).toBeVisible();
const response = await page.waitForResponse(
(response) =>
response.url().endsWith('documents/?page=1') &&
response.status() === 200,
);
const result = await response.json();
const allCount = result.count as number;
await expect(page.getByTestId('grid-loader')).toBeHidden();
// My Docs
await page.getByRole('link', { name: 'My docs' }).click();
await expect(row).toBeVisible();
await expect(rowShare).toBeHidden();
const allDocs = page.getByLabel('All docs');
const myDocs = page.getByLabel('My docs');
const sharedWithMe = page.getByLabel('Shared with me');
// Initial state
await expect(allDocs).toBeVisible();
await expect(allDocs).toHaveAttribute('aria-current', 'page');
await expect(myDocs).toBeVisible();
await expect(myDocs).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
await expect(myDocs).not.toHaveAttribute('aria-current');
await expect(sharedWithMe).toBeVisible();
await expect(sharedWithMe).toHaveCSS(
'background-color',
'rgba(0, 0, 0, 0)',
);
await expect(sharedWithMe).not.toHaveAttribute('aria-current');
await allDocs.click();
await page.waitForURL('**/?target=all_docs');
let url = new URL(page.url());
let target = url.searchParams.get('target');
expect(target).toBe('all_docs');
// My docs
await myDocs.click();
url = new URL(page.url());
target = url.searchParams.get('target');
expect(target).toBe('my_docs');
const responseMyDocs = await page.waitForResponse(
(response) =>
response.url().endsWith('documents/?page=1&is_creator_me=true') &&
response.status() === 200,
);
const resultMyDocs = await responseMyDocs.json();
const countMyDocs = resultMyDocs.count as number;
await expect(page.getByTestId('grid-loader')).toBeHidden();
expect(countMyDocs).toBeLessThanOrEqual(allCount);
// Shared with me
await page.getByRole('link', { name: 'Shared with me' }).click();
await expect(row).toBeHidden();
await expect(rowShare).toBeVisible();
await cleanup();
await sharedWithMe.click();
url = new URL(page.url());
target = url.searchParams.get('target');
expect(target).toBe('shared_with_me');
const responseSharedWithMe = await page.waitForResponse(
(response) =>
response.url().includes('documents/?page=1&is_creator_me=false') &&
response.status() === 200,
);
const resultSharedWithMe = await responseSharedWithMe.json();
const countSharedWithMe = resultSharedWithMe.count as number;
await expect(page.getByTestId('grid-loader')).toBeHidden();
expect(countSharedWithMe).toBeLessThanOrEqual(allCount);
expect(countSharedWithMe + countMyDocs).toEqual(allCount);
});
});

View File

@@ -8,7 +8,7 @@ import {
verifyDocName,
} from './utils-common';
import { mockedAccesses, mockedInvitations } from './utils-share';
import { createRootSubPage, getTreeRow } from './utils-sub-pages';
import { createRootSubPage } from './utils-sub-pages';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -65,74 +65,38 @@ test.describe('Doc Header', () => {
page,
browserName,
}) => {
await createDoc(page, 'doc-update-emoji', browserName, 1);
const emojiPicker = page.locator('.--docs--doc-title').getByRole('button');
const optionMenu = page.getByLabel('Open the document options');
const addEmojiMenuItem = page.getByRole('menuitem', { name: 'Add emoji' });
const removeEmojiMenuItem = page.getByRole('menuitem', {
name: 'Remove emoji',
});
// Top parent should not have emoji picker
await expect(emojiPicker).toBeHidden();
await optionMenu.click();
await expect(addEmojiMenuItem).toBeHidden();
await expect(removeEmojiMenuItem).toBeHidden();
await page.keyboard.press('Escape');
const { name: docChild } = await createRootSubPage(
page,
browserName,
'doc-update-emoji-child',
);
await verifyDocName(page, docChild);
// Emoji picker should be hidden initially
await expect(emojiPicker).toBeHidden();
// Add emoji
await optionMenu.click();
await expect(removeEmojiMenuItem).toBeHidden();
await addEmojiMenuItem.click();
await expect(emojiPicker).toHaveText('📄');
// Change emoji
await emojiPicker.click({
delay: 100,
});
await page.getByRole('button', { name: '😀' }).first().click();
await expect(emojiPicker).toHaveText('😀');
// Update title
await createDoc(page, 'doc-update', browserName, 1);
const docTitle = page.getByRole('textbox', { name: 'Document title' });
await docTitle.fill('Hello Emoji World');
await expect(docTitle).toBeVisible();
await docTitle.fill('👍 Hello Emoji World');
await docTitle.blur();
await verifyDocName(page, 'Hello Emoji World');
await verifyDocName(page, '👍 Hello Emoji World');
// Check the tree
const row = await getTreeRow(page, 'Hello Emoji World');
await expect(row.getByText('😀')).toBeVisible();
const docTree = page.getByTestId('doc-tree');
await expect(docTree.getByText('Hello Emoji World')).toBeVisible();
await expect(docTree.getByLabel('Document emoji icon')).toBeVisible();
await expect(docTree.getByLabel('Simple document icon')).toBeHidden();
// Remove emoji
await optionMenu.click();
await expect(addEmojiMenuItem).toBeHidden();
await removeEmojiMenuItem.click();
await expect(emojiPicker).toBeHidden();
await page.getByTestId('home-button').click();
// Check the documents grid
const gridRow = await getGridRow(page, 'Hello Emoji World');
await expect(gridRow.getByLabel('Document emoji icon')).toBeVisible();
await expect(gridRow.getByLabel('Simple document icon')).toBeHidden();
});
test('it deletes the doc', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-delete', browserName, 1);
await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Delete document' }).click();
await page.getByLabel('Delete document').click();
await expect(
page.getByRole('heading', { name: 'Delete a doc' }),
).toBeVisible();
await expect(page.getByText(`This document will be`)).toBeVisible();
await expect(page.getByText(`This document and any sub-`)).toBeVisible();
await page
.getByRole('button', {
@@ -178,19 +142,13 @@ test.describe('Doc Header', () => {
await goToGridDoc(page);
await expect(
page.getByRole('textbox', { name: 'Document title' }),
).toContainText('Mocked document');
await expect(
page.getByRole('button', { name: 'Export the document' }),
).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
await expect(page.getByLabel('Delete document')).toBeDisabled();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
@@ -206,7 +164,7 @@ test.describe('Doc Header', () => {
const invitationCard = shareModal.getByLabel('List invitation card');
await expect(invitationCard).toBeVisible();
await expect(
invitationCard.getByText('test.test@invitation.test').first(),
invitationCard.getByText('test@invitation.test').first(),
).toBeVisible();
const invitationRole = invitationCard.getByLabel('doc-role-dropdown');
await expect(invitationRole).toBeVisible();
@@ -220,7 +178,7 @@ test.describe('Doc Header', () => {
const roles = memberCard.getByLabel('doc-role-dropdown');
await expect(memberCard).toBeVisible();
await expect(
memberCard.getByText('test.test@accesses.test').first(),
memberCard.getByText('test@accesses.test').first(),
).toBeVisible();
await expect(roles).toBeVisible();
@@ -258,18 +216,12 @@ test.describe('Doc Header', () => {
await goToGridDoc(page);
await expect(
page.getByRole('textbox', { name: 'Document title' }),
).toContainText('Mocked document');
await expect(
page.getByRole('button', { name: 'Export the document' }),
).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
await expect(page.getByLabel('Delete document')).toBeDisabled();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
@@ -287,7 +239,7 @@ test.describe('Doc Header', () => {
const invitationCard = shareModal.getByLabel('List invitation card');
await expect(invitationCard).toBeVisible();
await expect(
invitationCard.getByText('test.test@invitation.test').first(),
invitationCard.getByText('test@invitation.test').first(),
).toBeVisible();
await expect(invitationCard.getByLabel('Document role text')).toBeVisible();
await expect(
@@ -295,7 +247,7 @@ test.describe('Doc Header', () => {
).toBeHidden();
const memberCard = shareModal.getByLabel('List members card');
await expect(memberCard.getByText('test.test@accesses.test')).toBeVisible();
await expect(memberCard.getByText('test@accesses.test')).toBeVisible();
await expect(memberCard.getByLabel('Document role text')).toBeVisible();
await expect(
memberCard.getByRole('button', { name: 'more_horiz' }),
@@ -330,18 +282,12 @@ test.describe('Doc Header', () => {
await goToGridDoc(page);
await expect(
page.getByRole('heading', { name: 'Mocked document' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Export the document' }),
).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
await expect(page.getByLabel('Delete document')).toBeDisabled();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
@@ -356,7 +302,7 @@ test.describe('Doc Header', () => {
const invitationCard = shareModal.getByLabel('List invitation card');
await expect(invitationCard).toBeVisible();
await expect(
invitationCard.getByText('test.test@invitation.test').first(),
invitationCard.getByText('test@invitation.test').first(),
).toBeVisible();
await expect(invitationCard.getByLabel('Document role text')).toBeVisible();
await expect(
@@ -364,7 +310,7 @@ test.describe('Doc Header', () => {
).toBeHidden();
const memberCard = shareModal.getByLabel('List members card');
await expect(memberCard.getByText('test.test@accesses.test')).toBeVisible();
await expect(memberCard.getByText('test@accesses.test')).toBeVisible();
await expect(memberCard.getByLabel('Document role text')).toBeVisible();
await expect(
memberCard.getByRole('button', { name: 'more_horiz' }),
@@ -397,7 +343,7 @@ test.describe('Doc Header', () => {
// Copy content to clipboard
await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Copy as Markdown' }).click();
await page.getByLabel('Copy as Markdown').click();
await expect(page.getByText('Copied to clipboard')).toBeVisible();
// Test that clipboard is in Markdown format
@@ -431,7 +377,7 @@ test.describe('Doc Header', () => {
// Copy content to clipboard
await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Copy as HTML' }).click();
await page.getByLabel('Copy as HTML').click();
await expect(page.getByText('Copied to clipboard')).toBeVisible();
// Test that clipboard is in HTML format
@@ -488,15 +434,11 @@ test.describe('Doc Header', () => {
test('it pins a document', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, `Pin doc`, browserName);
await page
.getByRole('button', { name: 'Open the document options' })
.click();
await page.getByLabel('Open the document options').click();
// Pin
await page.getByText('push_pin').click();
await page
.getByRole('button', { name: 'Open the document options' })
.click();
await page.getByLabel('Open the document options').click();
await expect(page.getByText('Unpin')).toBeVisible();
await page.goto('/');
@@ -504,26 +446,22 @@ test.describe('Doc Header', () => {
const row = await getGridRow(page, docTitle);
// Check is pinned
await expect(row.getByTestId('doc-pinned-icon')).toBeVisible();
await expect(row.locator('[data-testid^="doc-pinned-"]')).toBeVisible();
const leftPanelFavorites = page.getByTestId('left-panel-favorites');
await expect(leftPanelFavorites.getByText(docTitle)).toBeVisible();
await row.getByText(docTitle).click();
await page
.getByRole('button', { name: 'Open the document options' })
.click();
await page.getByLabel('Open the document options').click();
// Unpin
await page.getByText('Unpin').click();
await page
.getByRole('button', { name: 'Open the document options' })
.click();
await page.getByLabel('Open the document options').click();
await expect(page.getByText('push_pin')).toBeVisible();
await page.goto('/');
// Check is unpinned
await expect(row.getByTestId('doc-pinned-icon')).toBeHidden();
await expect(row.locator('[data-testid^="doc-pinned-"]')).toBeHidden();
await expect(leftPanelFavorites.getByText(docTitle)).toBeHidden();
});
@@ -622,7 +560,7 @@ test.describe('Documents Header mobile', () => {
await expect(
page.getByRole('menuitem', { name: 'Copy link' }),
).toBeVisible();
await page.getByRole('menuitem', { name: 'Share' }).click();
await page.getByLabel('Share').click();
await expect(page.getByRole('button', { name: 'Copy link' })).toBeVisible();
});
@@ -645,7 +583,7 @@ test.describe('Documents Header mobile', () => {
await goToGridDoc(page);
await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Share' }).click();
await page.getByLabel('Share').click();
const shareModal = page.getByRole('dialog', {
name: 'Share modal content',

View File

@@ -18,7 +18,7 @@ test.describe('Inherited share accesses', () => {
).toBeVisible();
const user = page.getByTestId(
`doc-share-member-row-user.test@${browserName}.test`,
`doc-share-member-row-user@${browserName}.test`,
);
await expect(user).toBeVisible();
await expect(user.getByText('E2E Chromium')).toBeVisible();

View File

@@ -7,8 +7,6 @@ import {
randomName,
verifyDocName,
} from './utils-common';
import { writeInEditor } from './utils-editor';
import { connectOtherUserToDoc, updateRoleUser } from './utils-share';
import { createRootSubPage } from './utils-sub-pages';
test.describe('Document create member', () => {
@@ -27,8 +25,9 @@ test.describe('Document create member', () => {
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByTestId('quick-search-input');
const inputSearch = page.getByRole('combobox', {
name: 'Quick search input',
});
await expect(inputSearch).toBeVisible();
// Select user 1 and verify tag
@@ -75,15 +74,13 @@ test.describe('Document create member', () => {
// Check roles are displayed
await list.getByLabel('doc-role-dropdown').click();
await expect(page.getByRole('menuitem', { name: 'Reader' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Editor' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Owner' })).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Administrator' }),
).toBeVisible();
await expect(page.getByLabel('Reader')).toBeVisible();
await expect(page.getByLabel('Editor')).toBeVisible();
await expect(page.getByLabel('Owner')).toBeVisible();
await expect(page.getByLabel('Administrator')).toBeVisible();
// Validate
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await page.getByLabel('Administrator').click();
await page.getByRole('button', { name: 'Invite' }).click();
// Check invitation added
@@ -120,7 +117,9 @@ test.describe('Document create member', () => {
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByTestId('quick-search-input');
const inputSearch = page.getByRole('combobox', {
name: 'Quick search input',
});
const [email] = randomName('test@test.fr', browserName, 1);
await inputSearch.fill(email);
@@ -129,7 +128,7 @@ test.describe('Document create member', () => {
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByLabel('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Owner' }).click();
await page.getByLabel('Owner').click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
@@ -147,7 +146,7 @@ test.describe('Document create member', () => {
// Choose a role
await container.getByLabel('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Owner' }).click();
await page.getByLabel('Owner').click();
const responsePromiseCreateInvitationFail = page.waitForResponse(
(response) =>
@@ -168,23 +167,18 @@ test.describe('Document create member', () => {
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByTestId('quick-search-input');
const inputSearch = page.getByRole('combobox', {
name: 'Quick search input',
});
let email = 'user.test21@example.COM';
const email = randomName('test@test.fr', browserName, 1)[0];
await inputSearch.fill(email);
// Check email is found in search (case insensitive)
await expect(page.getByRole('option').getByText(email)).toHaveCount(1);
email = email + 'M';
await inputSearch.fill(email);
await page.getByTestId(`search-user-row-${email}`).click();
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByLabel('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await page.getByLabel('Administrator').click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
@@ -199,32 +193,26 @@ test.describe('Document create member', () => {
const listInvitation = page.getByTestId('doc-share-quick-search');
const userInvitation = listInvitation.getByTestId(
`doc-share-invitation-row-${email.toLowerCase()}`,
`doc-share-invitation-row-${email}`,
);
await expect(userInvitation).toBeVisible();
const responsePromisePatchInvitation = page.waitForResponse(
(response) =>
response.url().includes('/invitations/') &&
response.status() === 200 &&
response.request().method() === 'PATCH',
);
await userInvitation.getByLabel('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Reader' }).click();
const responsePatchInvitation = await responsePromisePatchInvitation;
expect(responsePatchInvitation.ok()).toBeTruthy();
await page.getByLabel('Reader').click();
const moreActions = userInvitation.getByRole('button', {
name: 'Open invitation actions menu',
});
await moreActions.click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByLabel('Delete').click();
await expect(userInvitation).toBeHidden();
});
});
test.describe('Document create member: Multiple login', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('It creates a member from a request coming from a 403 page', async ({
page,
@@ -232,6 +220,9 @@ test.describe('Document create member', () => {
}) => {
test.slow();
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docTitle] = await createDoc(
page,
'Member access request',
@@ -241,63 +232,67 @@ test.describe('Document create member', () => {
await verifyDocName(page, docTitle);
await writeInEditor({ page, text: 'Hello World' });
const urlDoc = page.url();
const docUrl = page.url();
await page
.getByRole('button', {
name: 'Logout',
})
.click();
// Other user will request access
const { otherPage, otherBrowserName, cleanup } =
await connectOtherUserToDoc({ browserName, docUrl });
const otherBrowser = BROWSERS.find((b) => b !== browserName);
await keyCloakSignIn(page, otherBrowser!);
await expect(page.getByTestId('header-logo-link')).toBeVisible();
await page.goto(urlDoc);
await expect(
otherPage.getByText('Insufficient access rights to view the document.'),
page.getByText('Insufficient access rights to view the document.'),
).toBeVisible({
timeout: 10000,
});
await otherPage.getByRole('button', { name: 'Request access' }).click();
await page.getByRole('button', { name: 'Request access' }).click();
await expect(
otherPage.getByText('Your access request for this document is pending.'),
page.getByText('Your access request for this document is pending.'),
).toBeVisible();
// First user approves the request
await page
.getByRole('button', {
name: 'Logout',
})
.click();
await page.goto('/');
await keyCloakSignIn(page, browserName);
await expect(page.getByTestId('header-logo-link')).toBeVisible({
timeout: 10000,
});
await page.goto(urlDoc);
await page.getByRole('button', { name: 'Share' }).click();
await expect(page.getByText('Access Requests')).toBeVisible();
await expect(page.getByText(`E2E ${otherBrowserName}`)).toBeVisible();
await expect(page.getByText(`E2E ${otherBrowser}`)).toBeVisible();
const emailRequest = `user.test@${otherBrowserName}.test`;
const emailRequest = `user@${otherBrowser}.test`;
await expect(page.getByText(emailRequest)).toBeVisible();
const container = page.getByTestId(
`doc-share-access-request-row-${emailRequest}`,
);
await container.getByLabel('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await page.getByLabel('Administrator').click();
await container.getByRole('button', { name: 'Approve' }).click();
await expect(page.getByText('Access Requests')).toBeHidden();
await expect(page.getByText('Share with 2 users')).toBeVisible();
await expect(page.getByText(`E2E ${otherBrowserName}`)).toBeVisible();
// Other user verifies he has access
await otherPage.reload();
await verifyDocName(otherPage, docTitle);
await expect(otherPage.getByText('Hello World')).toBeVisible();
// Revoke access
await updateRoleUser(page, 'Remove access', emailRequest);
await expect(
otherPage.getByText('Insufficient access rights to view the document.'),
).toBeVisible();
// Cleanup: other user logout
await cleanup();
await expect(page.getByText(`E2E ${otherBrowser}`)).toBeVisible();
});
});
test.describe('Document create member: Multiple login', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('It cannot request member access on child doc on a 403 page', async ({
page,

View File

@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { createDoc, verifyDocName } from './utils-common';
import { createDoc, goToGridDoc, verifyDocName } from './utils-common';
import { addNewMember } from './utils-share';
test.beforeEach(async ({ page }) => {
@@ -8,14 +8,8 @@ test.beforeEach(async ({ page }) => {
});
test.describe('Document list members', () => {
test('it checks a big list of members', async ({ page, browserName }) => {
const [docTitle] = await createDoc(
page,
'members-big-members-list',
browserName,
1,
);
test('it checks a big list of members', async ({ page }) => {
const docTitle = await goToGridDoc(page);
await verifyDocName(page, docTitle);
// Get the current URL and extract the last part
@@ -31,7 +25,7 @@ test.describe('Document list members', () => {
return cleanUrl.split('/').pop() || '';
})();
await page.route(/.*\/documents\/.*\/accesses\//, async (route) => {
await page.route('**/documents/**/accesses/', async (route) => {
const request = route.request();
const url = new URL(request.url());
const pageId = url.searchParams.get('page') ?? '1';
@@ -79,7 +73,7 @@ test.describe('Document list members', () => {
await expect(loadMore).toBeHidden();
});
test('it checks a big list of invitations', async ({ page, browserName }) => {
test('it checks a big list of invitations', async ({ page }) => {
await page.route(
/.*\/documents\/.*\/invitations\/\?page=.*/,
async (route) => {
@@ -114,12 +108,7 @@ test.describe('Document list members', () => {
},
);
const [docTitle] = await createDoc(
page,
'members-big-invitation-list',
browserName,
1,
);
const docTitle = await goToGridDoc(page);
await verifyDocName(page, docTitle);
await page.getByRole('button', { name: 'Share' }).click();
@@ -150,7 +139,7 @@ test.describe('Document list members', () => {
const list = page.getByTestId('doc-share-quick-search');
await expect(list).toBeVisible();
const currentUser = list.getByTestId(
`doc-share-member-row-user.test@${browserName}.test`,
`doc-share-member-row-user@${browserName}.test`,
);
const currentUserRole = currentUser.getByLabel('doc-role-dropdown');
await expect(currentUser).toBeVisible();
@@ -182,12 +171,12 @@ test.describe('Document list members', () => {
});
await currentUserRole.click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await page.getByLabel('Administrator').click();
await list.click();
await expect(currentUserRole).toBeVisible();
await currentUserRole.click();
await page.getByRole('menuitem', { name: 'Reader' }).click();
await page.getByLabel('Reader').click();
await list.click();
await expect(currentUserRole).toBeHidden();
});
@@ -201,7 +190,7 @@ test.describe('Document list members', () => {
const list = page.getByTestId('doc-share-quick-search');
const emailMyself = `user.test@${browserName}.test`;
const emailMyself = `user@${browserName}.test`;
const mySelf = list.getByTestId(`doc-share-member-row-${emailMyself}`);
const mySelfRole = mySelf.getByRole('button', {
name: 'doc-role-dropdown',

View File

@@ -9,7 +9,6 @@ import {
mockedDocument,
verifyDocName,
} from './utils-common';
import { writeInEditor } from './utils-editor';
import { createRootSubPage } from './utils-sub-pages';
test.describe('Doc Routing', () => {
@@ -59,49 +58,41 @@ test.describe('Doc Routing', () => {
await createRootSubPage(page, browserName, '401-doc-child');
await writeInEditor({ page, text: 'Hello World' });
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
const responsePromise = page.route(
/.*\/documents\/.*\/$|users\/me\/$/,
async (route) => {
const request = route.request();
// Wait for the doc link (via its dynamic title) to be visible
const docLink = page.getByRole('link', { name: docTitle });
await expect(docLink).toBeVisible();
// When we quit a document, a PATCH request is sent to save the document.
// We intercept this request to simulate a 401 error from the backend.
// The GET request to users/me is also intercepted to simulate the user
// being logged out when trying to fetch user info.
// This way we can test the 401 error handling when saving the document
if (
(request.url().includes('/documents/') &&
request.method().includes('PATCH')) ||
(request.url().includes('/users/me/') &&
request.method().includes('GET'))
) {
await route.fulfill({
status: 401,
json: {
detail: 'Log in to access the document',
},
});
} else {
await route.continue();
}
},
// Intercept GET/PATCH requests to return 401
await page.route(/.*\/documents\/.*\/$|users\/me\/$/, async (route) => {
const request = route.request();
if (
request.method().includes('PATCH') ||
request.method().includes('GET')
) {
await route.fulfill({
status: 401,
json: { detail: 'Log in to access the document' },
});
} else {
await route.continue();
}
});
// Explicitly wait for a 401 response after clicking
const wait401 = page.waitForResponse(
(resp) =>
resp.status() === 401 &&
/\/(documents\/[^/]+\/|users\/me\/)$/.test(resp.url()),
);
await page.getByRole('link', { name: '401-doc-parent' }).click();
await responsePromise;
await docLink.click();
await wait401;
await expect(page.getByText('Log in to access the document.')).toBeVisible({
timeout: 10000,
});
await expect(page.locator('meta[name="robots"]')).toHaveAttribute(
'content',
'noindex',
);
await expect(page).toHaveTitle(/401 Unauthorized - Docs/);
});
});

View File

@@ -8,6 +8,8 @@ test.beforeEach(async ({ page }) => {
test.describe('Doc Table Content', () => {
test('it checks the doc table content', async ({ page, browserName }) => {
test.setTimeout(60000);
const [randomDoc] = await createDoc(
page,
'doc-table-content',

View File

@@ -1,155 +0,0 @@
import { expect, test } from '@playwright/test';
import {
clickInEditorMenu,
clickInGridMenu,
createDoc,
getGridRow,
verifyDocName,
} from './utils-common';
import { addNewMember } from './utils-share';
import {
addChild,
createRootSubPage,
navigateToPageFromTree,
} from './utils-sub-pages';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Trashbin', () => {
test('it controls UI and interaction from the grid page', async ({
page,
browserName,
}) => {
const [title1] = await createDoc(page, 'my-trash-doc-1', browserName, 1);
const [title2] = await createDoc(page, 'my-trash-doc-2', browserName, 1);
await verifyDocName(page, title2);
await page.getByRole('button', { name: 'Share' }).click();
await addNewMember(page, 0, 'Editor');
await page.getByRole('button', { name: 'close' }).click();
await page.getByRole('button', { name: 'Back to homepage' }).click();
const row1 = await getGridRow(page, title1);
await clickInGridMenu(page, row1, 'Delete');
await page.getByRole('button', { name: 'Delete document' }).click();
await expect(row1.getByText(title1)).toBeHidden();
const row2 = await getGridRow(page, title2);
await clickInGridMenu(page, row2, 'Delete');
await page.getByRole('button', { name: 'Delete document' }).click();
await expect(row2.getByText(title2)).toBeHidden();
await page.getByRole('link', { name: 'Trashbin' }).click();
const docsGrid = page.getByTestId('docs-grid');
await expect(docsGrid.getByText('Days remaining')).toBeVisible();
await expect(row1.getByText(title1)).toBeVisible();
await expect(row1.getByText('30 days')).toBeVisible();
await expect(row2.getByText(title2)).toBeVisible();
await expect(
row2.getByRole('button', {
name: 'Open the sharing settings for the document',
}),
).toBeVisible();
await expect(
row2.getByRole('button', {
name: 'Open the sharing settings for the document',
}),
).toBeDisabled();
await clickInGridMenu(page, row2, 'Restore');
await expect(row2.getByText(title2)).toBeHidden();
await page.getByRole('link', { name: 'All docs' }).click();
const row2Restored = await getGridRow(page, title2);
await expect(row2Restored.getByText(title2)).toBeVisible();
await row2Restored.getByRole('link', { name: /Open document/ }).click();
await verifyDocName(page, title2);
await page.getByRole('button', { name: 'Back to homepage' }).click();
await expect(row2.getByText(title2)).toBeVisible();
await expect(
row2.getByRole('button', {
name: 'Open the sharing settings for the document',
}),
).toBeEnabled();
await page.getByRole('link', { name: 'Trashbin' }).click();
await expect(row2.getByText(title2)).toBeHidden();
});
test('it controls UI and interaction from the doc page', async ({
page,
browserName,
}) => {
const [topParent] = await createDoc(
page,
'my-trash-editor-doc',
browserName,
1,
);
await verifyDocName(page, topParent);
const { name: subDocName } = await createRootSubPage(
page,
browserName,
'my-trash-editor-subdoc',
);
const subsubDocName = await addChild({
page,
browserName,
docParent: subDocName,
docName: 'my-trash-editor-subsubdoc',
});
await verifyDocName(page, subsubDocName);
await navigateToPageFromTree({ page, title: subDocName });
await verifyDocName(page, subDocName);
await clickInEditorMenu(page, 'Delete sub-document');
await page.getByRole('button', { name: 'Delete document' }).click();
await verifyDocName(page, topParent);
await page.getByRole('button', { name: 'Back to homepage' }).click();
await page.getByRole('link', { name: 'Trashbin' }).click();
const row = await getGridRow(page, subDocName);
await row.getByText(subDocName).click();
await verifyDocName(page, subDocName);
await expect(
page.locator('.--docs--editor-container.--docs--doc-deleted'),
).toBeVisible();
await expect(page.getByLabel('Alert deleted document')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeDisabled();
await expect(page.locator('.bn-editor')).toHaveAttribute(
'contenteditable',
'false',
);
const docTree = page.getByTestId('doc-tree');
await expect(docTree.getByText(topParent)).toBeHidden();
await expect(
docTree.getByText(subDocName, {
exact: true,
}),
).toBeVisible();
await expect(docTree.getByText(subsubDocName)).toBeVisible();
await expect(
docTree
.locator(".--docs-sub-page-item[aria-disabled='true']")
.getByText(subsubDocName),
).toBeVisible();
await page.getByRole('button', { name: 'Restore' }).click();
await expect(page.getByLabel('Alert deleted document')).toBeHidden();
await expect(page.locator('.bn-editor')).toHaveAttribute(
'contenteditable',
'true',
);
await expect(page.getByRole('button', { name: 'Share' })).toBeEnabled();
await expect(docTree.getByText(topParent)).toBeVisible();
});
});

View File

@@ -9,11 +9,7 @@ import {
verifyDocName,
} from './utils-common';
import { addNewMember } from './utils-share';
import {
clickOnAddRootSubPage,
createRootSubPage,
getTreeRow,
} from './utils-sub-pages';
import { clickOnAddRootSubPage, createRootSubPage } from './utils-sub-pages';
test.describe('Doc Tree', () => {
test.beforeEach(async ({ page }) => {
@@ -224,11 +220,11 @@ test.describe('Doc Tree', () => {
const list = page.getByTestId('doc-share-quick-search');
const currentUser = list.getByTestId(
`doc-share-member-row-user.test@${browserName}.test`,
`doc-share-member-row-user@${browserName}.test`,
);
const currentUserRole = currentUser.getByLabel('doc-role-dropdown');
await currentUserRole.click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await page.getByLabel('Administrator').click();
await list.click();
await page.getByRole('button', { name: 'Ok' }).click();
@@ -239,12 +235,6 @@ test.describe('Doc Tree', () => {
'doc-tree-detach-child',
);
await expect(
page
.getByLabel('It is the card information about the document.')
.getByText('Administrator ·'),
).toBeVisible();
const docTree = page.getByTestId('doc-tree');
await expect(docTree.getByText(docChild)).toBeVisible();
await docTree.click();
@@ -302,58 +292,6 @@ test.describe('Doc Tree', () => {
// Now test keyboard navigation on sub-document
await expect(docTree.getByText(docChild)).toBeVisible();
});
test('it updates the child icon from the tree', async ({
page,
browserName,
}) => {
const [docParent] = await createDoc(
page,
'doc-child-emoji',
browserName,
1,
);
await verifyDocName(page, docParent);
const { name: docChild } = await createRootSubPage(
page,
browserName,
'doc-child-emoji-child',
);
const row = await getTreeRow(page, docChild);
// Check Remove emoji is not present initially
await row.hover();
const menu = row.getByText(`more_horiz`);
await menu.click();
await expect(
page.getByRole('menuitem', { name: 'Remove emoji' }),
).toBeHidden();
// Close the menu
await page.keyboard.press('Escape');
// Update the emoji from the tree
await row.locator('.--docs--doc-icon').click();
await page.getByRole('button', { name: '😀' }).first().click();
// Verify the emoji is updated in the tree and in the document title
await expect(row.getByText('😀')).toBeVisible();
const titleEmojiPicker = page
.locator('.--docs--doc-title')
.getByRole('button');
await expect(titleEmojiPicker).toHaveText('😀');
// Now remove the emoji using the new action
await row.hover();
await menu.click();
await page.getByRole('menuitem', { name: 'Remove emoji' }).click();
await expect(row.getByText('😀')).toBeHidden();
await expect(titleEmojiPicker).toBeHidden();
});
});
test.describe('Doc Tree: Inheritance', () => {

View File

@@ -18,7 +18,7 @@ test.describe('Doc Version', () => {
await verifyDocName(page, randomDoc);
await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Version history' }).click();
await page.getByLabel('Version history').click();
await expect(page.getByText('History', { exact: true })).toBeVisible();
const modal = page.getByLabel('version history modal');
@@ -54,7 +54,7 @@ test.describe('Doc Version', () => {
).toBeVisible();
await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Version history' }).click();
await page.getByLabel('Version history').click();
await expect(panel).toBeVisible();
await expect(page.getByText('History', { exact: true })).toBeVisible();
@@ -82,9 +82,7 @@ test.describe('Doc Version', () => {
await verifyDocName(page, 'Mocked document');
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Version history' }),
).toBeDisabled();
await expect(page.getByLabel('Version history')).toBeDisabled();
});
test('it restores the doc version', async ({ page, browserName }) => {
@@ -111,7 +109,7 @@ test.describe('Doc Version', () => {
await expect(page.getByText('World')).toBeVisible();
await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Version history' }).click();
await page.getByLabel('Version history').click();
const modal = page.getByLabel('version history modal');
const panel = modal.getByLabel('version list');

View File

@@ -7,8 +7,6 @@ import {
keyCloakSignIn,
verifyDocName,
} from './utils-common';
import { getEditor, writeInEditor } from './utils-editor';
import { addNewMember, connectOtherUserToDoc } from './utils-share';
import { createRootSubPage } from './utils-sub-pages';
test.describe('Doc Visibility', () => {
@@ -46,21 +44,17 @@ test.describe('Doc Visibility', () => {
await expect(selectVisibility.getByText('Private')).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Read only' }),
).toBeHidden();
await expect(
page.getByRole('menuitem', { name: 'Can read and edit' }),
).toBeHidden();
await expect(page.getByLabel('Read only')).toBeHidden();
await expect(page.getByLabel('Can read and edit')).toBeHidden();
await selectVisibility.click();
await page.getByRole('menuitem', { name: 'Connected' }).click();
await page.getByLabel('Connected').click();
await expect(page.getByTestId('doc-access-mode')).toBeVisible();
await selectVisibility.click();
await page.getByRole('menuitem', { name: 'Public' }).click();
await page.getByLabel('Public', { exact: true }).click();
await expect(page.getByTestId('doc-access-mode')).toBeVisible();
});
@@ -152,44 +146,60 @@ test.describe('Doc Visibility: Restricted', () => {
await verifyDocName(page, docTitle);
await writeInEditor({ page, text: 'Hello World' });
const docUrl = page.url();
const { otherBrowserName, otherPage, cleanup } =
await connectOtherUserToDoc({
browserName,
docUrl,
});
await expect(
otherPage.getByText('Insufficient access rights to view the document.'),
).toBeVisible({
timeout: 10000,
});
await page.getByRole('button', { name: 'Share' }).click();
await addNewMember(page, 0, 'Reader', otherBrowserName);
await otherPage.reload();
await expect(otherPage.getByText('Hello World')).toBeVisible({
timeout: 10000,
const inputSearch = page.getByRole('combobox', {
name: 'Quick search input',
});
await cleanup();
const otherBrowser = BROWSERS.find((b) => b !== browserName);
if (!otherBrowser) {
throw new Error('No alternative browser found');
}
const username = `user@${otherBrowser}.test`;
await inputSearch.fill(username);
await page.getByRole('option', { name: username }).click();
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByLabel('doc-role-dropdown').click();
await page.getByLabel('Reader').click();
await page.getByRole('button', { name: 'Invite' }).click();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
const urlDoc = page.url();
await page
.getByRole('button', {
name: 'Logout',
})
.click();
await keyCloakSignIn(page, otherBrowser);
await expect(page.getByTestId('header-logo-link')).toBeVisible();
await page.goto(urlDoc);
await verifyDocName(page, docTitle);
await expect(page.getByLabel('Share button')).toBeVisible();
});
});
test.describe('Doc Visibility: Public', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.use({ storageState: { cookies: [], origins: [] } });
test('It checks a public doc in read only mode', async ({
page,
browserName,
}) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docTitle] = await createDoc(
page,
'Public read only',
@@ -199,8 +209,6 @@ test.describe('Doc Visibility: Public', () => {
await verifyDocName(page, docTitle);
await writeInEditor({ page, text: 'Hello Public Viewonly' });
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
@@ -242,67 +250,49 @@ test.describe('Doc Visibility: Public', () => {
await expect(page.getByTestId('search-docs-button')).toBeVisible();
await expect(page.getByTestId('new-doc-button')).toBeVisible();
const docUrl = page.url();
const urlDoc = page.url();
const { otherPage, cleanup } = await connectOtherUserToDoc({
browserName,
docUrl,
withoutSignIn: true,
});
await page
.getByRole('button', {
name: 'Logout',
})
.click();
await expect(otherPage.locator('h2').getByText(docTitle)).toBeVisible();
await expect(otherPage.getByTestId('search-docs-button')).toBeHidden();
await expect(otherPage.getByTestId('new-doc-button')).toBeHidden();
await expect(
otherPage.getByRole('button', { name: 'Share' }),
).toBeVisible();
const card = otherPage.getByLabel('It is the card information');
await expectLoginPage(page);
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await expect(page.getByTestId('search-docs-button')).toBeHidden();
await expect(page.getByTestId('new-doc-button')).toBeHidden();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
const card = page.getByLabel('It is the card information');
await expect(card).toBeVisible();
await expect(card.getByText('Reader')).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await expect(
otherPage.locator('.--docs--editor-container.--docs--doc-readonly'),
).toBeVisible();
const otherEditor = await getEditor({ page: otherPage });
await expect(otherEditor).toHaveAttribute('contenteditable', 'false');
await expect(otherEditor.getByText('Hello Public Viewonly')).toBeVisible();
// Cursor and selection of the anonymous user are not visible
await otherEditor.getByText('Hello Public').selectText();
await expect(
page.locator('.collaboration-cursor-custom__base'),
).toBeHidden();
await expect(page.locator('.ProseMirror-yjs-selection')).toBeHidden();
// Can still see changes made by others
await writeInEditor({ page, text: 'Can you see it ?' });
await expect(otherEditor.getByText('Can you see it ?')).toBeVisible();
await otherPage.getByRole('button', { name: 'Share' }).click();
await expect(
otherPage.getByText(
page.getByText(
'You can view this document but need additional access to see its members or modify settings.',
),
).toBeVisible();
await expect(
otherPage.getByRole('button', { name: 'Request access' }),
page.getByRole('button', { name: 'Request access' }),
).toBeHidden();
await cleanup();
});
test('It checks a public doc in editable mode', async ({
page,
browserName,
}) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docTitle] = await createDoc(page, 'Public editable', browserName, 1);
await verifyDocName(page, docTitle);
await writeInEditor({ page, text: 'Hello Public Editable' });
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
@@ -318,7 +308,7 @@ test.describe('Doc Visibility: Public', () => {
).toBeVisible();
await page.getByTestId('doc-access-mode').click();
await page.getByRole('menuitem', { name: 'Editing' }).click();
await page.getByLabel('Editing').click();
await expect(
page.getByText('The document visibility has been updated.').first(),
@@ -336,47 +326,20 @@ test.describe('Doc Visibility: Public', () => {
cardContainer.getByText('Public document', { exact: true }),
).toBeVisible();
const docUrl = page.url();
const urlDoc = page.url();
const { otherPage, cleanup } = await connectOtherUserToDoc({
browserName,
docUrl,
withoutSignIn: true,
docTitle,
});
await page
.getByRole('button', {
name: 'Logout',
})
.click();
await expect(otherPage.getByTestId('search-docs-button')).toBeHidden();
await expect(otherPage.getByTestId('new-doc-button')).toBeHidden();
await expectLoginPage(page);
const otherEditor = await getEditor({ page: otherPage });
await expect(otherEditor).toHaveAttribute('contenteditable', 'true');
await expect(otherEditor.getByText('Hello Public Editable')).toBeVisible();
await page.goto(urlDoc);
// We can see the collaboration cursor of the anonymous user
await otherEditor.getByText('Hello Public').selectText();
await expect(
page.locator('.collaboration-cursor-custom__base').getByText('Anonymous'),
).toBeVisible();
await expect(
otherPage.getByRole('button', { name: 'Share' }),
).toBeVisible();
const card = otherPage.getByLabel('It is the card information');
await expect(card).toBeVisible();
await expect(card.getByText('Editor')).toBeVisible();
await otherPage.getByRole('button', { name: 'Share' }).click();
await expect(
otherPage.getByText(
'You can view this document but need additional access to see its members or modify settings.',
),
).toBeVisible();
await expect(
otherPage.getByRole('button', { name: 'Request access' }),
).toBeHidden();
await cleanup();
await verifyDocName(page, docTitle);
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
});
});
@@ -568,7 +531,7 @@ test.describe('Doc Visibility: Authenticated', () => {
const urlDoc = page.url();
await page.getByTestId('doc-access-mode').click();
await page.getByRole('menuitem', { name: 'Editing' }).click();
await page.getByLabel('Editing').click();
await expect(
page.getByText('The document visibility has been updated.').first(),

View File

@@ -47,7 +47,7 @@ test.describe('Footer', () => {
// Check the translation
const header = page.locator('header').first();
await header.getByRole('button').getByText('English').click();
await page.getByRole('menuitem', { name: 'Français' }).click();
await page.getByLabel('Français').click();
await expect(
page.locator('footer').getByText('Mentions légales'),
@@ -132,7 +132,7 @@ test.describe('Footer', () => {
// Check the translation
const header = page.locator('header').first();
await header.getByRole('button').getByText('English').click();
await page.getByRole('menuitem', { name: 'Français' }).click();
await page.getByLabel('Français').click();
await expect(
page

View File

@@ -32,15 +32,6 @@ test.describe('Header', () => {
}) => {
await overrideConfig(page, {
FRONTEND_THEME: 'dsfr',
theme_customization: {
header: {
logo: {
src: '/assets/logo-gouv.svg',
width: '220px',
alt: 'Gouvernement Logo',
},
},
},
});
await page.goto('/');
@@ -107,15 +98,6 @@ test.describe('Header mobile', () => {
test('it checks the header when mobile with DSFR theme', async ({ page }) => {
await overrideConfig(page, {
FRONTEND_THEME: 'dsfr',
theme_customization: {
header: {
logo: {
src: '/assets/logo-gouv.svg',
width: '220px',
alt: 'Gouvernement Logo',
},
},
},
});
await page.goto('/');
@@ -149,30 +131,3 @@ test.describe('Header: Log out', () => {
await expectLoginPage(page);
});
});
test.describe('Header: Override configuration', () => {
test('checks the header is correctly overrided', async ({ page }) => {
await overrideConfig(page, {
FRONTEND_THEME: 'dsfr',
theme_customization: {
header: {
logo: {
src: '/assets/logo-gouv.svg',
width: '220px',
alt: '',
},
},
},
});
await page.goto('/');
const header = page.locator('header').first();
const logoImage = header.getByTestId('header-icon-docs');
await expect(logoImage).toBeVisible();
await expect(logoImage).not.toHaveAttribute('src', '/assets/icon-docs.svg');
await expect(logoImage).toHaveAttribute('src', '/assets/logo-gouv.svg');
await expect(logoImage).toHaveAttribute('alt', '');
});
});

View File

@@ -1,7 +1,6 @@
import { expect, test } from '@playwright/test';
import { TestLanguage, createDoc, waitForLanguageSwitch } from './utils-common';
import { openSuggestionMenu } from './utils-editor';
test.describe('Language', () => {
test.beforeEach(async ({ page }) => {
@@ -52,7 +51,6 @@ test.describe('Language', () => {
await expect(page.locator('html')).toHaveAttribute('lang', 'en');
await expect(languagePicker).toContainText('English');
});
test('can switch language using only keyboard', async ({ page }) => {
await page.goto('/');
await waitForLanguageSwitch(page, TestLanguage.English);
@@ -108,18 +106,18 @@ test.describe('Language', () => {
}) => {
await createDoc(page, 'doc-toolbar', browserName, 1);
const editor = await openSuggestionMenu({ page });
const editor = page.locator('.ProseMirror');
// Trigger slash menu to show english menu
await editor.click();
await editor.fill('/');
await expect(page.getByText('Headings', { exact: true })).toBeVisible();
await editor.click(); // close the menu
await expect(page.getByText('Headings', { exact: true })).toBeHidden();
// Change language to French
await waitForLanguageSwitch(page, TestLanguage.French);
// Trigger slash menu to show french menu
await openSuggestionMenu({ page });
await editor.locator('.bn-block-outer').last().fill('/');
await expect(page.getByText('Titres', { exact: true })).toBeVisible();
});
});

View File

@@ -1,7 +1,5 @@
import { expect, test } from '@playwright/test';
import { createDoc } from './utils-common';
test.describe('Left panel desktop', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -13,53 +11,6 @@ test.describe('Left panel desktop', () => {
await expect(page.getByTestId('home-button')).toBeVisible();
await expect(page.getByTestId('new-doc-button')).toBeVisible();
});
test('checks resize handle is present and functional on document page', async ({
page,
browserName,
}) => {
// On home page, resize handle should NOT be present
let resizeHandle = page.locator('[data-panel-resize-handle-id]');
await expect(resizeHandle).toBeHidden();
// Create and navigate to a document
await createDoc(page, 'doc-resize-test', browserName, 1);
// Now resize handle should be visible on document page
resizeHandle = page.locator('[data-panel-resize-handle-id]').first();
await expect(resizeHandle).toBeVisible();
const leftPanel = page.getByTestId('left-panel-desktop');
await expect(leftPanel).toBeVisible();
// Get initial panel width
const initialBox = await leftPanel.boundingBox();
expect(initialBox).not.toBeNull();
// Get handle position
const handleBox = await resizeHandle.boundingBox();
expect(handleBox).not.toBeNull();
// Test resize by dragging the handle
await page.mouse.move(
handleBox!.x + handleBox!.width / 2,
handleBox!.y + handleBox!.height / 2,
);
await page.mouse.down();
await page.mouse.move(
handleBox!.x + 100,
handleBox!.y + handleBox!.height / 2,
);
await page.mouse.up();
// Wait for resize to complete
await page.waitForTimeout(200);
// Verify the panel has been resized
const newBox = await leftPanel.boundingBox();
expect(newBox).not.toBeNull();
expect(newBox!.width).toBeGreaterThan(initialBox!.width);
});
});
test.describe('Left panel mobile', () => {
@@ -96,12 +47,4 @@ test.describe('Left panel mobile', () => {
await expect(languageButton).toBeInViewport();
await expect(logoutButton).toBeInViewport();
});
test('checks resize handle is not present on mobile', async ({ page }) => {
await page.goto('/');
// Verify the resize handle is NOT present on mobile
const resizeHandle = page.locator('[data-panel-resize-handle-id]');
await expect(resizeHandle).toBeHidden();
});
});

View File

@@ -1,7 +1,6 @@
import { Locator, Page, expect } from '@playwright/test';
import { Page, expect } from '@playwright/test';
export type BrowserName = 'chromium' | 'firefox' | 'webkit';
export const BROWSERS: BrowserName[] = ['chromium', 'webkit', 'firefox'];
export const BROWSERS = ['chromium', 'webkit', 'firefox'];
export const CONFIG = {
AI_FEATURE_ENABLED: true,
@@ -23,7 +22,6 @@ export const CONFIG = {
LANGUAGE_CODE: 'en-us',
POSTHOG_KEY: {},
SENTRY_DSN: null,
TRASHBIN_CUTOFF_DAYS: 30,
theme_customization: {},
} as const;
@@ -31,7 +29,7 @@ export const overrideConfig = async (
page: Page,
newConfig: { [_K in keyof typeof CONFIG]?: unknown },
) =>
await page.route(/.*\/api\/v1.0\/config\/.*/, async (route) => {
await page.route('**/api/v1.0/config/', async (route) => {
const request = route.request();
if (request.method().includes('GET')) {
await route.fulfill({
@@ -48,7 +46,7 @@ export const overrideConfig = async (
export const keyCloakSignIn = async (
page: Page,
browserName: string,
fromHome = true,
fromHome: boolean = true,
) => {
if (fromHome) {
await page.getByRole('button', { name: 'Start Writing' }).first().click();
@@ -79,8 +77,8 @@ export const createDoc = async (
page: Page,
docName: string,
browserName: string,
length = 1,
isMobile = false,
length: number = 1,
isMobile: boolean = false,
) => {
const randomDocs = randomName(docName, browserName, length);
@@ -204,7 +202,7 @@ export const waitForResponseCreateDoc = (page: Page) => {
};
export const mockedDocument = async (page: Page, data: object) => {
await page.route(/\**\/documents\/\**/, async (route) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
@@ -256,7 +254,7 @@ export const mockedDocument = async (page: Page, data: object) => {
};
export const mockedListDocs = async (page: Page, data: object[] = []) => {
await page.route(/\**\/documents\/\**/, async (route) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (request.method().includes('GET') && request.url().includes('page=')) {
await route.fulfill({
@@ -277,7 +275,6 @@ export const expectLoginPage = async (page: Page) =>
).toBeVisible({
timeout: 10000,
});
// language helper
export const TestLanguage = {
English: {
@@ -301,7 +298,7 @@ export async function waitForLanguageSwitch(
page: Page,
lang: TestLanguageValue,
) {
await page.route(/\**\/api\/v1.0\/users\/\**/, async (route, request) => {
await page.route('**/api/v1.0/users/**', async (route, request) => {
if (request.method().includes('PATCH')) {
await route.fulfill({
json: {
@@ -325,21 +322,5 @@ export async function waitForLanguageSwitch(
await languagePicker.click();
await page.getByRole('menuitem', { name: lang.label }).click();
await page.getByLabel(lang.label).click();
}
export const clickInEditorMenu = async (page: Page, textButton: string) => {
await page.getByRole('button', { name: 'Open the document options' }).click();
await page.getByRole('menuitem', { name: textButton }).click();
};
export const clickInGridMenu = async (
page: Page,
row: Locator,
textButton: string,
) => {
await row
.getByRole('button', { name: /Open the menu of actions for the document/ })
.click();
await page.getByRole('menuitem', { name: textButton }).click();
};

View File

@@ -1,27 +0,0 @@
import { Page } from '@playwright/test';
export const getEditor = async ({ page }: { page: Page }) => {
const editor = page.locator('.ProseMirror');
await editor.click();
return editor;
};
export const openSuggestionMenu = async ({ page }: { page: Page }) => {
const editor = await getEditor({ page });
await editor.click();
await writeInEditor({ page, text: '/' });
return editor;
};
export const writeInEditor = async ({
page,
text,
}: {
page: Page;
text: string;
}) => {
const editor = await getEditor({ page });
await editor.locator('.bn-block-outer .bn-inline-content').last().fill(text);
return editor;
};

View File

@@ -1,21 +1,14 @@
import { Page, chromium, expect } from '@playwright/test';
import {
BROWSERS,
BrowserName,
keyCloakSignIn,
verifyDocName,
} from './utils-common';
import { Page, expect } from '@playwright/test';
export type Role = 'Administrator' | 'Owner' | 'Member' | 'Editor' | 'Reader';
export type LinkReach = 'Private' | 'Connected' | 'Public';
export type LinkRole = 'Reading' | 'Editing';
export type LinkRole = 'Reading' | 'Edition';
export const addNewMember = async (
page: Page,
index: number,
role: 'Administrator' | 'Owner' | 'Editor' | 'Reader',
fillText = 'user.test',
fillText: string = 'user.test',
) => {
const responsePromiseSearchUser = page.waitForResponse(
(response) =>
@@ -23,7 +16,9 @@ export const addNewMember = async (
response.status() === 200,
);
const inputSearch = page.getByTestId('quick-search-input');
const inputSearch = page.getByRole('combobox', {
name: 'Quick search input',
});
// Select a new user
await inputSearch.fill(fillText);
@@ -39,7 +34,7 @@ export const addNewMember = async (
// Choose a role
await page.getByLabel('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: role }).click();
await page.getByLabel(role).click();
await page.getByRole('button', { name: 'Invite' }).click();
return users[index].email;
@@ -66,84 +61,6 @@ export const updateShareLink = async (
}
};
export const updateRoleUser = async (
page: Page,
role: Role | 'Remove access',
email: string,
) => {
const list = page.getByTestId('doc-share-quick-search');
const currentUser = list.getByTestId(`doc-share-member-row-${email}`);
const currentUserRole = currentUser.getByLabel('doc-role-dropdown');
await currentUserRole.click();
await page.getByRole('menuitem', { name: role }).click();
await list.click();
};
/**
* Connects another user to a document.
* Useful to test real-time collaboration features.
* @param browserName The name of the browser to use.
* @param docUrl The URL of the document to connect to.
* @param docTitle The title of the document (optional).
* @returns An object containing the other browser, context, and page.
*/
export const connectOtherUserToDoc = async ({
browserName,
docUrl,
docTitle,
withoutSignIn,
}: {
browserName: BrowserName;
docUrl: string;
docTitle?: string;
withoutSignIn?: boolean;
}) => {
const otherBrowserName = BROWSERS.find((b) => b !== browserName);
if (!otherBrowserName) {
throw new Error('No alternative browser found');
}
const otherBrowser = await chromium.launch({ headless: true });
const otherContext = await otherBrowser.newContext({
locale: 'en-US',
timezoneId: 'Europe/Paris',
permissions: [],
storageState: {
cookies: [],
origins: [],
},
});
const otherPage = await otherContext.newPage();
await otherPage.goto(docUrl);
if (!withoutSignIn) {
const loginFromApp = otherPage
.getByRole('main', { name: 'Main content' })
.getByLabel('Login');
const loginFromHome = otherPage.getByRole('button', {
name: 'Start Writing',
});
await loginFromApp.or(loginFromHome).first().click({
timeout: 15000,
});
await keyCloakSignIn(otherPage, otherBrowserName, false);
}
if (docTitle) {
await verifyDocName(otherPage, docTitle);
}
const cleanup = async () => {
await otherPage.close();
await otherContext.close();
await otherBrowser.close();
};
return { otherBrowser, otherContext, otherPage, otherBrowserName, cleanup };
};
export const mockedInvitations = async (page: Page, json?: object) => {
let result = [
{
@@ -155,7 +72,7 @@ export const mockedInvitations = async (page: Page, json?: object) => {
retrieve: true,
},
created_at: '2024-10-03T12:19:26.107687Z',
email: 'test.test@invitation.test',
email: 'test@invitation.test',
document: '4888c328-8406-4412-9b0b-c0ba5b9e5fb6',
role: 'editor',
issuer: '7380f42f-02eb-4ad5-b8f0-037a0e66066d',
@@ -163,7 +80,7 @@ export const mockedInvitations = async (page: Page, json?: object) => {
...json,
},
];
await page.route(/.*\/invitations\/.*/, async (route) => {
await page.route('**/invitations/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
@@ -184,7 +101,7 @@ export const mockedInvitations = async (page: Page, json?: object) => {
});
await page.route(
/.*\/invitations\/120ec765-43af-4602-83eb-7f4e1224548a\/.*/,
'**/invitations/120ec765-43af-4602-83eb-7f4e1224548a/**/',
async (route) => {
const request = route.request();
if (request.method().includes('DELETE')) {
@@ -199,7 +116,7 @@ export const mockedInvitations = async (page: Page, json?: object) => {
};
export const mockedAccesses = async (page: Page, json?: object) => {
await page.route(/.*\/accesses\/.*/, async (route) => {
await page.route('**/accesses/**/', async (route) => {
const request = route.request();
if (
@@ -212,7 +129,7 @@ export const mockedAccesses = async (page: Page, json?: object) => {
id: 'bc8bbbc5-a635-4f65-9817-fd1e9ec8ef87',
user: {
id: 'b4a21bb3-722e-426c-9f78-9d190eda641c',
email: 'test.test@accesses.test',
email: 'test@accesses.test',
},
team: '',
max_ancestors_role: null,

View File

@@ -1,18 +1,16 @@
import { Page, expect } from '@playwright/test';
import {
BrowserName,
randomName,
updateDocTitle,
verifyDocName,
waitForResponseCreateDoc,
} from './utils-common';
export const createRootSubPage = async (
page: Page,
browserName: BrowserName,
browserName: string,
docName: string,
isMobile = false,
isMobile: boolean = false,
) => {
if (isMobile) {
await page
@@ -65,78 +63,5 @@ export const clickOnAddRootSubPage = async (page: Page) => {
const rootItem = page.getByTestId('doc-tree-root-item');
await expect(rootItem).toBeVisible();
await rootItem.hover();
await rootItem.getByTestId('doc-tree-item-actions-add-child').click();
};
export const addChild = async ({
page,
browserName,
docParent,
docName,
}: {
page: Page;
browserName: BrowserName;
docParent: string;
docName: string;
}) => {
let item = page.getByTestId('doc-tree-root-item');
const isParent = await item
.filter({
hasText: docParent,
})
.first()
.count();
if (!isParent) {
const items = page.getByRole('treeitem');
item = items
.filter({
hasText: docParent,
})
.first();
}
await item.hover();
await item.getByTestId('doc-tree-item-actions-add-child').click();
const [name] = randomName(docName, browserName, 1);
await updateDocTitle(page, name);
return name;
};
export const getTreeRow = async (page: Page, title: string) => {
const docTree = page.getByTestId('doc-tree');
const row = docTree
.getByRole('treeitem')
.filter({
hasText: title,
})
.first();
await expect(row).toBeVisible();
return row;
};
export const navigateToTopParentFromTree = async ({ page }: { page: Page }) => {
await page.getByRole('link', { name: /Open root document/ }).click();
};
export const navigateToPageFromTree = async ({
page,
title,
}: {
page: Page;
title: string;
}) => {
const docTree = page.getByTestId('doc-tree');
await docTree
.getByText(title, {
exact: true,
})
.click();
await verifyDocName(page, title);
await rootItem.getByRole('button', { name: 'add_box' }).click();
};

View File

@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "3.9.0",
"version": "3.7.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.55.0",
"@types/node": "*",
"@types/pdf-parse": "1.1.5",
"eslint-plugin-docs": "*",
@@ -23,7 +23,7 @@
},
"dependencies": {
"convert-stream": "1.0.2",
"pdf-parse": "2.4.5"
"pdf-parse": "1.1.1"
},
"packageManager": "yarn@1.22.22"
}

View File

@@ -35,8 +35,3 @@ yarn-error.log*
*.tsbuildinfo
service-worker.js
# Font embedding
public/assets/fonts/emoji/*
!public/assets/fonts/emoji/fallback.png
public/assets/fonts/Marianne/*

View File

@@ -1,3 +1,2 @@
next-env.d.ts
service-worker.js
public/assets/fonts/*

View File

@@ -1,3 +0,0 @@
out/**/*
public/assets/fonts/**/*
node_modules/**/*

View File

@@ -1,7 +1,5 @@
const crypto = require('crypto');
const path = require('path');
const CopyPlugin = require('copy-webpack-plugin');
const { InjectManifest } = require('workbox-webpack-plugin');
const buildId = crypto.randomBytes(256).toString('hex').slice(0, 8);
@@ -43,30 +41,6 @@ const nextConfig = {
},
);
// Copy necessary fonts from node_modules to public directory during build or dev
config.plugins.push(
new CopyPlugin({
patterns: [
{
from: path.resolve(
__dirname,
'../../node_modules/emoji-datasource-apple/img/apple/64',
),
to: path.resolve(__dirname, 'public/assets/fonts/emoji'),
force: true,
},
{
from: path.resolve(
__dirname,
'../../node_modules/@gouvfr-lasuite/ui-kit/dist/assets/fonts/Marianne',
),
to: path.resolve(__dirname, 'public/assets/fonts/Marianne'),
force: true,
},
],
}),
);
if (!isServer && process.env.NEXT_PUBLIC_SW_DEACTIVATED !== 'true') {
config.plugins.push(
new InjectManifest({

View File

@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "3.9.0",
"version": "3.7.0",
"repository": "https://github.com/suitenumerique/docs",
"author": "DINUM",
"license": "MIT",
@@ -19,52 +19,44 @@
},
"dependencies": {
"@ag-media/react-pdf-table": "2.0.3",
"@blocknote/code-block": "0.41.1",
"@blocknote/core": "0.41.1",
"@blocknote/mantine": "0.41.1",
"@blocknote/react": "0.41.1",
"@blocknote/xl-docx-exporter": "0.41.1",
"@blocknote/xl-multi-column": "0.41.1",
"@blocknote/xl-odt-exporter": "0.41.1",
"@blocknote/xl-pdf-exporter": "0.41.1",
"@blocknote/code-block": "0.37.0",
"@blocknote/core": "0.37.0",
"@blocknote/mantine": "0.37.0",
"@blocknote/react": "0.37.0",
"@blocknote/xl-docx-exporter": "0.37.0",
"@blocknote/xl-multi-column": "0.37.0",
"@blocknote/xl-pdf-exporter": "0.37.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/material-icons": "5.2.7",
"@fontsource/material-icons": "5.2.5",
"@gouvfr-lasuite/integration": "1.0.3",
"@gouvfr-lasuite/ui-kit": "0.16.2",
"@hocuspocus/provider": "3.4.0",
"@mantine/core": "8.3.6",
"@mantine/hooks": "8.3.6",
"@gouvfr-lasuite/ui-kit": "0.16.1",
"@hocuspocus/provider": "2.15.2",
"@openfun/cunningham-react": "3.2.3",
"@react-pdf/renderer": "4.3.1",
"@sentry/nextjs": "10.22.0",
"@tanstack/react-query": "5.90.6",
"@tiptap/extensions": "3.10.1",
"@react-pdf/renderer": "4.3.0",
"@sentry/nextjs": "10.11.0",
"@tanstack/react-query": "5.87.4",
"canvg": "4.0.3",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"crisp-sdk-web": "1.0.26",
"docx": "*",
"emoji-datasource-apple": "16.0.0",
"crisp-sdk-web": "1.0.25",
"docx": "9.5.0",
"emoji-mart": "5.6.0",
"emoji-regex": "10.6.0",
"i18next": "25.6.0",
"emoji-regex": "10.5.0",
"i18next": "25.5.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.3",
"posthog-js": "1.264.2",
"react": "*",
"react-aria-components": "1.13.0",
"react-aria-components": "1.12.1",
"react-dom": "*",
"react-i18next": "16.2.3",
"react-intersection-observer": "10.0.0",
"react-resizable-panels": "3.0.6",
"react-i18next": "15.7.3",
"react-intersection-observer": "9.16.0",
"react-select": "5.10.2",
"styled-components": "6.1.19",
"use-debounce": "10.0.6",
@@ -74,9 +66,9 @@
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.90.2",
"@tanstack/react-query-devtools": "5.87.4",
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/jest-dom": "6.8.0",
"@testing-library/react": "16.3.0",
"@testing-library/user-event": "14.6.1",
"@types/lodash": "4.17.20",
@@ -84,22 +76,21 @@
"@types/node": "*",
"@types/react": "*",
"@types/react-dom": "*",
"@vitejs/plugin-react": "5.1.0",
"copy-webpack-plugin": "13.0.1",
"cross-env": "10.1.0",
"dotenv": "17.2.3",
"@vitejs/plugin-react": "5.0.2",
"cross-env": "10.0.0",
"dotenv": "17.2.2",
"eslint-plugin-docs": "*",
"fetch-mock": "9.11.0",
"jsdom": "27.1.0",
"jsdom": "26.1.0",
"node-fetch": "2.7.0",
"prettier": "3.6.2",
"stylelint": "16.25.0",
"stylelint-config-standard": "39.0.1",
"stylelint": "16.24.0",
"stylelint-config-standard": "39.0.0",
"stylelint-prettier": "5.0.3",
"typescript": "*",
"vite-tsconfig-paths": "5.1.4",
"vitest": "4.0.6",
"webpack": "5.102.1",
"vitest": "3.2.4",
"webpack": "5.101.3",
"workbox-webpack-plugin": "7.1.0"
},
"packageManager": "yarn@1.22.22"

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