mirror of
https://github.com/suitenumerique/docs.git
synced 2026-05-07 07:32:33 +02:00
Compare commits
51 Commits
feature/pr
...
refacto/re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b383c58eb | ||
|
|
85128c7b11 | ||
|
|
5f700ed6c4 | ||
|
|
b0d9ed15c0 | ||
|
|
d41e44dcd5 | ||
|
|
07e7b3feb6 | ||
|
|
aa71cfdfc0 | ||
|
|
7afa17a181 | ||
|
|
af2b381097 | ||
|
|
5015d42677 | ||
|
|
738ff90fc7 | ||
|
|
0e8094c733 | ||
|
|
9231730bf0 | ||
|
|
21100b986d | ||
|
|
eaddbd83d7 | ||
|
|
22c587fdd0 | ||
|
|
9568d12f68 | ||
|
|
33a9e99d54 | ||
|
|
6cfc8990b9 | ||
|
|
8c84dbf39a | ||
|
|
b6efac3983 | ||
|
|
fa9d56d79b | ||
|
|
4fe508bba1 | ||
|
|
487d0b12ca | ||
|
|
9f1d4543e7 | ||
|
|
c90280fb4d | ||
|
|
a2860e8fe6 | ||
|
|
cfd1fd00da | ||
|
|
ed663f2e1e | ||
|
|
99764b8e3e | ||
|
|
37091ca804 | ||
|
|
394fbc5537 | ||
|
|
7df5aba991 | ||
|
|
c464715158 | ||
|
|
5e31eb0caa | ||
|
|
a00c51247d | ||
|
|
100817b0e6 | ||
|
|
ff2c61a3dc | ||
|
|
4d250a7342 | ||
|
|
6f2cd8a829 | ||
|
|
b6c6fc8217 | ||
|
|
68f1600c2b | ||
|
|
1c2bafb0f7 | ||
|
|
6b3d19715b | ||
|
|
51d4746435 | ||
|
|
d7a186a98b | ||
|
|
207f21447d | ||
|
|
3433d6de9a | ||
|
|
5e22bc4736 | ||
|
|
2d2e326cb6 | ||
|
|
ef9376368f |
46
CHANGELOG.md
46
CHANGELOG.md
@@ -6,11 +6,40 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- ⚡️(frontend) add skeleton on content loading #2254
|
||||
|
||||
### Changed
|
||||
|
||||
- 🧵(backend) remove lock from db table #2272
|
||||
|
||||
### Fixed
|
||||
|
||||
- 💬(frontend) add missing link in onboarding description #2233
|
||||
- 🐛(frontend) sanitize pasted and dropped content in document title #2210
|
||||
- 🐛(frontend) Emoji menu doesn't display above comment box #2229
|
||||
- 🐛(frontend) Block menu doesn't stay open on 1st line #2229
|
||||
- 🐛(frontend) The "+" on the first line of a new doc doesn't work #2229
|
||||
|
||||
|
||||
## [v5.0.0] - 2026-04-08
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(backend) create a dedicated endpoint to update document content #2171
|
||||
- ⚡️(backend) stream s3 file content with a dedicated endpoint #2171
|
||||
- ✨(backend) allow to use new ai feature using mistral sdk #2193
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️(backend) rename documents content endpoint in `formatted-content` (BC)
|
||||
- 🚸(frontend) show Crisp from the help menu #2222
|
||||
- ♿️(frontend) structure correctly 5xx error alerts #2128
|
||||
- ♿️(frontend) make doc search result labels uniquely identifiable #2212
|
||||
- ⬆️(backend) upgrade docspec to v3.0.x and adapt converter API #2220
|
||||
- ✨(backend) make forward auth request uri header configurable #2241
|
||||
- ♿️(frontend) fix sidebar resize handle for screen readers #2122
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -21,6 +50,16 @@ and this project adheres to
|
||||
- 🐛(frontend) fix interlinking modal clipping #2213
|
||||
- 🛂(frontend) fix cannot manage member on small screen #2226
|
||||
- 🐛(backend) load jwks url when OIDC_RS_PRIVATE_KEY_STR is set
|
||||
- 🐛(backend) Prevent moving document to its own descendant or self #2208
|
||||
- 🐛(backend) return 400 when restoring a non-deleted document #2225
|
||||
- 🐛(backend) fix race condition in reconciliation requests CSV import #2153
|
||||
- 🐛(backend) create_for_owner: add accesses before saving doc content #2124
|
||||
- 🐛(backend) enforce emoji validation for reactions #1965
|
||||
|
||||
### Removed
|
||||
|
||||
- 🔥(backend) remove deprecated descendants endpoint #2243
|
||||
- 🔥(backend) remove content in document responses #2171
|
||||
|
||||
## [v4.8.6] - 2026-04-08
|
||||
|
||||
@@ -59,7 +98,6 @@ and this project adheres to
|
||||
- ⚡️(frontend) add jitter to WS reconnection #2162
|
||||
- 🐛(frontend) fix tree pagination #2145
|
||||
- 🐛(nginx) add page reconciliation on nginx #2154
|
||||
- 🐛(backend) fix race condition in reconciliation requests CSV import #2153
|
||||
|
||||
## [v4.8.4] - 2026-03-25
|
||||
|
||||
@@ -81,9 +119,6 @@ and this project adheres to
|
||||
- 🐛(y-provider) destroy Y.Doc instances after each convert request #2129
|
||||
- 🐛(backend) remove deleted sub documents in favorite_list endpoint #2083
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(backend) create_for_owner: add accesses before saving doc content #2124
|
||||
|
||||
## [v4.8.3] - 2026-03-23
|
||||
|
||||
@@ -1252,7 +1287,8 @@ and this project adheres to
|
||||
- ✨(frontend) Coming Soon page (#67)
|
||||
- 🚀 Impress, project to manage your documents easily and collaboratively.
|
||||
|
||||
[unreleased]: https://github.com/suitenumerique/docs/compare/v4.8.6...main
|
||||
[unreleased]: https://github.com/suitenumerique/docs/compare/v5.0.0...main
|
||||
[v5.0.0]: https://github.com/suitenumerique/docs/releases/v5.0.0
|
||||
[v4.8.6]: https://github.com/suitenumerique/docs/releases/v4.8.6
|
||||
[v4.8.5]: https://github.com/suitenumerique/docs/releases/v4.8.5
|
||||
[v4.8.4]: https://github.com/suitenumerique/docs/releases/v4.8.4
|
||||
|
||||
10
Dockerfile
10
Dockerfile
@@ -134,7 +134,15 @@ ENV DB_HOST=postgresql \
|
||||
DB_PORT=5432
|
||||
|
||||
# Run django development server
|
||||
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||
CMD [\
|
||||
"uvicorn",\
|
||||
"--app-dir=/app",\
|
||||
"--host=0.0.0.0",\
|
||||
"--lifespan=off",\
|
||||
"--reload",\
|
||||
"--reload-dir=/app",\
|
||||
"impress.asgi:application"\
|
||||
]
|
||||
|
||||
# ---- Production image ----
|
||||
FROM core AS backend-production
|
||||
|
||||
23
UPGRADE.md
23
UPGRADE.md
@@ -16,6 +16,29 @@ the following command inside your docker container:
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### [5.0.0] - 2026-04-30
|
||||
|
||||
We made several changes around document content management leading to several breaking changes in the API.
|
||||
|
||||
- The endpoint `/api/v1.0/documents/{document_id}/content/` has been renamed in `/api/v1.0/documents/{document_id}/formatted-content/`
|
||||
- There is no more `content` attribute in the response of `/api/v1.0/documents/{document_id}/`, two new endpoints have been added to retrieve or update the document content.
|
||||
- A new `GET /api/v1.0/documents/{document_id}/content/` endpoint has been implemented to fetch the document content ; this endpoint streams the whole content with a `text/plain` content-type response.
|
||||
- A new `PATCH /api/v1.0/documents/{document_id}/content/` endpoint has been added to update the document content ; expected payload is:
|
||||
```json
|
||||
{
|
||||
"content": "document content in base64",
|
||||
}
|
||||
```
|
||||
|
||||
Other changes:
|
||||
|
||||
- The deprecated endpoint `/api/v1.0/documents/<document_id>/descendants` is removed. The search endpoint should be used instead.
|
||||
- Upgrade docspec dependency to version >= 3.0.0
|
||||
The docspec service has changed since version 3.0.0, we ware now compatible with this version and not with version 2.x.x anymore
|
||||
- It is now possible to use the Mistral SDK instead of the OpenAI for the AI features. If your provider is compatible with the mistral API, we encourage you to use it.
|
||||
- `AI_API_KEY` settings is renamed in `OPENAI_SDK_API_KEY` and is only used to congiure the OpenAi sdk
|
||||
- `AI_BASE_URL` settings is renamed in `OPENAI_SDK_BASE_URL` and is only used to congiure the OpenAi sdk
|
||||
|
||||
## [4.6.0] - 2026-02-27
|
||||
|
||||
- ⚠️ Some setup have changed to offer a bigger flexibility and consistency, overriding the favicon and logo are now from the theme configuration.
|
||||
|
||||
40
compose.yml
40
compose.yml
@@ -29,8 +29,8 @@ services:
|
||||
- MINIO_ROOT_USER=impress
|
||||
- MINIO_ROOT_PASSWORD=password
|
||||
ports:
|
||||
- '9000:9000'
|
||||
- '9001:9001'
|
||||
- "9000:9000"
|
||||
- "9001:9001"
|
||||
healthcheck:
|
||||
test: ["CMD", "mc", "ready", "local"]
|
||||
interval: 1s
|
||||
@@ -81,16 +81,16 @@ services:
|
||||
- ./src/backend:/app
|
||||
- ./data/static:/data/static
|
||||
depends_on:
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
mailcatcher:
|
||||
condition: service_started
|
||||
redis:
|
||||
condition: service_started
|
||||
createbuckets:
|
||||
condition: service_started
|
||||
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
mailcatcher:
|
||||
condition: service_started
|
||||
redis:
|
||||
condition: service_started
|
||||
createbuckets:
|
||||
condition: service_started
|
||||
|
||||
celery-dev:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: impress:backend-development
|
||||
@@ -143,7 +143,7 @@ services:
|
||||
|
||||
frontend-development:
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
build:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./src/frontend/Dockerfile
|
||||
target: impress-dev
|
||||
@@ -173,13 +173,13 @@ services:
|
||||
image: node:22
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
environment:
|
||||
HOME: /tmp
|
||||
HOME: /tmp
|
||||
volumes:
|
||||
- ".:/app"
|
||||
|
||||
y-provider-development:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
build:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./src/frontend/servers/y-provider/Dockerfile
|
||||
target: y-provider-development
|
||||
@@ -221,7 +221,11 @@ services:
|
||||
- --health-enabled=true
|
||||
- --metrics-enabled=true
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'exec 3<>/dev/tcp/localhost/9000; echo -e "GET /health/live HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" >&3; grep "HTTP/1.1 200 OK" <&3']
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
'exec 3<>/dev/tcp/localhost/9000; echo -e "GET /health/live HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" >&3; grep "HTTP/1.1 200 OK" <&3',
|
||||
]
|
||||
start_period: 5s
|
||||
interval: 1s
|
||||
timeout: 2s
|
||||
@@ -235,7 +239,7 @@ services:
|
||||
KC_DB_PASSWORD: pass
|
||||
KC_DB_USERNAME: impress
|
||||
KC_DB_SCHEMA: public
|
||||
PROXY_ADDRESS_FORWARDING: 'true'
|
||||
PROXY_ADDRESS_FORWARDING: "true"
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
@@ -244,7 +248,7 @@ services:
|
||||
restart: true
|
||||
|
||||
docspec:
|
||||
image: ghcr.io/docspecio/api:2.6.3
|
||||
image: ghcr.io/docspecio/api:3.0.1
|
||||
ports:
|
||||
- "4000:4000"
|
||||
|
||||
|
||||
15
docs/env.md
15
docs/env.md
@@ -9,14 +9,16 @@ These are the environment variables you can set for the `impress-backend` contai
|
||||
| Option | Description | default |
|
||||
| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| AI_ALLOW_REACH_FROM | Users that can use AI must be this level. options are "public", "authenticated", "restricted" | authenticated |
|
||||
| AI_API_KEY | AI key to be used for AI Base url | |
|
||||
| AI_BASE_URL | OpenAI compatible AI base url | |
|
||||
| AI_BOT | Information to give to the frontend about the AI bot | { "name": "Docs AI", "color": "#8bc6ff" }
|
||||
| OPENAI_SDK_API_KEY | AI key to be used by the OpenAI python SDK | |
|
||||
| OPENAI_SDK_BASE_URL | OpenAI compatible AI base url | |
|
||||
| MISTRAL_SDK_API_KEY | AI key to be used by the Mistral python SDK /!\ Mistral sdk can be used only in async mode with uvicorn /!\ | |
|
||||
| MISTRAL_SDK_BASE_URL | Mistral compatible AI base url | |
|
||||
| AI_BOT | Information to give to the frontend about the AI bot | { "name": "Docs AI", "color": "#8bc6ff" } |
|
||||
| AI_FEATURE_ENABLED | Enable AI options | false |
|
||||
| AI_FEATURE_BLOCKNOTE_ENABLED | Enable Blocknote AI options | false |
|
||||
| AI_FEATURE_LEGACY_ENABLED | Enable legacyAI options | true |
|
||||
| AI_FEATURE_BLOCKNOTE_ENABLED | Enable Blocknote AI options | false |
|
||||
| AI_FEATURE_LEGACY_ENABLED | Enable legacyAI options | true |
|
||||
| AI_MODEL | AI Model to use | |
|
||||
| AI_VERCEL_SDK_VERSION | The vercel AI SDK version used | 6 |
|
||||
| AI_VERCEL_SDK_VERSION | The vercel AI SDK version used | 6 |
|
||||
| ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true |
|
||||
| API_USERS_LIST_LIMIT | Limit on API users | 5 |
|
||||
| API_USERS_LIST_THROTTLE_RATE_BURST | Throttle rate for api on burst | 30/minute |
|
||||
@@ -91,6 +93,7 @@ These are the environment variables you can set for the `impress-backend` contai
|
||||
| MALWARE_DETECTION_BACKEND | The malware detection backend use from the django-lasuite package | lasuite.malware_detection.backends.dummy.DummyBackend |
|
||||
| MALWARE_DETECTION_PARAMETERS | A dict containing all the parameters to initiate the malware detection backend | {"callback_path": "core.malware_detection.malware_detection_callback",} |
|
||||
| MEDIA_BASE_URL | | |
|
||||
| MEDIA_AUTH_ORIGINAL_URL_HEADER | Parameter containing the original request URL, as seen at the media auth endpoint, in CGI/WSGI form (HTTP_HEADER_NAME_ALL_CAPS_WITH_UNDERSCORES) | HTTP_X_ORIGINAL_URL |
|
||||
| NO_WEBSOCKET_CACHE_TIMEOUT | Cache used to store current editor session key when only users without websocket are editing a document | 120 |
|
||||
| OIDC_ALLOW_DUPLICATE_EMAILS | Allow duplicate emails | false |
|
||||
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth parameters | {} |
|
||||
|
||||
@@ -71,14 +71,6 @@ OIDC_RS_ALLOWED_AUDIENCES=""
|
||||
# User reconciliation
|
||||
USER_RECONCILIATION_FORM_URL=http://localhost:3000
|
||||
|
||||
# AI
|
||||
AI_FEATURE_ENABLED=true
|
||||
AI_FEATURE_BLOCKNOTE_ENABLED=true
|
||||
AI_FEATURE_LEGACY_ENABLED=true
|
||||
AI_BASE_URL=https://openaiendpoint.com
|
||||
AI_API_KEY=password
|
||||
AI_MODEL=llama
|
||||
|
||||
# Collaboration
|
||||
COLLABORATION_API_URL=http://y-provider-development:4444/collaboration/api/
|
||||
COLLABORATION_BACKEND_BASE_URL=http://app-dev:8000
|
||||
|
||||
@@ -12,6 +12,7 @@ from core.models import DocumentAccess, RoleChoices, get_trashbin_cutoff
|
||||
ACTION_FOR_METHOD_TO_PERMISSION = {
|
||||
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"},
|
||||
"children": {"GET": "children_list", "POST": "children_create"},
|
||||
"content": {"PATCH": "content_patch", "GET": "content_retrieve"},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -4,26 +4,30 @@
|
||||
import binascii
|
||||
import mimetypes
|
||||
from base64 import b64decode
|
||||
from logging import getLogger
|
||||
from os.path import splitext
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import connection, transaction
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.db.models import Q
|
||||
from django.utils.functional import lazy
|
||||
from django.utils.text import slugify
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import emoji
|
||||
import magic
|
||||
from rest_framework import serializers
|
||||
|
||||
from core import choices, enums, models, utils, validators
|
||||
from core import choices, enums, models, validators
|
||||
from core.services import mime_types
|
||||
from core.services.ai_services import AI_ACTIONS
|
||||
from core.services.ai_services.legacy import AI_ACTIONS
|
||||
from core.services.converter_services import (
|
||||
ConversionError,
|
||||
Converter,
|
||||
)
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
"""Serialize users."""
|
||||
@@ -178,7 +182,6 @@ class DocumentLightSerializer(serializers.ModelSerializer):
|
||||
class DocumentSerializer(ListDocumentSerializer):
|
||||
"""Serialize documents with all fields for display in detail views."""
|
||||
|
||||
content = serializers.CharField(required=False)
|
||||
websocket = serializers.BooleanField(required=False, write_only=True)
|
||||
file = serializers.FileField(
|
||||
required=False, write_only=True, allow_null=True, max_length=255
|
||||
@@ -193,7 +196,6 @@ class DocumentSerializer(ListDocumentSerializer):
|
||||
"ancestors_link_role",
|
||||
"computed_link_reach",
|
||||
"computed_link_role",
|
||||
"content",
|
||||
"created_at",
|
||||
"creator",
|
||||
"deleted_at",
|
||||
@@ -242,13 +244,6 @@ class DocumentSerializer(ListDocumentSerializer):
|
||||
if request:
|
||||
if request.method == "POST":
|
||||
fields["id"].read_only = False
|
||||
if (
|
||||
serializers.BooleanField().to_internal_value(
|
||||
request.query_params.get("without_content", False)
|
||||
)
|
||||
is True
|
||||
):
|
||||
del fields["content"]
|
||||
|
||||
return fields
|
||||
|
||||
@@ -265,18 +260,6 @@ class DocumentSerializer(ListDocumentSerializer):
|
||||
|
||||
return value
|
||||
|
||||
def validate_content(self, value):
|
||||
"""Validate the content field."""
|
||||
if not value:
|
||||
return None
|
||||
|
||||
try:
|
||||
b64decode(value, validate=True)
|
||||
except binascii.Error as err:
|
||||
raise serializers.ValidationError("Invalid base64 content.") from err
|
||||
|
||||
return value
|
||||
|
||||
def validate_file(self, file):
|
||||
"""Add file size and type constraints as defined in settings."""
|
||||
if not file:
|
||||
@@ -310,52 +293,33 @@ class DocumentSerializer(ListDocumentSerializer):
|
||||
return instance # No data provided, skip the update
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
def save(self, **kwargs):
|
||||
|
||||
class DocumentContentSerializer(serializers.Serializer):
|
||||
"""Serializer for updating only the raw content of a document stored in S3."""
|
||||
|
||||
content = serializers.CharField(required=True)
|
||||
websocket = serializers.BooleanField(required=False)
|
||||
|
||||
def validate_content(self, value):
|
||||
"""Validate the content field."""
|
||||
try:
|
||||
b64decode(value, validate=True)
|
||||
except binascii.Error as err:
|
||||
raise serializers.ValidationError("Invalid base64 content.") from err
|
||||
|
||||
return value
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""
|
||||
Process the content field to extract attachment keys and update the document's
|
||||
"attachments" field for access control.
|
||||
This serializer does not support updates.
|
||||
"""
|
||||
content = self.validated_data.get("content", "")
|
||||
extracted_attachments = set(utils.extract_attachments(content))
|
||||
raise NotImplementedError("Update is not supported for this serializer.")
|
||||
|
||||
existing_attachments = (
|
||||
set(self.instance.attachments or []) if self.instance else set()
|
||||
)
|
||||
new_attachments = extracted_attachments - existing_attachments
|
||||
|
||||
if new_attachments:
|
||||
attachments_documents = (
|
||||
models.Document.objects.filter(
|
||||
attachments__overlap=list(new_attachments)
|
||||
)
|
||||
.only("path", "attachments")
|
||||
.order_by("path")
|
||||
)
|
||||
|
||||
user = self.context["request"].user
|
||||
readable_per_se_paths = (
|
||||
models.Document.objects.readable_per_se(user)
|
||||
.order_by("path")
|
||||
.values_list("path", flat=True)
|
||||
)
|
||||
readable_attachments_paths = utils.filter_descendants(
|
||||
[doc.path for doc in attachments_documents],
|
||||
readable_per_se_paths,
|
||||
skip_sorting=True,
|
||||
)
|
||||
|
||||
readable_attachments = set()
|
||||
for document in attachments_documents:
|
||||
if document.path not in readable_attachments_paths:
|
||||
continue
|
||||
readable_attachments.update(set(document.attachments) & new_attachments)
|
||||
|
||||
# Update attachments with readable keys
|
||||
self.validated_data["attachments"] = list(
|
||||
existing_attachments | readable_attachments
|
||||
)
|
||||
|
||||
return super().save(**kwargs)
|
||||
def create(self, validated_data):
|
||||
"""
|
||||
This serializer does not support create.
|
||||
"""
|
||||
raise NotImplementedError("Create is not supported for this serializer.")
|
||||
|
||||
|
||||
class DocumentAccessSerializer(serializers.ModelSerializer):
|
||||
@@ -506,18 +470,18 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
|
||||
{"content": ["Could not convert content"]}
|
||||
) from err
|
||||
|
||||
with transaction.atomic():
|
||||
# locks the table to ensure safe concurrent access
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
f'LOCK TABLE "{models.Document._meta.db_table}" ' # noqa: SLF001
|
||||
"IN SHARE ROW EXCLUSIVE MODE;"
|
||||
)
|
||||
|
||||
document = models.Document.add_root(
|
||||
title=validated_data["title"],
|
||||
creator=user,
|
||||
)
|
||||
while True:
|
||||
try:
|
||||
with transaction.atomic():
|
||||
document = models.Document.add_root(
|
||||
title=validated_data["title"],
|
||||
creator=user,
|
||||
)
|
||||
break
|
||||
except IntegrityError as e:
|
||||
if "impress_document_path_key" not in str(e):
|
||||
raise
|
||||
logger.warning("Path key conflict when creating document, retrying...")
|
||||
|
||||
if user:
|
||||
# Associate the document with the pre-existing user
|
||||
@@ -915,6 +879,12 @@ class ReactionSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
read_only_fields = ["id", "created_at", "users"]
|
||||
|
||||
def validate_emoji(self, value):
|
||||
"""Ensure the reaction is a single emoji."""
|
||||
if not emoji.is_emoji(value):
|
||||
raise serializers.ValidationError("Reaction must be a single valid emoji.")
|
||||
return value
|
||||
|
||||
|
||||
class CommentSerializer(serializers.ModelSerializer):
|
||||
"""Serialize comments (nested under a thread) with reactions and abilities."""
|
||||
|
||||
@@ -194,3 +194,8 @@ class AIUserRateThrottle(AIBaseRateThrottle):
|
||||
if x_forwarded_for
|
||||
else request.META.get("REMOTE_ADDR")
|
||||
)
|
||||
|
||||
|
||||
def get_content_metadata_cache_key(document_id):
|
||||
"""Return the cache key used to store content metadata."""
|
||||
return f"docs:content-metadata:{document_id!s}"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import base64
|
||||
import datetime as dt
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
@@ -18,7 +19,7 @@ from django.core.cache import cache
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.validators import URLValidator
|
||||
from django.db import connection, transaction
|
||||
from django.db import IntegrityError, connection, transaction
|
||||
from django.db import models as db
|
||||
from django.db.models.expressions import RawSQL
|
||||
from django.db.models.functions import Greatest, Left, Length
|
||||
@@ -43,11 +44,13 @@ from rest_framework import filters, status, viewsets
|
||||
from rest_framework import response as drf_response
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.views import APIView
|
||||
from treebeard.exceptions import InvalidMoveToDescendant
|
||||
|
||||
from core import authentication, choices, enums, models
|
||||
from core.api.filters import remove_accents
|
||||
from core.services import mime_types
|
||||
from core.services.ai_services import AIService
|
||||
from core.services.ai_services.blocknote import AIService
|
||||
from core.services.ai_services.legacy import get_legacy_ai_service
|
||||
from core.services.collaboration_services import CollaborationService
|
||||
from core.services.converter_services import (
|
||||
ConversionError,
|
||||
@@ -705,18 +708,18 @@ class DocumentViewSet(
|
||||
{"file": ["Could not convert file content"]}
|
||||
) from err
|
||||
|
||||
with transaction.atomic():
|
||||
# locks the table to ensure safe concurrent access
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
f'LOCK TABLE "{models.Document._meta.db_table}" ' # noqa: SLF001
|
||||
"IN SHARE ROW EXCLUSIVE MODE;"
|
||||
)
|
||||
|
||||
obj = models.Document.add_root(
|
||||
creator=self.request.user,
|
||||
**serializer.validated_data,
|
||||
)
|
||||
while True:
|
||||
try:
|
||||
with transaction.atomic():
|
||||
obj = models.Document.add_root(
|
||||
creator=self.request.user,
|
||||
**serializer.validated_data,
|
||||
)
|
||||
break
|
||||
except IntegrityError as e:
|
||||
if "impress_document_path_key" not in str(e):
|
||||
raise
|
||||
logger.warning("Path key conflict when creating document, retrying...")
|
||||
serializer.instance = obj
|
||||
models.DocumentAccess.objects.create(
|
||||
document=obj,
|
||||
@@ -776,17 +779,15 @@ class DocumentViewSet(
|
||||
def perform_update(self, serializer):
|
||||
"""Check rules about collaboration."""
|
||||
if (
|
||||
serializer.validated_data.get("websocket", False)
|
||||
or not settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY
|
||||
not serializer.validated_data.get("websocket", False)
|
||||
and settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY
|
||||
and not self._can_user_edit_document(serializer.instance.id, set_cache=True)
|
||||
):
|
||||
return super().perform_update(serializer)
|
||||
raise drf.exceptions.PermissionDenied(
|
||||
"You are not allowed to edit this document."
|
||||
)
|
||||
|
||||
if self._can_user_edit_document(serializer.instance.id, set_cache=True):
|
||||
return super().perform_update(serializer)
|
||||
|
||||
raise drf.exceptions.PermissionDenied(
|
||||
"You are not allowed to edit this document."
|
||||
)
|
||||
return super().perform_update(serializer)
|
||||
|
||||
@drf.decorators.action(
|
||||
detail=True,
|
||||
@@ -962,7 +963,13 @@ class DocumentViewSet(
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
document.move(target_document, pos=position)
|
||||
try:
|
||||
document.move(target_document, pos=position)
|
||||
except InvalidMoveToDescendant:
|
||||
return drf.response.Response(
|
||||
{"target_document_id": "Cannot move a document to its own descendant."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Make sure we have at least one owner
|
||||
if (
|
||||
@@ -990,7 +997,10 @@ class DocumentViewSet(
|
||||
Restore a soft-deleted document if it was deleted less than x days ago.
|
||||
"""
|
||||
document = self.get_object()
|
||||
document.restore()
|
||||
try:
|
||||
document.restore()
|
||||
except RuntimeError as err:
|
||||
raise drf.exceptions.ValidationError({"detail": str(err)}) from err
|
||||
|
||||
return drf_response.Response(
|
||||
{"detail": "Document has been successfully restored."},
|
||||
@@ -1112,30 +1122,6 @@ class DocumentViewSet(
|
||||
|
||||
return self.get_response_for_queryset(queryset)
|
||||
|
||||
@drf.decorators.action(
|
||||
detail=True,
|
||||
methods=["get"],
|
||||
ordering=["path"],
|
||||
)
|
||||
def descendants(self, request, *args, **kwargs):
|
||||
"""Deprecated endpoint to list descendants of a document."""
|
||||
logger.warning(
|
||||
"The 'descendants' endpoint is deprecated and will be removed in a future release. "
|
||||
"The search endpoint should be used for all document retrieval use cases."
|
||||
)
|
||||
document = self.get_object()
|
||||
|
||||
queryset = document.get_descendants().filter(ancestors_deleted_at__isnull=True)
|
||||
queryset = self.filter_queryset(queryset)
|
||||
|
||||
filterset = DocumentFilter(request.GET, queryset=queryset)
|
||||
if not filterset.is_valid():
|
||||
raise drf.exceptions.ValidationError(filterset.errors)
|
||||
|
||||
queryset = filterset.qs
|
||||
|
||||
return self.get_response_for_queryset(queryset)
|
||||
|
||||
@drf.decorators.action(
|
||||
detail=True,
|
||||
methods=["get"],
|
||||
@@ -1777,10 +1763,13 @@ class DocumentViewSet(
|
||||
|
||||
def _auth_get_original_url(self, request):
|
||||
"""
|
||||
Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header.
|
||||
Extracts and parses the original URL from the configured parameter header.
|
||||
Raises PermissionDenied if the header is missing.
|
||||
|
||||
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
|
||||
The original url is passed by reverse proxy in the header specified by the
|
||||
MEDIA_AUTH_ORIGINAL_URL_HEADER setting.
|
||||
|
||||
For nginx (the default) this is set to HTTP_X_ORIGINAL_URL.
|
||||
See corresponding ingress configuration in Helm chart and read about the
|
||||
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
|
||||
is configured to do this.
|
||||
@@ -1791,9 +1780,14 @@ class DocumentViewSet(
|
||||
reasons.
|
||||
"""
|
||||
# Extract the original URL from the request header
|
||||
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
|
||||
original_url = request.META.get(settings.MEDIA_AUTH_ORIGINAL_URL_HEADER)
|
||||
if not original_url:
|
||||
logger.debug("Missing HTTP_X_ORIGINAL_URL header in subrequest")
|
||||
logger.debug(
|
||||
"Missing %s header in subrequest. "
|
||||
"Maybe you need to set MEDIA_AUTH_ORIGINAL_URL_HEADER correctly for your ingress"
|
||||
" proxy.",
|
||||
settings.MEDIA_AUTH_ORIGINAL_URL_HEADER,
|
||||
)
|
||||
raise drf.exceptions.PermissionDenied()
|
||||
|
||||
logger.debug("Original url: '%s'", original_url)
|
||||
@@ -1875,6 +1869,170 @@ class DocumentViewSet(
|
||||
|
||||
return drf.response.Response("authorized", headers=request.headers, status=200)
|
||||
|
||||
@drf.decorators.action(detail=True, methods=["patch"])
|
||||
def content(self, request, *args, **kwargs):
|
||||
"""Update the raw Yjs content of a document stored in S3."""
|
||||
document = self.get_object()
|
||||
|
||||
serializer = serializers.DocumentContentSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
if (
|
||||
not serializer.validated_data.get("websocket", False)
|
||||
and settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY
|
||||
and not self._can_user_edit_document(document.id, set_cache=True)
|
||||
):
|
||||
raise drf.exceptions.PermissionDenied(
|
||||
"You are not allowed to edit this document."
|
||||
)
|
||||
|
||||
content = serializer.validated_data["content"]
|
||||
try:
|
||||
extracted_attachments = set(extract_attachments(content))
|
||||
except ValueError:
|
||||
return drf_response.Response(
|
||||
"invalid yjs document", status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
existing_attachments = set(document.attachments or [])
|
||||
new_attachments = extracted_attachments - existing_attachments
|
||||
|
||||
# Ensure we update attachments the request user is allowed to read
|
||||
if new_attachments:
|
||||
attachments_documents = (
|
||||
models.Document.objects.filter(
|
||||
attachments__overlap=list(new_attachments)
|
||||
)
|
||||
.only("path", "attachments")
|
||||
.order_by("path")
|
||||
)
|
||||
|
||||
user = self.request.user
|
||||
readable_per_se_paths = (
|
||||
models.Document.objects.readable_per_se(user)
|
||||
.order_by("path")
|
||||
.values_list("path", flat=True)
|
||||
)
|
||||
readable_attachments_paths = filter_descendants(
|
||||
[doc.path for doc in attachments_documents],
|
||||
readable_per_se_paths,
|
||||
skip_sorting=True,
|
||||
)
|
||||
|
||||
readable_attachments = set()
|
||||
for attachments_document in attachments_documents:
|
||||
if attachments_document.path not in readable_attachments_paths:
|
||||
continue
|
||||
readable_attachments.update(
|
||||
set(attachments_document.attachments) & new_attachments
|
||||
)
|
||||
|
||||
# Update attachments with readable keys
|
||||
document.attachments = list(existing_attachments | readable_attachments)
|
||||
document.content = content
|
||||
document.save()
|
||||
cache.delete(utils.get_content_metadata_cache_key(document.id))
|
||||
|
||||
return drf_response.Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@content.mapping.get
|
||||
def content_retrieve(self, request, *args, **kwargs):
|
||||
"""
|
||||
Retrieve the raw content file from s3 and stream it.
|
||||
|
||||
We implement a HTTP cache based on the ETag and LastModified headers.
|
||||
We retrieve the ETag and LastModified from the S3 head operation, save them in cache to
|
||||
reuse them in future requests.
|
||||
We check in the request if the ETag is present in the If-None-Match header and if it's the
|
||||
same as the one from the S3 head operation, we return a 304 response.
|
||||
If the ETag is not present or not the same, we do the same check based on the LastModifed
|
||||
value if present in the If-Modified-Since header.
|
||||
"""
|
||||
document = self.get_object()
|
||||
# The S3 call to fetch the document can take time and the database
|
||||
# connection is useless in this process. Hence we are closing it now
|
||||
# to prevent having a massive number of database connections during
|
||||
# the web-socket re-connection burst.
|
||||
connection.close()
|
||||
|
||||
if not (
|
||||
content_metadata := cache.get(
|
||||
utils.get_content_metadata_cache_key(document.id)
|
||||
)
|
||||
):
|
||||
try:
|
||||
file_metadata = default_storage.connection.meta.client.head_object(
|
||||
Bucket=default_storage.bucket_name, Key=document.file_key
|
||||
)
|
||||
except ClientError:
|
||||
return StreamingHttpResponse(
|
||||
b"", content_type="text/plain", status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
last_modified = file_metadata["LastModified"]
|
||||
etag = file_metadata["ETag"]
|
||||
size = file_metadata["ContentLength"]
|
||||
|
||||
cache.set(
|
||||
utils.get_content_metadata_cache_key(document.id),
|
||||
{
|
||||
"last_modified": last_modified.isoformat(),
|
||||
"etag": etag,
|
||||
"size": size,
|
||||
},
|
||||
settings.CONTENT_METADATA_CACHE_TIMEOUT,
|
||||
)
|
||||
else:
|
||||
last_modified = dt.datetime.fromisoformat(
|
||||
content_metadata.get("last_modified")
|
||||
)
|
||||
etag = content_metadata.get("etag")
|
||||
size = content_metadata.get("size")
|
||||
|
||||
# --- Check conditional headers from any client ---
|
||||
if_none_match = request.META.get("HTTP_IF_NONE_MATCH") # contains ETag
|
||||
if_modified_since = request.META.get("HTTP_IF_MODIFIED_SINCE")
|
||||
|
||||
# Strip the W/ weak prefix. Proxies (e.g. nginx with gzip) convert strong
|
||||
# ETags to weak ones, so a strict equality check would fail on production
|
||||
# even when unchanged.
|
||||
if if_none_match and if_none_match.startswith("W/"):
|
||||
if_none_match = if_none_match.removeprefix("W/")
|
||||
|
||||
if if_none_match and if_none_match == etag:
|
||||
return drf_response.Response(status=status.HTTP_304_NOT_MODIFIED)
|
||||
|
||||
if if_modified_since:
|
||||
try:
|
||||
since = dt.datetime.strptime(
|
||||
if_modified_since, "%a, %d %b %Y %H:%M:%S %Z"
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
if not since.tzinfo:
|
||||
since = since.replace(tzinfo=dt.timezone.utc)
|
||||
if last_modified <= since:
|
||||
return drf_response.Response(status=status.HTTP_304_NOT_MODIFIED)
|
||||
|
||||
def _stream(file_key):
|
||||
with default_storage.open(file_key, "rb") as f:
|
||||
while chunk := f.read(8192):
|
||||
yield chunk
|
||||
|
||||
response = StreamingHttpResponse(
|
||||
streaming_content=_stream(document.file_key),
|
||||
content_type="text/plain",
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
response["Content-Length"] = size
|
||||
response["ETag"] = etag
|
||||
response["Last-Modified"] = last_modified.strftime("%a, %d %b %Y %H:%M:%S %Z")
|
||||
response["Cache-Control"] = "private, no-cache"
|
||||
|
||||
return response
|
||||
|
||||
@drf.decorators.action(detail=True, methods=["get"], url_path="media-check")
|
||||
def media_check(self, request, *args, **kwargs):
|
||||
"""
|
||||
@@ -1976,13 +2134,16 @@ class DocumentViewSet(
|
||||
# Check permissions first
|
||||
self.get_object()
|
||||
|
||||
if not settings.AI_FEATURE_ENABLED or not settings.AI_FEATURE_LEGACY_ENABLED:
|
||||
raise ValidationError("AI feature is not enabled.")
|
||||
|
||||
serializer = serializers.AITransformSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
text = serializer.validated_data["text"]
|
||||
action = serializer.validated_data["action"]
|
||||
|
||||
response = AIService().transform(text, action)
|
||||
response = get_legacy_ai_service().transform(text, action)
|
||||
|
||||
return drf.response.Response(response, status=drf.status.HTTP_200_OK)
|
||||
|
||||
@@ -2004,13 +2165,16 @@ class DocumentViewSet(
|
||||
# Check permissions first
|
||||
self.get_object()
|
||||
|
||||
if not settings.AI_FEATURE_ENABLED or not settings.AI_FEATURE_LEGACY_ENABLED:
|
||||
raise ValidationError("AI feature is not enabled.")
|
||||
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
text = serializer.validated_data["text"]
|
||||
language = serializer.validated_data["language"]
|
||||
|
||||
response = AIService().translate(text, language)
|
||||
response = get_legacy_ai_service().translate(text, language)
|
||||
|
||||
return drf.response.Response(response, status=drf.status.HTTP_200_OK)
|
||||
|
||||
@@ -2121,7 +2285,7 @@ class DocumentViewSet(
|
||||
GET /api/v1.0/documents/<resource_id>/cors-proxy
|
||||
Act like a proxy to fetch external resources and bypass CORS restrictions.
|
||||
"""
|
||||
url = request.query_params.get("url")
|
||||
url = request.query_params.get("url", "").strip()
|
||||
if not url:
|
||||
return drf.response.Response(
|
||||
{"detail": "Missing 'url' query parameter"},
|
||||
@@ -2193,10 +2357,10 @@ class DocumentViewSet(
|
||||
@drf.decorators.action(
|
||||
detail=True,
|
||||
methods=["get"],
|
||||
url_path="content",
|
||||
name="Get document content in different formats",
|
||||
url_path="formatted-content",
|
||||
name="Convert document content to different formats",
|
||||
)
|
||||
def content(self, request, pk=None):
|
||||
def formatted_content(self, request, pk=None):
|
||||
"""
|
||||
Retrieve document content in different formats (JSON, Markdown, HTML).
|
||||
|
||||
|
||||
@@ -231,9 +231,10 @@ class ReactionFactory(factory.django.DjangoModelFactory):
|
||||
|
||||
class Meta:
|
||||
model = models.Reaction
|
||||
skip_postgeneration_save = True
|
||||
|
||||
comment = factory.SubFactory(CommentFactory)
|
||||
emoji = "test"
|
||||
emoji = factory.Faker("emoji")
|
||||
|
||||
@factory.post_generation
|
||||
def users(self, create, extracted, **kwargs):
|
||||
|
||||
@@ -19,7 +19,7 @@ from django.core.cache import cache
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.mail import send_mail
|
||||
from django.db import connection, models, transaction
|
||||
from django.db import IntegrityError, models, transaction
|
||||
from django.db.models.functions import Left, Length
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils import timezone
|
||||
@@ -277,24 +277,26 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
)
|
||||
return
|
||||
|
||||
with transaction.atomic():
|
||||
# locks the table to ensure safe concurrent access
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
f'LOCK TABLE "{Document._meta.db_table}" ' # noqa: SLF001
|
||||
"IN SHARE ROW EXCLUSIVE MODE;"
|
||||
while True:
|
||||
try:
|
||||
with transaction.atomic():
|
||||
sandbox_document = Document.add_root(
|
||||
title=template_document.title,
|
||||
content=template_document.content,
|
||||
attachments=template_document.attachments,
|
||||
duplicated_from=template_document,
|
||||
creator=self,
|
||||
)
|
||||
DocumentAccess.objects.create(
|
||||
user=self, document=sandbox_document, role=RoleChoices.OWNER
|
||||
)
|
||||
break
|
||||
except IntegrityError as e:
|
||||
if "impress_document_path_key" not in str(e):
|
||||
raise
|
||||
logger.warning(
|
||||
"Path key conflict when creating sandbox document, retrying..."
|
||||
)
|
||||
sandbox_document = Document.add_root(
|
||||
title=template_document.title,
|
||||
content=template_document.content,
|
||||
attachments=template_document.attachments,
|
||||
duplicated_from=template_document,
|
||||
creator=self,
|
||||
)
|
||||
|
||||
DocumentAccess.objects.create(
|
||||
user=self, document=sandbox_document, role=RoleChoices.OWNER
|
||||
)
|
||||
|
||||
def _convert_valid_invitations(self):
|
||||
"""
|
||||
@@ -1308,7 +1310,9 @@ class Document(MP_Node, BaseModel):
|
||||
"children_create": can_create_children,
|
||||
"collaboration_auth": can_get,
|
||||
"comment": can_comment,
|
||||
"content": can_get,
|
||||
"formatted_content": can_get,
|
||||
"content_patch": can_update,
|
||||
"content_retrieve": retrieve,
|
||||
"cors_proxy": can_get,
|
||||
"descendants": can_get,
|
||||
"destroy": can_destroy,
|
||||
|
||||
@@ -7,15 +7,17 @@ import os
|
||||
import queue
|
||||
import threading
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from functools import cache
|
||||
from typing import Any, Dict, Union
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
from langfuse import get_client
|
||||
from langfuse.openai import OpenAI as OpenAI_Langfuse
|
||||
from pydantic_ai import Agent, DeferredToolRequests
|
||||
from pydantic_ai.models.mistral import MistralModel
|
||||
from pydantic_ai.models.openai import OpenAIChatModel
|
||||
from pydantic_ai.providers.mistral import MistralProvider
|
||||
from pydantic_ai.providers.openai import OpenAIProvider
|
||||
from pydantic_ai.tools import ToolDefinition
|
||||
from pydantic_ai.toolsets.external import ExternalToolset
|
||||
@@ -24,13 +26,6 @@ from pydantic_ai.ui.vercel_ai import VercelAIAdapter
|
||||
from pydantic_ai.ui.vercel_ai.request_types import RequestData, TextUIPart, UIMessage
|
||||
from rest_framework.request import Request
|
||||
|
||||
from core import enums
|
||||
|
||||
if settings.LANGFUSE_PUBLIC_KEY:
|
||||
OpenAI = OpenAI_Langfuse
|
||||
else:
|
||||
from openai import OpenAI
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
BLOCKNOTE_TOOL_STRICT_PROMPT = """
|
||||
@@ -64,50 +59,6 @@ IDs ALWAYS end with "$". Use ids EXACTLY as provided.
|
||||
Return ONLY the JSON tool input. No prose, no markdown.
|
||||
"""
|
||||
|
||||
AI_ACTIONS = {
|
||||
"prompt": (
|
||||
"Answer the prompt using markdown formatting for structure and emphasis. "
|
||||
"Return the content directly without wrapping it in code blocks or markdown delimiters. "
|
||||
"Preserve the language and markdown formatting. "
|
||||
"Do not provide any other information. "
|
||||
"Preserve the language."
|
||||
),
|
||||
"correct": (
|
||||
"Correct grammar and spelling of the markdown text, "
|
||||
"preserving language and markdown formatting. "
|
||||
"Do not provide any other information. "
|
||||
"Preserve the language."
|
||||
),
|
||||
"rephrase": (
|
||||
"Rephrase the given markdown text, "
|
||||
"preserving language and markdown formatting. "
|
||||
"Do not provide any other information. "
|
||||
"Preserve the language."
|
||||
),
|
||||
"summarize": (
|
||||
"Summarize the markdown text, preserving language and markdown formatting. "
|
||||
"Do not provide any other information. "
|
||||
"Preserve the language."
|
||||
),
|
||||
"beautify": (
|
||||
"Add formatting to the text to make it more readable. "
|
||||
"Do not provide any other information. "
|
||||
"Preserve the language."
|
||||
),
|
||||
"emojify": (
|
||||
"Add emojis to the important parts of the text. "
|
||||
"Do not provide any other information. "
|
||||
"Preserve the language."
|
||||
),
|
||||
}
|
||||
|
||||
AI_TRANSLATE = (
|
||||
"Keep the same html structure and formatting. "
|
||||
"Translate the content in the html to the specified language {language:s}. "
|
||||
"Check the translation for accuracy and make any necessary corrections. "
|
||||
"Do not provide any other information."
|
||||
)
|
||||
|
||||
|
||||
def convert_async_generator_to_sync(async_gen: AsyncIterator[str]) -> Iterator[str]:
|
||||
"""Convert an async generator to a sync generator."""
|
||||
@@ -143,46 +94,40 @@ def convert_async_generator_to_sync(async_gen: AsyncIterator[str]) -> Iterator[s
|
||||
thread.join()
|
||||
|
||||
|
||||
class AIService:
|
||||
"""Service class for AI-related operations."""
|
||||
|
||||
def __init__(self):
|
||||
"""Ensure that the AI configuration is set properly."""
|
||||
if (
|
||||
settings.AI_BASE_URL is None
|
||||
or settings.AI_API_KEY is None
|
||||
or settings.AI_MODEL is None
|
||||
):
|
||||
raise ImproperlyConfigured("AI configuration not set")
|
||||
self.client = OpenAI(base_url=settings.AI_BASE_URL, api_key=settings.AI_API_KEY)
|
||||
|
||||
def call_ai_api(self, system_content, text):
|
||||
"""Helper method to call the OpenAI API and process the response."""
|
||||
response = self.client.chat.completions.create(
|
||||
model=settings.AI_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": system_content},
|
||||
{"role": "user", "content": text},
|
||||
],
|
||||
@cache
|
||||
def configure_pydantic_model_provider() -> OpenAIChatModel | MistralModel:
|
||||
"""Configure a pydantic Model and return it."""
|
||||
if (
|
||||
settings.OPENAI_SDK_API_KEY
|
||||
and settings.OPENAI_SDK_BASE_URL
|
||||
and settings.AI_MODEL
|
||||
):
|
||||
return OpenAIChatModel(
|
||||
settings.AI_MODEL,
|
||||
provider=OpenAIProvider(
|
||||
api_key=settings.OPENAI_SDK_API_KEY,
|
||||
base_url=settings.OPENAI_SDK_BASE_URL,
|
||||
),
|
||||
)
|
||||
|
||||
content = response.choices[0].message.content
|
||||
if (
|
||||
settings.MISTRAL_SDK_API_KEY
|
||||
and settings.MISTRAL_SDK_BASE_URL
|
||||
and settings.AI_MODEL
|
||||
):
|
||||
return MistralModel(
|
||||
settings.AI_MODEL,
|
||||
provider=MistralProvider(
|
||||
api_key=settings.MISTRAL_SDK_API_KEY,
|
||||
base_url=settings.MISTRAL_SDK_BASE_URL,
|
||||
),
|
||||
)
|
||||
|
||||
if not content:
|
||||
raise RuntimeError("AI response does not contain an answer")
|
||||
raise ImproperlyConfigured("AI configuration not set")
|
||||
|
||||
return {"answer": content}
|
||||
|
||||
def transform(self, text, action):
|
||||
"""Transform text based on specified action."""
|
||||
system_content = AI_ACTIONS[action]
|
||||
return self.call_ai_api(system_content, text)
|
||||
|
||||
def translate(self, text, language):
|
||||
"""Translate text to a specified language."""
|
||||
language_display = enums.ALL_LANGUAGES.get(language, language)
|
||||
system_content = AI_TRANSLATE.format(language=language_display)
|
||||
return self.call_ai_api(system_content, text)
|
||||
class AIService:
|
||||
"""Service class for AI-related operations."""
|
||||
|
||||
@staticmethod
|
||||
def inject_document_state_messages(
|
||||
@@ -324,13 +269,9 @@ class AIService:
|
||||
langfuse.auth_check()
|
||||
Agent.instrument_all()
|
||||
|
||||
model = OpenAIChatModel(
|
||||
settings.AI_MODEL,
|
||||
provider=OpenAIProvider(
|
||||
base_url=settings.AI_BASE_URL, api_key=settings.AI_API_KEY
|
||||
),
|
||||
agent = Agent(
|
||||
configure_pydantic_model_provider(), instrument=instrument_enabled
|
||||
)
|
||||
agent = Agent(model, instrument=instrument_enabled)
|
||||
|
||||
accept = request.META.get("HTTP_ACCEPT", SSE_CONTENT_TYPE)
|
||||
|
||||
201
src/backend/core/services/ai_services/legacy.py
Normal file
201
src/backend/core/services/ai_services/legacy.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""Module dedicated to the legacy ai services."""
|
||||
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from functools import cache
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
from langfuse import get_client, observe
|
||||
from langfuse.openai import OpenAI as OpenAI_Langfuse
|
||||
from mistralai import Mistral
|
||||
|
||||
from core import enums
|
||||
|
||||
if settings.LANGFUSE_PUBLIC_KEY:
|
||||
OpenAI = OpenAI_Langfuse
|
||||
else:
|
||||
from openai import OpenAI
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
AI_ACTIONS = {
|
||||
"prompt": (
|
||||
"Answer the prompt using markdown formatting for structure and emphasis. "
|
||||
"Return the content directly without wrapping it in code blocks or markdown delimiters. "
|
||||
"Preserve the language and markdown formatting. "
|
||||
"Do not provide any other information. "
|
||||
"Preserve the language."
|
||||
),
|
||||
"correct": (
|
||||
"Correct grammar and spelling of the markdown text, "
|
||||
"preserving language and markdown formatting. "
|
||||
"Do not provide any other information. "
|
||||
"Preserve the language."
|
||||
),
|
||||
"rephrase": (
|
||||
"Rephrase the given markdown text, "
|
||||
"preserving language and markdown formatting. "
|
||||
"Do not provide any other information. "
|
||||
"Preserve the language."
|
||||
),
|
||||
"summarize": (
|
||||
"Summarize the markdown text, preserving language and markdown formatting. "
|
||||
"Do not provide any other information. "
|
||||
"Preserve the language."
|
||||
),
|
||||
"beautify": (
|
||||
"Add formatting to the text to make it more readable. "
|
||||
"Do not provide any other information. "
|
||||
"Preserve the language."
|
||||
),
|
||||
"emojify": (
|
||||
"Add emojis to the important parts of the text. "
|
||||
"Do not provide any other information. "
|
||||
"Preserve the language."
|
||||
),
|
||||
}
|
||||
|
||||
AI_TRANSLATE = (
|
||||
"Keep the same html structure and formatting. "
|
||||
"Translate the content in the html to the specified language {language:s}. "
|
||||
"Check the translation for accuracy and make any necessary corrections. "
|
||||
"Do not provide any other information. "
|
||||
"Return the content directly without wrapping it in code blocks or markdown delimiters."
|
||||
)
|
||||
|
||||
|
||||
class LegacyAiClient(ABC):
|
||||
"""abstract class for legacy client."""
|
||||
|
||||
@abstractmethod
|
||||
def call_ai_api(self, system_content, text) -> str:
|
||||
"""Abstract method call_ai_api."""
|
||||
|
||||
|
||||
class LegacyAiServiceMistralClient(LegacyAiClient):
|
||||
"""ai_service using mistral sdk for the legacy ai feature."""
|
||||
|
||||
def __init__(self):
|
||||
"""Configure mistral sdk"""
|
||||
if (
|
||||
not settings.MISTRAL_SDK_API_KEY
|
||||
or not settings.MISTRAL_SDK_BASE_URL
|
||||
or not settings.AI_MODEL
|
||||
):
|
||||
raise ImproperlyConfigured("Mistral sdk configuration not set")
|
||||
|
||||
self.client = Mistral(
|
||||
api_key=settings.MISTRAL_SDK_API_KEY,
|
||||
server_url=settings.MISTRAL_SDK_BASE_URL,
|
||||
)
|
||||
|
||||
@observe(as_type="generation")
|
||||
def call_ai_api(self, system_content, text) -> str:
|
||||
langfuse = None
|
||||
messages = [
|
||||
{"role": "system", "content": system_content},
|
||||
{"role": "user", "content": text},
|
||||
]
|
||||
if settings.LANGFUSE_PUBLIC_KEY:
|
||||
langfuse = get_client()
|
||||
langfuse.auth_check()
|
||||
|
||||
langfuse.update_current_generation(
|
||||
input=messages,
|
||||
model=settings.AI_MODEL,
|
||||
)
|
||||
|
||||
response = self.client.chat.complete(
|
||||
model=settings.AI_MODEL,
|
||||
messages=messages,
|
||||
stream=False,
|
||||
)
|
||||
|
||||
if langfuse:
|
||||
langfuse.update_current_generation(
|
||||
usage_details={
|
||||
"input": response.usage.prompt_tokens,
|
||||
"output": response.usage.completion_tokens,
|
||||
},
|
||||
output=response.choices[0].message.content,
|
||||
)
|
||||
|
||||
return response.choices[0].message.content
|
||||
|
||||
|
||||
class LegacyAiServiceOpenAiClient(LegacyAiClient):
|
||||
"""ai_service using OpenAI client for the legacy ai feature."""
|
||||
|
||||
def __init__(self):
|
||||
"""configure OpenAI client."""
|
||||
if (
|
||||
not settings.OPENAI_SDK_BASE_URL
|
||||
or not settings.OPENAI_SDK_API_KEY
|
||||
or not settings.AI_MODEL
|
||||
):
|
||||
raise ImproperlyConfigured("OpenAI configuration not set")
|
||||
self.client = OpenAI(
|
||||
base_url=settings.OPENAI_SDK_BASE_URL, api_key=settings.OPENAI_SDK_API_KEY
|
||||
)
|
||||
|
||||
def call_ai_api(self, system_content, text) -> str:
|
||||
response = self.client.chat.completions.create(
|
||||
model=settings.AI_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": system_content},
|
||||
{"role": "user", "content": text},
|
||||
],
|
||||
)
|
||||
|
||||
return response.choices[0].message.content
|
||||
|
||||
|
||||
class LegacyAIService:
|
||||
"""Legacy ai service used by transform and translate actions."""
|
||||
|
||||
def __init__(self, ai_client: LegacyAiClient):
|
||||
"""Assign client to the service."""
|
||||
self.ai_client = ai_client
|
||||
|
||||
def call_ai_api(self, system_content, text):
|
||||
"""Helper method to call the OpenAI API and process the response."""
|
||||
|
||||
content = self.ai_client.call_ai_api(system_content, text)
|
||||
|
||||
if not content:
|
||||
raise RuntimeError("AI response does not contain an answer")
|
||||
|
||||
return {"answer": content}
|
||||
|
||||
def transform(self, text, action):
|
||||
"""Transform text based on specified action."""
|
||||
system_content = AI_ACTIONS[action]
|
||||
return self.call_ai_api(system_content, text)
|
||||
|
||||
def translate(self, text, language):
|
||||
"""Translate text to a specified language."""
|
||||
language_display = enums.ALL_LANGUAGES.get(language, language)
|
||||
system_content = AI_TRANSLATE.format(language=language_display)
|
||||
return self.call_ai_api(system_content, text)
|
||||
|
||||
|
||||
@cache
|
||||
def get_legacy_ai_service() -> LegacyAIService:
|
||||
"""Helper responsible to correctly instantiate and configure legacy ai service."""
|
||||
|
||||
ai_client = None
|
||||
|
||||
if settings.MISTRAL_SDK_API_KEY:
|
||||
ai_client = LegacyAiServiceMistralClient()
|
||||
|
||||
if settings.OPENAI_SDK_API_KEY:
|
||||
ai_client = LegacyAiServiceOpenAiClient()
|
||||
|
||||
if not ai_client:
|
||||
raise ImproperlyConfigured(
|
||||
"trying to configure legacy ai_service but missing client configuration."
|
||||
)
|
||||
|
||||
return LegacyAIService(ai_client)
|
||||
@@ -49,7 +49,7 @@ class Converter:
|
||||
|
||||
if content_type == mime_types.DOCX and accept == mime_types.YJS:
|
||||
blocknote_data = self.docspec.convert(
|
||||
data, mime_types.DOCX, mime_types.BLOCKNOTE
|
||||
data, content_type, mime_types.BLOCKNOTE
|
||||
)
|
||||
return self.ydoc.convert(
|
||||
blocknote_data, mime_types.BLOCKNOTE, mime_types.YJS
|
||||
@@ -66,8 +66,11 @@ class DocSpecConverter:
|
||||
|
||||
response = requests.post(
|
||||
url,
|
||||
headers={"Accept": mime_types.BLOCKNOTE},
|
||||
files={"file": ("document.docx", data, content_type)},
|
||||
headers={
|
||||
"Content-Type": content_type,
|
||||
"Accept": mime_types.BLOCKNOTE,
|
||||
},
|
||||
data=data,
|
||||
timeout=settings.CONVERSION_API_TIMEOUT,
|
||||
verify=settings.CONVERSION_API_SECURE,
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.services.ai_services.blocknote import configure_pydantic_model_provider
|
||||
from core.tests.conftest import TEAM, USER, VIA
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -20,13 +21,14 @@ pytestmark = pytest.mark.django_db
|
||||
def ai_settings(settings):
|
||||
"""Fixture to set AI settings."""
|
||||
settings.AI_MODEL = "llama"
|
||||
settings.AI_BASE_URL = "http://localhost-ai:12345/"
|
||||
settings.AI_API_KEY = "test-key"
|
||||
settings.OPENAI_SDK_BASE_URL = "http://localhost-ai:12345/"
|
||||
settings.OPENAI_SDK_API_KEY = "test-key"
|
||||
settings.AI_FEATURE_ENABLED = True
|
||||
settings.AI_FEATURE_BLOCKNOTE_ENABLED = True
|
||||
settings.AI_FEATURE_LEGACY_ENABLED = True
|
||||
settings.LANGFUSE_PUBLIC_KEY = None
|
||||
settings.AI_VERCEL_SDK_VERSION = 6
|
||||
configure_pydantic_model_provider.cache_clear()
|
||||
|
||||
|
||||
@override_settings(
|
||||
@@ -65,7 +67,7 @@ def test_api_documents_ai_proxy_anonymous_forbidden(reach, role):
|
||||
|
||||
|
||||
@override_settings(AI_ALLOW_REACH_FROM="public")
|
||||
@patch("core.services.ai_services.AIService.stream")
|
||||
@patch("core.services.ai_services.blocknote.AIService.stream")
|
||||
def test_api_documents_ai_proxy_anonymous_success(mock_stream):
|
||||
"""
|
||||
Anonymous users should be able to request AI proxy to a document
|
||||
@@ -149,7 +151,7 @@ def test_api_documents_ai_proxy_authenticated_forbidden(reach, role):
|
||||
("public", "editor"),
|
||||
],
|
||||
)
|
||||
@patch("core.services.ai_services.AIService.stream")
|
||||
@patch("core.services.ai_services.blocknote.AIService.stream")
|
||||
def test_api_documents_ai_proxy_authenticated_success(mock_stream, reach, role):
|
||||
"""
|
||||
Authenticated users should be able to request AI proxy to a document
|
||||
@@ -205,7 +207,7 @@ def test_api_documents_ai_proxy_reader(via, mock_user_teams):
|
||||
|
||||
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
@patch("core.services.ai_services.AIService.stream")
|
||||
@patch("core.services.ai_services.blocknote.AIService.stream")
|
||||
def test_api_documents_ai_proxy_success(mock_stream, via, role, mock_user_teams):
|
||||
"""Users with sufficient permissions should be able to request AI proxy."""
|
||||
user = factories.UserFactory()
|
||||
@@ -266,7 +268,7 @@ def test_api_documents_ai_proxy_ai_feature_disabled(settings, setting_to_disable
|
||||
|
||||
|
||||
@override_settings(AI_DOCUMENT_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
|
||||
@patch("core.services.ai_services.AIService.stream")
|
||||
@patch("core.services.ai_services.blocknote.AIService.stream")
|
||||
def test_api_documents_ai_proxy_throttling_document(mock_stream):
|
||||
"""
|
||||
Throttling per document should be triggered on the AI proxy endpoint.
|
||||
@@ -304,7 +306,7 @@ def test_api_documents_ai_proxy_throttling_document(mock_stream):
|
||||
|
||||
|
||||
@override_settings(AI_USER_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
|
||||
@patch("core.services.ai_services.AIService.stream")
|
||||
@patch("core.services.ai_services.blocknote.AIService.stream")
|
||||
def test_api_documents_ai_proxy_throttling_user(mock_stream):
|
||||
"""
|
||||
Throttling per user should be triggered on the AI proxy endpoint.
|
||||
@@ -339,7 +341,7 @@ def test_api_documents_ai_proxy_throttling_user(mock_stream):
|
||||
}
|
||||
|
||||
|
||||
@patch("core.services.ai_services.AIService.stream")
|
||||
@patch("core.services.ai_services.blocknote.AIService.stream")
|
||||
def test_api_documents_ai_proxy_returns_streaming_response(mock_stream):
|
||||
"""AI proxy should return a StreamingHttpResponse with correct headers."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
@@ -2,47 +2,62 @@
|
||||
Test AI transform API endpoint for users in impress's core app.
|
||||
"""
|
||||
|
||||
import random
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.test import override_settings
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.services.ai_services.legacy import get_legacy_ai_service
|
||||
from core.tests.conftest import TEAM, USER, VIA
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ai_settings():
|
||||
def ai_settings(settings):
|
||||
"""Fixture to set AI settings."""
|
||||
with override_settings(
|
||||
AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="llama"
|
||||
):
|
||||
yield
|
||||
settings.AI_FEATURE_ENABLED = True
|
||||
settings.AI_FEATURE_LEGACY_ENABLED = True
|
||||
settings.OPENAI_SDK_BASE_URL = "http://example.com"
|
||||
settings.OPENAI_SDK_API_KEY = "test-key"
|
||||
settings.AI_MODEL = "llama"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_openai_client_config():
|
||||
"""Clear the _configure_legacy_openai_client cache"""
|
||||
get_legacy_ai_service.cache_clear()
|
||||
|
||||
|
||||
@override_settings(
|
||||
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"reach, role",
|
||||
"reach, role, ai_allow_reach_from",
|
||||
[
|
||||
("restricted", "reader"),
|
||||
("restricted", "editor"),
|
||||
("authenticated", "reader"),
|
||||
("authenticated", "editor"),
|
||||
("public", "reader"),
|
||||
("restricted", "reader", "public"),
|
||||
("restricted", "reader", "authenticated"),
|
||||
("restricted", "reader", "restricted"),
|
||||
("restricted", "editor", "public"),
|
||||
("restricted", "editor", "authenticated"),
|
||||
("restricted", "editor", "restricted"),
|
||||
("authenticated", "reader", "public"),
|
||||
("authenticated", "reader", "authenticated"),
|
||||
("authenticated", "reader", "restricted"),
|
||||
("authenticated", "editor", "public"),
|
||||
("authenticated", "editor", "authenticated"),
|
||||
("authenticated", "editor", "restricted"),
|
||||
("public", "reader", "public"),
|
||||
("public", "reader", "authenticated"),
|
||||
("public", "reader", "restricted"),
|
||||
],
|
||||
)
|
||||
def test_api_documents_ai_transform_anonymous_forbidden(reach, role):
|
||||
def test_api_documents_ai_transform_anonymous_forbidden(
|
||||
reach, role, ai_allow_reach_from, settings
|
||||
):
|
||||
"""
|
||||
Anonymous users should not be able to request AI transform if the link reach
|
||||
and role don't allow it.
|
||||
"""
|
||||
settings.AI_ALLOW_REACH_FROM = ai_allow_reach_from
|
||||
document = factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
|
||||
@@ -54,14 +69,14 @@ def test_api_documents_ai_transform_anonymous_forbidden(reach, role):
|
||||
}
|
||||
|
||||
|
||||
@override_settings(AI_ALLOW_REACH_FROM="public")
|
||||
@pytest.mark.usefixtures("ai_settings")
|
||||
@patch("openai.resources.chat.completions.Completions.create")
|
||||
def test_api_documents_ai_transform_anonymous_success(mock_create):
|
||||
def test_api_documents_ai_transform_anonymous_success(mock_create, settings):
|
||||
"""
|
||||
Anonymous users should be able to request AI transform to a document
|
||||
if the link reach and role permit it.
|
||||
"""
|
||||
settings.AI_ALLOW_REACH_FROM = "public"
|
||||
document = factories.DocumentFactory(link_reach="public", link_role="editor")
|
||||
|
||||
mock_create.return_value = MagicMock(
|
||||
@@ -88,14 +103,17 @@ def test_api_documents_ai_transform_anonymous_success(mock_create):
|
||||
)
|
||||
|
||||
|
||||
@override_settings(AI_ALLOW_REACH_FROM=random.choice(["authenticated", "restricted"]))
|
||||
@pytest.mark.usefixtures("ai_settings")
|
||||
@pytest.mark.parametrize("ai_allow_reach_from", ["authenticated", "restricted"])
|
||||
@patch("openai.resources.chat.completions.Completions.create")
|
||||
def test_api_documents_ai_transform_anonymous_limited_by_setting(mock_create):
|
||||
def test_api_documents_ai_transform_anonymous_limited_by_setting(
|
||||
mock_create, ai_allow_reach_from, settings
|
||||
):
|
||||
"""
|
||||
Anonymous users should be able to request AI transform to a document
|
||||
if the link reach and role permit it.
|
||||
"""
|
||||
settings.AI_ALLOW_REACH_FROM = ai_allow_reach_from
|
||||
document = factories.DocumentFactory(link_reach="public", link_role="editor")
|
||||
|
||||
answer = '{"answer": "Salut"}'
|
||||
@@ -176,8 +194,8 @@ def test_api_documents_ai_transform_authenticated_success(mock_create, reach, ro
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Answer the prompt using markdown formatting for structure and emphasis. "
|
||||
"Return the content directly without wrapping it in code blocks or markdown delimiters. "
|
||||
"Preserve the language and markdown formatting. "
|
||||
"Return the content directly without wrapping it in code blocks or markdown "
|
||||
"delimiters. Preserve the language and markdown formatting. "
|
||||
"Do not provide any other information. "
|
||||
"Preserve the language."
|
||||
),
|
||||
@@ -253,8 +271,8 @@ def test_api_documents_ai_transform_success(mock_create, via, role, mock_user_te
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Answer the prompt using markdown formatting for structure and emphasis. "
|
||||
"Return the content directly without wrapping it in code blocks or markdown delimiters. "
|
||||
"Preserve the language and markdown formatting. "
|
||||
"Return the content directly without wrapping it in code blocks or markdown "
|
||||
"delimiters. Preserve the language and markdown formatting. "
|
||||
"Do not provide any other information. "
|
||||
"Preserve the language."
|
||||
),
|
||||
@@ -264,6 +282,7 @@ def test_api_documents_ai_transform_success(mock_create, via, role, mock_user_te
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("ai_settings")
|
||||
def test_api_documents_ai_transform_empty_text():
|
||||
"""The text should not be empty when requesting AI transform."""
|
||||
user = factories.UserFactory()
|
||||
@@ -280,6 +299,7 @@ def test_api_documents_ai_transform_empty_text():
|
||||
assert response.json() == {"text": ["This field may not be blank."]}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("ai_settings")
|
||||
def test_api_documents_ai_transform_invalid_action():
|
||||
"""The action should valid when requesting AI transform."""
|
||||
user = factories.UserFactory()
|
||||
@@ -296,14 +316,14 @@ def test_api_documents_ai_transform_invalid_action():
|
||||
assert response.json() == {"action": ['"invalid" is not a valid choice.']}
|
||||
|
||||
|
||||
@override_settings(AI_DOCUMENT_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
|
||||
@pytest.mark.usefixtures("ai_settings")
|
||||
@patch("openai.resources.chat.completions.Completions.create")
|
||||
def test_api_documents_ai_transform_throttling_document(mock_create):
|
||||
def test_api_documents_ai_transform_throttling_document(mock_create, settings):
|
||||
"""
|
||||
Throttling per document should be triggered on the AI transform endpoint.
|
||||
For full throttle class test see: `test_api_utils_ai_document_rate_throttles`
|
||||
"""
|
||||
settings.AI_DOCUMENT_RATE_THROTTLE_RATES = {"minute": 3, "hour": 6, "day": 10}
|
||||
client = APIClient()
|
||||
document = factories.DocumentFactory(link_reach="public", link_role="editor")
|
||||
|
||||
@@ -329,14 +349,14 @@ def test_api_documents_ai_transform_throttling_document(mock_create):
|
||||
}
|
||||
|
||||
|
||||
@override_settings(AI_USER_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
|
||||
@pytest.mark.usefixtures("ai_settings")
|
||||
@patch("openai.resources.chat.completions.Completions.create")
|
||||
def test_api_documents_ai_transform_throttling_user(mock_create):
|
||||
def test_api_documents_ai_transform_throttling_user(mock_create, settings):
|
||||
"""
|
||||
Throttling per user should be triggered on the AI transform endpoint.
|
||||
For full throttle class test see: `test_api_utils_ai_user_rate_throttles`
|
||||
"""
|
||||
settings.AI_USER_RATE_THROTTLE_RATES = {"minute": 3, "hour": 6, "day": 10}
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
@@ -2,27 +2,32 @@
|
||||
Test AI translate API endpoint for users in impress's core app.
|
||||
"""
|
||||
|
||||
import random
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.test import override_settings
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.services.ai_services.legacy import get_legacy_ai_service
|
||||
from core.tests.conftest import TEAM, USER, VIA
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ai_settings():
|
||||
def ai_settings(settings):
|
||||
"""Fixture to set AI settings."""
|
||||
with override_settings(
|
||||
AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="llama"
|
||||
):
|
||||
yield
|
||||
settings.AI_FEATURE_ENABLED = True
|
||||
settings.AI_FEATURE_LEGACY_ENABLED = True
|
||||
settings.OPENAI_SDK_BASE_URL = "http://example.com"
|
||||
settings.OPENAI_SDK_API_KEY = "test-key"
|
||||
settings.AI_MODEL = "llama"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_openai_client_config():
|
||||
"clear the configure_legacy_openai_client cache"
|
||||
get_legacy_ai_service.cache_clear()
|
||||
|
||||
|
||||
def test_api_documents_ai_translate_viewset_options_metadata():
|
||||
@@ -45,24 +50,34 @@ def test_api_documents_ai_translate_viewset_options_metadata():
|
||||
}
|
||||
|
||||
|
||||
@override_settings(
|
||||
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"reach, role",
|
||||
"reach, role, ai_allow_reach_from",
|
||||
[
|
||||
("restricted", "reader"),
|
||||
("restricted", "editor"),
|
||||
("authenticated", "reader"),
|
||||
("authenticated", "editor"),
|
||||
("public", "reader"),
|
||||
("restricted", "reader", "public"),
|
||||
("restricted", "reader", "authenticated"),
|
||||
("restricted", "reader", "restricted"),
|
||||
("restricted", "editor", "public"),
|
||||
("restricted", "editor", "authenticated"),
|
||||
("restricted", "editor", "restricted"),
|
||||
("authenticated", "reader", "public"),
|
||||
("authenticated", "reader", "authenticated"),
|
||||
("authenticated", "reader", "restricted"),
|
||||
("authenticated", "editor", "public"),
|
||||
("authenticated", "editor", "authenticated"),
|
||||
("authenticated", "editor", "restricted"),
|
||||
("public", "reader", "public"),
|
||||
("public", "reader", "authenticated"),
|
||||
("public", "reader", "restricted"),
|
||||
],
|
||||
)
|
||||
def test_api_documents_ai_translate_anonymous_forbidden(reach, role):
|
||||
def test_api_documents_ai_translate_anonymous_forbidden(
|
||||
reach, role, ai_allow_reach_from, settings
|
||||
):
|
||||
"""
|
||||
Anonymous users should not be able to request AI translate if the link reach
|
||||
and role don't allow it.
|
||||
"""
|
||||
settings.AI_ALLOW_REACH_FROM = ai_allow_reach_from
|
||||
document = factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
|
||||
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
|
||||
@@ -74,14 +89,14 @@ def test_api_documents_ai_translate_anonymous_forbidden(reach, role):
|
||||
}
|
||||
|
||||
|
||||
@override_settings(AI_ALLOW_REACH_FROM="public")
|
||||
@pytest.mark.usefixtures("ai_settings")
|
||||
@patch("openai.resources.chat.completions.Completions.create")
|
||||
def test_api_documents_ai_translate_anonymous_success(mock_create):
|
||||
def test_api_documents_ai_translate_anonymous_success(mock_create, settings):
|
||||
"""
|
||||
Anonymous users should be able to request AI translate to a document
|
||||
if the link reach and role permit it.
|
||||
"""
|
||||
settings.AI_ALLOW_REACH_FROM = "public"
|
||||
document = factories.DocumentFactory(link_reach="public", link_role="editor")
|
||||
|
||||
mock_create.return_value = MagicMock(
|
||||
@@ -102,7 +117,9 @@ def test_api_documents_ai_translate_anonymous_success(mock_create):
|
||||
"Keep the same html structure and formatting. "
|
||||
"Translate the content in the html to the specified language Spanish. "
|
||||
"Check the translation for accuracy and make any necessary corrections. "
|
||||
"Do not provide any other information."
|
||||
"Do not provide any other information. "
|
||||
"Return the content directly without wrapping it in code blocks or markdown "
|
||||
"delimiters."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": "Hello"},
|
||||
@@ -110,14 +127,17 @@ def test_api_documents_ai_translate_anonymous_success(mock_create):
|
||||
)
|
||||
|
||||
|
||||
@override_settings(AI_ALLOW_REACH_FROM=random.choice(["authenticated", "restricted"]))
|
||||
@pytest.mark.usefixtures("ai_settings")
|
||||
@pytest.mark.parametrize("ai_allow_reach_from", ["authenticated", "restricted"])
|
||||
@patch("openai.resources.chat.completions.Completions.create")
|
||||
def test_api_documents_ai_translate_anonymous_limited_by_setting(mock_create):
|
||||
def test_api_documents_ai_translate_anonymous_limited_by_setting(
|
||||
mock_create, ai_allow_reach_from, settings
|
||||
):
|
||||
"""
|
||||
Anonymous users should be able to request AI translate to a document
|
||||
if the link reach and role permit it.
|
||||
"""
|
||||
settings.AI_ALLOW_REACH_FROM = ai_allow_reach_from
|
||||
document = factories.DocumentFactory(link_reach="public", link_role="editor")
|
||||
|
||||
answer = '{"answer": "Salut"}'
|
||||
@@ -201,7 +221,9 @@ def test_api_documents_ai_translate_authenticated_success(mock_create, reach, ro
|
||||
"Translate the content in the html to the "
|
||||
"specified language Colombian Spanish. "
|
||||
"Check the translation for accuracy and make any necessary corrections. "
|
||||
"Do not provide any other information."
|
||||
"Do not provide any other information. "
|
||||
"Return the content directly without wrapping it in code blocks or markdown "
|
||||
"delimiters."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": "Hello"},
|
||||
@@ -278,7 +300,9 @@ def test_api_documents_ai_translate_success(mock_create, via, role, mock_user_te
|
||||
"Translate the content in the html to the "
|
||||
"specified language Colombian Spanish. "
|
||||
"Check the translation for accuracy and make any necessary corrections. "
|
||||
"Do not provide any other information."
|
||||
"Do not provide any other information. "
|
||||
"Return the content directly without wrapping it in code blocks or markdown "
|
||||
"delimiters."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": "Hello"},
|
||||
@@ -286,6 +310,7 @@ def test_api_documents_ai_translate_success(mock_create, via, role, mock_user_te
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("ai_settings")
|
||||
def test_api_documents_ai_translate_empty_text():
|
||||
"""The text should not be empty when requesting AI translate."""
|
||||
user = factories.UserFactory()
|
||||
@@ -302,6 +327,7 @@ def test_api_documents_ai_translate_empty_text():
|
||||
assert response.json() == {"text": ["This field may not be blank."]}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("ai_settings")
|
||||
def test_api_documents_ai_translate_invalid_action():
|
||||
"""The action should valid when requesting AI translate."""
|
||||
user = factories.UserFactory()
|
||||
@@ -318,14 +344,14 @@ def test_api_documents_ai_translate_invalid_action():
|
||||
assert response.json() == {"language": ['"invalid" is not a valid choice.']}
|
||||
|
||||
|
||||
@override_settings(AI_DOCUMENT_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
|
||||
@pytest.mark.usefixtures("ai_settings")
|
||||
@patch("openai.resources.chat.completions.Completions.create")
|
||||
def test_api_documents_ai_translate_throttling_document(mock_create):
|
||||
def test_api_documents_ai_translate_throttling_document(mock_create, settings):
|
||||
"""
|
||||
Throttling per document should be triggered on the AI translate endpoint.
|
||||
For full throttle class test see: `test_api_utils_ai_document_rate_throttles`
|
||||
"""
|
||||
settings.AI_DOCUMENT_RATE_THROTTLE_RATES = {"minute": 3, "hour": 6, "day": 10}
|
||||
client = APIClient()
|
||||
document = factories.DocumentFactory(link_reach="public", link_role="editor")
|
||||
|
||||
@@ -351,14 +377,14 @@ def test_api_documents_ai_translate_throttling_document(mock_create):
|
||||
}
|
||||
|
||||
|
||||
@override_settings(AI_USER_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
|
||||
@pytest.mark.usefixtures("ai_settings")
|
||||
@patch("openai.resources.chat.completions.Completions.create")
|
||||
def test_api_documents_ai_translate_throttling_user(mock_create):
|
||||
def test_api_documents_ai_translate_throttling_user(mock_create, settings):
|
||||
"""
|
||||
Throttling per user should be triggered on the AI translate endpoint.
|
||||
For full throttle class test see: `test_api_utils_ai_user_rate_throttles`
|
||||
"""
|
||||
settings.AI_USER_RATE_THROTTLE_RATES = {"minute": 3, "hour": 6, "day": 10}
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
@@ -644,11 +644,13 @@ def test_create_reaction_anonymous_user_public_document(link_role):
|
||||
document = factories.DocumentFactory(link_reach="public", link_role=link_role)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread)
|
||||
reaction = factories.ReactionFactory(comment=comment)
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
|
||||
f"comments/{comment.id!s}/reactions/",
|
||||
{"emoji": "test"},
|
||||
{"emoji": reaction.emoji},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
@@ -664,12 +666,14 @@ def test_create_reaction_authenticated_user_public_document():
|
||||
)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread)
|
||||
reaction = factories.ReactionFactory(comment=comment)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
|
||||
f"comments/{comment.id!s}/reactions/",
|
||||
{"emoji": "test"},
|
||||
{"emoji": reaction.emoji},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
@@ -684,17 +688,19 @@ def test_create_reaction_authenticated_user_accessible_public_document():
|
||||
)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread)
|
||||
reaction = factories.ReactionFactory(comment=comment)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
|
||||
f"comments/{comment.id!s}/reactions/",
|
||||
{"emoji": "test"},
|
||||
{"emoji": reaction.emoji},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
assert models.Reaction.objects.filter(
|
||||
comment=comment, emoji="test", users__in=[user]
|
||||
comment=comment, emoji=reaction.emoji, users__in=[user]
|
||||
).exists()
|
||||
|
||||
|
||||
@@ -709,12 +715,14 @@ def test_create_reaction_authenticated_user_connected_document_link_role_reader(
|
||||
)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread)
|
||||
reaction = factories.ReactionFactory(comment=comment)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
|
||||
f"comments/{comment.id!s}/reactions/",
|
||||
{"emoji": "test"},
|
||||
{"emoji": reaction.emoji},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
@@ -737,17 +745,19 @@ def test_create_reaction_authenticated_user_connected_document(link_role):
|
||||
)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread)
|
||||
reaction = factories.ReactionFactory(comment=comment)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
|
||||
f"comments/{comment.id!s}/reactions/",
|
||||
{"emoji": "test"},
|
||||
{"emoji": reaction.emoji},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
assert models.Reaction.objects.filter(
|
||||
comment=comment, emoji="test", users__in=[user]
|
||||
comment=comment, emoji=reaction.emoji, users__in=[user]
|
||||
).exists()
|
||||
|
||||
|
||||
@@ -760,12 +770,14 @@ def test_create_reaction_authenticated_user_restricted_accessible_document():
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread)
|
||||
reaction = factories.ReactionFactory(comment=comment)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
|
||||
f"comments/{comment.id!s}/reactions/",
|
||||
{"emoji": "test"},
|
||||
{"emoji": reaction.emoji},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
@@ -781,12 +793,14 @@ def test_create_reaction_authenticated_user_restricted_accessible_document_role_
|
||||
)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread)
|
||||
reaction = factories.ReactionFactory(comment=comment)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
|
||||
f"comments/{comment.id!s}/reactions/",
|
||||
{"emoji": "test"},
|
||||
{"emoji": reaction.emoji},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
@@ -806,26 +820,70 @@ def test_create_reaction_authenticated_user_restricted_accessible_document_role_
|
||||
document = factories.DocumentFactory(link_reach="restricted", users=[(user, role)])
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread)
|
||||
reaction = factories.ReactionFactory(comment=comment)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
|
||||
f"comments/{comment.id!s}/reactions/",
|
||||
{"emoji": "test"},
|
||||
{"emoji": reaction.emoji},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
assert models.Reaction.objects.filter(
|
||||
comment=comment, emoji="test", users__in=[user]
|
||||
comment=comment, emoji=reaction.emoji, users__in=[user]
|
||||
).exists()
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
|
||||
f"comments/{comment.id!s}/reactions/",
|
||||
{"emoji": reaction.emoji},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"user_already_reacted": True}
|
||||
|
||||
|
||||
def test_create_reaction_invalid_emoji():
|
||||
"""Users should not be able to submit non-emojis as reactions."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(
|
||||
link_reach="restricted", users=[(user, models.RoleChoices.COMMENTER)]
|
||||
)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
|
||||
f"comments/{comment.id!s}/reactions/",
|
||||
{"emoji": "test"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"user_already_reacted": True}
|
||||
assert "Reaction must be a single valid emoji." in str(response.json())
|
||||
|
||||
|
||||
def test_create_reaction_multiple_emojis():
|
||||
"""Users should not be able to submit multiple emojis as a single reaction."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(
|
||||
link_reach="restricted", users=[(user, models.RoleChoices.COMMENTER)]
|
||||
)
|
||||
thread = factories.ThreadFactory(document=document)
|
||||
comment = factories.CommentFactory(thread=thread)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
|
||||
f"comments/{comment.id!s}/reactions/",
|
||||
{"emoji": "🐛🐛"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "Reaction must be a single valid emoji." in str(response.json())
|
||||
|
||||
|
||||
# Delete reaction
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
"""
|
||||
Tests for the GET /api/v1.0/documents/{id}/content/ endpoint.
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.core.files.storage import default_storage
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.api.utils import get_content_metadata_cache_key
|
||||
from core.tests.conftest import TEAM, USER, VIA
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", ["authenticated", "restricted"])
|
||||
def test_api_documents_content_retrieve_anonymous_non_public(reach):
|
||||
"""Anonymous users cannot retrieve content of non-public documents."""
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/content/")
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
def test_api_documents_content_retrieve_anonymous_public():
|
||||
"""Anonymous users can retrieve content of a public document."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
|
||||
assert not cache.get(get_content_metadata_cache_key(document.id))
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/content/")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response["Content-Type"] == "text/plain"
|
||||
assert b"".join(
|
||||
response.streaming_content
|
||||
) == factories.YDOC_HELLO_WORLD_BASE64.encode("utf-8")
|
||||
assert response["Content-Length"] is not None
|
||||
assert response["ETag"] is not None
|
||||
assert response["Last-Modified"] is not None
|
||||
assert response["Cache-Control"] == "private, no-cache"
|
||||
|
||||
assert cache.get(get_content_metadata_cache_key(document.id))
|
||||
|
||||
|
||||
def test_api_documents_content_retrieve_authenticated_no_access():
|
||||
"""Authenticated users without access cannot retrieve content of a restricted document."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/content/")
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
@pytest.mark.parametrize("link_reach", ["authenticated", "public"])
|
||||
def test_api_documents_content_retrieve_authenticated_not_restricted(link_reach):
|
||||
"""
|
||||
Authenticated users can retrieve content of a public document
|
||||
without any explicit access grant.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach=link_reach)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
assert not cache.get(get_content_metadata_cache_key(document.id))
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/content/")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert b"".join(
|
||||
response.streaming_content
|
||||
) == factories.YDOC_HELLO_WORLD_BASE64.encode("utf-8")
|
||||
assert response["Content-Length"] is not None
|
||||
assert response["ETag"] is not None
|
||||
assert response["Last-Modified"] is not None
|
||||
assert response["Cache-Control"] == "private, no-cache"
|
||||
|
||||
assert cache.get(get_content_metadata_cache_key(document.id))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
@pytest.mark.parametrize(
|
||||
"role", ["reader", "commenter", "editor", "administrator", "owner"]
|
||||
)
|
||||
def test_api_documents_content_retrieve_success(role, via, mock_user_teams):
|
||||
"""Users with any role can retrieve document content, directly or via a team."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="lasuite", role=role
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
assert not cache.get(get_content_metadata_cache_key(document.id))
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/content/")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert b"".join(
|
||||
response.streaming_content
|
||||
) == factories.YDOC_HELLO_WORLD_BASE64.encode("utf-8")
|
||||
assert response["Content-Length"] is not None
|
||||
assert response["ETag"] is not None
|
||||
assert response["Last-Modified"] is not None
|
||||
assert response["Cache-Control"] == "private, no-cache"
|
||||
|
||||
assert cache.get(get_content_metadata_cache_key(document.id))
|
||||
|
||||
|
||||
def test_api_documents_content_retrieve_nonexistent_document():
|
||||
"""Retrieving content of a non-existent document returns 404."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/{uuid4()!s}/content/")
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
def test_api_documents_content_retrieve_file_not_in_storage():
|
||||
"""Returns an empty string when the file does not exists on the storage."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role="reader")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
default_storage.delete(document.file_key)
|
||||
|
||||
assert not default_storage.exists(document.file_key)
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/content/")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert b"".join(response.streaming_content) == b""
|
||||
assert not response.get("Content-Length")
|
||||
assert not response.get("ETag")
|
||||
assert not response.get("Last-Modified")
|
||||
assert not response.get("Cache-Control")
|
||||
|
||||
assert not cache.get(get_content_metadata_cache_key(document.id))
|
||||
|
||||
|
||||
def test_api_documents_content_retrieve_content_length_header():
|
||||
"""The response includes the Content-Length header when available from storage."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role="reader")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/content/")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
expected_size = default_storage.size(document.file_key)
|
||||
assert int(response["Content-Length"]) == expected_size
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["reader", "commenter", "editor", "administrator"])
|
||||
def test_api_documents_content_retrieve_deleted_document_for_non_owners_all_roles(role):
|
||||
"""
|
||||
Retrieving content of a soft-deleted document returns 404 for any non-owner role.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
|
||||
|
||||
document.soft_delete()
|
||||
document.refresh_from_db()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/content/")
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
def test_api_documents_content_retrieve_deleted_document_for_owner():
|
||||
"""
|
||||
Owners can still retrieve content of a soft-deleted document.
|
||||
|
||||
The 'retrieve' ability is True for owners regardless of deletion state.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
|
||||
|
||||
document.soft_delete()
|
||||
document.refresh_from_db()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
assert not cache.get(get_content_metadata_cache_key(document.id))
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/content/")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert b"".join(
|
||||
response.streaming_content
|
||||
) == factories.YDOC_HELLO_WORLD_BASE64.encode("utf-8")
|
||||
assert response["Content-Length"] is not None
|
||||
assert response["ETag"] is not None
|
||||
assert response["Last-Modified"] is not None
|
||||
assert response["Cache-Control"] == "private, no-cache"
|
||||
|
||||
assert cache.get(get_content_metadata_cache_key(document.id))
|
||||
|
||||
|
||||
def test_api_documents_content_retrieve_reusing_etag():
|
||||
"""Fetching content reusing a valid ETag header should return a 304."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
file_metadata = default_storage.connection.meta.client.head_object(
|
||||
Bucket=default_storage.bucket_name, Key=document.file_key
|
||||
)
|
||||
last_modified = file_metadata["LastModified"]
|
||||
etag = file_metadata["ETag"]
|
||||
size = file_metadata["ContentLength"]
|
||||
|
||||
cache.set(
|
||||
get_content_metadata_cache_key(document.id),
|
||||
{
|
||||
"last_modified": last_modified.isoformat(),
|
||||
"etag": etag,
|
||||
"size": size,
|
||||
},
|
||||
)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/content/",
|
||||
headers={"If-None-Match": etag},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_304_NOT_MODIFIED
|
||||
|
||||
|
||||
def test_api_documents_content_retrieve_reusing_invalid_etag():
|
||||
"""Fetching content using an invalid ETag header should return a 200."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
file_metadata = default_storage.connection.meta.client.head_object(
|
||||
Bucket=default_storage.bucket_name, Key=document.file_key
|
||||
)
|
||||
last_modified = file_metadata["LastModified"]
|
||||
etag = file_metadata["ETag"]
|
||||
size = file_metadata["ContentLength"]
|
||||
|
||||
cache.set(
|
||||
get_content_metadata_cache_key(document.id),
|
||||
{
|
||||
"last_modified": last_modified.isoformat(),
|
||||
"etag": etag,
|
||||
"size": size,
|
||||
},
|
||||
)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/content/",
|
||||
headers={"If-None-Match": "invalid"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert b"".join(
|
||||
response.streaming_content
|
||||
) == factories.YDOC_HELLO_WORLD_BASE64.encode("utf-8")
|
||||
assert response["Content-Length"] is not None
|
||||
assert response["ETag"] is not None
|
||||
assert response["Last-Modified"] is not None
|
||||
assert response["Cache-Control"] == "private, no-cache"
|
||||
|
||||
|
||||
def test_api_documents_content_retrieve_using_etag_without_cache():
|
||||
"""
|
||||
Fetching content using a valid ETag header but without existing cache should return a 304.
|
||||
"""
|
||||
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
file_metadata = default_storage.connection.meta.client.head_object(
|
||||
Bucket=default_storage.bucket_name, Key=document.file_key
|
||||
)
|
||||
etag = file_metadata["ETag"]
|
||||
|
||||
assert not cache.get(get_content_metadata_cache_key(document.id))
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/content/",
|
||||
headers={"If-None-Match": etag},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_304_NOT_MODIFIED
|
||||
|
||||
|
||||
def test_api_documents_content_retrieve_reusing_last_modified_since():
|
||||
"""Fetching a content using a If-Modified-Since valid should return a 304."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
file_metadata = default_storage.connection.meta.client.head_object(
|
||||
Bucket=default_storage.bucket_name, Key=document.file_key
|
||||
)
|
||||
last_modified = file_metadata["LastModified"]
|
||||
etag = file_metadata["ETag"]
|
||||
size = file_metadata["ContentLength"]
|
||||
|
||||
cache.set(
|
||||
get_content_metadata_cache_key(document.id),
|
||||
{
|
||||
"last_modified": last_modified.isoformat(),
|
||||
"etag": etag,
|
||||
"size": size,
|
||||
},
|
||||
)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/content/",
|
||||
headers={
|
||||
"If-Modified-Since": timezone.now().strftime("%a, %d %b %Y %H:%M:%S %Z")
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_304_NOT_MODIFIED
|
||||
|
||||
|
||||
def test_api_documents_content_retrieve_using_last_modified_since_without_cache():
|
||||
"""
|
||||
Fetching a content using a If-Modified-Since valid should return a 304
|
||||
even if content metadata are not present in cache.
|
||||
"""
|
||||
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
assert not cache.get(get_content_metadata_cache_key(document.id))
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/content/",
|
||||
headers={
|
||||
"If-Modified-Since": timezone.now().strftime("%a, %d %b %Y %H:%M:%S %Z")
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_304_NOT_MODIFIED
|
||||
|
||||
|
||||
def test_api_documents_content_retrieve_reusing_last_modified_since_invalid():
|
||||
"""Fetching a content using a If-Modified-Since invalid should return a 200."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
file_metadata = default_storage.connection.meta.client.head_object(
|
||||
Bucket=default_storage.bucket_name, Key=document.file_key
|
||||
)
|
||||
last_modified = file_metadata["LastModified"]
|
||||
etag = file_metadata["ETag"]
|
||||
size = file_metadata["ContentLength"]
|
||||
|
||||
cache.set(
|
||||
get_content_metadata_cache_key(document.id),
|
||||
{
|
||||
"last_modified": last_modified.isoformat(),
|
||||
"etag": etag,
|
||||
"size": size,
|
||||
},
|
||||
)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/content/",
|
||||
headers={
|
||||
"If-Modified-Since": (timezone.now() - timedelta(minutes=60)).strftime(
|
||||
"%a, %d %b %Y %H:%M:%S %Z"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert b"".join(
|
||||
response.streaming_content
|
||||
) == factories.YDOC_HELLO_WORLD_BASE64.encode("utf-8")
|
||||
assert response["Content-Length"] is not None
|
||||
assert response["ETag"] is not None
|
||||
assert response["Last-Modified"] is not None
|
||||
assert response["Cache-Control"] == "private, no-cache"
|
||||
@@ -0,0 +1,587 @@
|
||||
"""
|
||||
Tests for the PATCH /api/v1.0/documents/{id}/content/ endpoint.
|
||||
"""
|
||||
|
||||
import base64
|
||||
from functools import cache
|
||||
from uuid import uuid4
|
||||
|
||||
from django.core.cache import cache as django_cache
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
import pycrdt
|
||||
import pytest
|
||||
import responses
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core.tests.conftest import TEAM, USER, VIA
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@cache
|
||||
def get_sample_ydoc():
|
||||
"""Return a ydoc from text for testing purposes."""
|
||||
ydoc = pycrdt.Doc()
|
||||
ydoc["document-store"] = pycrdt.Text("Hello")
|
||||
update = ydoc.get_update()
|
||||
return base64.b64encode(update).decode("utf-8")
|
||||
|
||||
|
||||
def get_s3_content(document):
|
||||
"""Read the raw content currently stored in S3 for the given document."""
|
||||
with default_storage.open(document.file_key, mode="rb") as file:
|
||||
return file.read().decode()
|
||||
|
||||
|
||||
def test_api_documents_content_update_anonymous():
|
||||
"""Anonymous users without access cannot update document content."""
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
|
||||
response = APIClient().patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/content/",
|
||||
{"content": get_sample_ydoc()},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
def test_api_documents_content_update_authenticated_no_access():
|
||||
"""Authenticated users without access cannot update document content."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/content/",
|
||||
{"content": get_sample_ydoc()},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["reader", "commenter"])
|
||||
def test_api_documents_content_update_read_only_role(role):
|
||||
"""Users with reader or commenter role cannot update document content."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/content/",
|
||||
{"content": get_sample_ydoc()},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
|
||||
def test_api_documents_content_update_success(role, via, mock_user_teams):
|
||||
"""Users with editor, administrator, or owner role can update document content."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite"]
|
||||
factories.TeamDocumentAccessFactory(
|
||||
document=document, team="lasuite", role=role
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/content/",
|
||||
{"content": get_sample_ydoc(), "websocket": True},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert get_s3_content(document) == get_sample_ydoc()
|
||||
|
||||
|
||||
def test_api_documents_content_update_missing_content_field():
|
||||
"""A request body without the content field returns 400."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role="editor")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/content/",
|
||||
{},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert response.json() == {
|
||||
"content": [
|
||||
"This field is required.",
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_content_update_invalid_base64():
|
||||
"""A non-base64 content value returns 400."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role="editor")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/content/",
|
||||
{"content": "not-valid-base64!!!"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert response.json() == {
|
||||
"content": [
|
||||
"Invalid base64 content.",
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_content_update_nonexistent_document():
|
||||
"""Updating the content of a non-existent document returns 404."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{uuid4()!s}/content/",
|
||||
{"content": get_sample_ydoc()},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
def test_api_documents_content_update_replaces_existing():
|
||||
"""Patching content replaces whatever was previously in S3."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role="editor")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
assert get_s3_content(document) == factories.YDOC_HELLO_WORLD_BASE64
|
||||
|
||||
new_content = get_sample_ydoc()
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/content/",
|
||||
{"content": new_content, "websocket": True},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert get_s3_content(document) == new_content
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["editor", "administrator"])
|
||||
def test_api_documents_content_update_deleted_document_for_non_owners(role):
|
||||
"""Updating content on a soft-deleted document returns 404 for non-owners.
|
||||
|
||||
Soft-deleted documents are excluded from the queryset for non-owners,
|
||||
so the endpoint returns 404 rather than 403.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
|
||||
|
||||
document.soft_delete()
|
||||
document.refresh_from_db()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/content/",
|
||||
{"content": get_sample_ydoc()},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
def test_api_documents_content_update_deleted_document_for_owners():
|
||||
"""Updating content on a soft-deleted document returns 403 for owners."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
|
||||
|
||||
document.soft_delete()
|
||||
document.refresh_from_db()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/content/",
|
||||
{"content": get_sample_ydoc()},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_api_documents_content_update_link_editor():
|
||||
"""
|
||||
A public document with link_role=editor allows any authenticated user to
|
||||
update content via the link role.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach="public", link_role="editor")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/content/",
|
||||
{"content": get_sample_ydoc(), "websocket": True},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert get_s3_content(document) == get_sample_ydoc()
|
||||
assert models.Document.objects.filter(id=document.id).exists()
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_content_update_authenticated_no_websocket(settings):
|
||||
"""
|
||||
When a user updates the document content, not connected to the websocket and is the first
|
||||
to update, the content should be updated.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
session_key = client.session.session_key
|
||||
|
||||
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||
|
||||
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
|
||||
endpoint_url = (
|
||||
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||
f"?room={document.id}&sessionKey={session_key}"
|
||||
)
|
||||
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
|
||||
|
||||
assert django_cache.get(f"docs:no-websocket:{document.id}") is None
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/content/",
|
||||
{"content": get_sample_ydoc(), "websocket": False},
|
||||
)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert get_s3_content(document) == get_sample_ydoc()
|
||||
assert django_cache.get(f"docs:no-websocket:{document.id}") == session_key
|
||||
assert ws_resp.call_count == 1
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_content_update_authenticated_no_websocket_user_already_editing(
|
||||
settings,
|
||||
):
|
||||
"""
|
||||
When a user updates the document content, not connected to the websocket and another session
|
||||
is already editing, the update should be denied.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
session_key = client.session.session_key
|
||||
|
||||
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||
|
||||
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
|
||||
endpoint_url = (
|
||||
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||
f"?room={document.id}&sessionKey={session_key}"
|
||||
)
|
||||
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
|
||||
|
||||
django_cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/content/",
|
||||
{"content": get_sample_ydoc(), "websocket": False},
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.json() == {"detail": "You are not allowed to edit this document."}
|
||||
assert ws_resp.call_count == 1
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_content_update_no_websocket_other_user_connected_to_websocket(
|
||||
settings,
|
||||
):
|
||||
"""
|
||||
When a user updates document content without websocket and another user is connected
|
||||
to the websocket, the update should be denied.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
session_key = client.session.session_key
|
||||
|
||||
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||
|
||||
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
|
||||
endpoint_url = (
|
||||
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||
f"?room={document.id}&sessionKey={session_key}"
|
||||
)
|
||||
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": False})
|
||||
|
||||
assert django_cache.get(f"docs:no-websocket:{document.id}") is None
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/content/",
|
||||
{"content": get_sample_ydoc(), "websocket": False},
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.json() == {"detail": "You are not allowed to edit this document."}
|
||||
assert django_cache.get(f"docs:no-websocket:{document.id}") is None
|
||||
assert ws_resp.call_count == 1
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_content_update_user_connected_to_websocket(settings):
|
||||
"""
|
||||
When a user updates document content and is connected to the websocket,
|
||||
the content should be updated.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
session_key = client.session.session_key
|
||||
|
||||
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||
|
||||
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
|
||||
endpoint_url = (
|
||||
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||
f"?room={document.id}&sessionKey={session_key}"
|
||||
)
|
||||
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": True})
|
||||
|
||||
assert django_cache.get(f"docs:no-websocket:{document.id}") is None
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/content/",
|
||||
{"content": get_sample_ydoc(), "websocket": False},
|
||||
)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert get_s3_content(document) == get_sample_ydoc()
|
||||
assert django_cache.get(f"docs:no-websocket:{document.id}") is None
|
||||
assert ws_resp.call_count == 1
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_content_update_websocket_server_unreachable_fallback_to_no_websocket(
|
||||
settings,
|
||||
):
|
||||
"""
|
||||
When the websocket server is unreachable, the content should be updated like if the user
|
||||
was not connected to the websocket.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
session_key = client.session.session_key
|
||||
|
||||
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||
|
||||
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
|
||||
endpoint_url = (
|
||||
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||
f"?room={document.id}&sessionKey={session_key}"
|
||||
)
|
||||
ws_resp = responses.get(endpoint_url, status=500)
|
||||
|
||||
assert django_cache.get(f"docs:no-websocket:{document.id}") is None
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/content/",
|
||||
{"content": get_sample_ydoc(), "websocket": False},
|
||||
)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert get_s3_content(document) == get_sample_ydoc()
|
||||
assert django_cache.get(f"docs:no-websocket:{document.id}") == session_key
|
||||
assert ws_resp.call_count == 1
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_content_update_websocket_server_unreachable_fallback_to_no_websocket_other_users(
|
||||
settings,
|
||||
):
|
||||
"""
|
||||
When the websocket server is unreachable, the behavior fallback to the no websocket one.
|
||||
If another user is already editing, the content update should be denied.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
session_key = client.session.session_key
|
||||
|
||||
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||
|
||||
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
|
||||
endpoint_url = (
|
||||
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||
f"?room={document.id}&sessionKey={session_key}"
|
||||
)
|
||||
ws_resp = responses.get(endpoint_url, status=500)
|
||||
|
||||
django_cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/content/",
|
||||
{"content": get_sample_ydoc(), "websocket": False},
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert django_cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
|
||||
assert ws_resp.call_count == 1
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_content_update_websocket_server_room_not_found_fallback_to_no_websocket_other_users(
|
||||
settings,
|
||||
):
|
||||
"""
|
||||
When the WebSocket server does not have the room created, the logic should fallback to
|
||||
no-WebSocket. If another user is already editing, the update must be denied.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
session_key = client.session.session_key
|
||||
|
||||
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||
|
||||
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
|
||||
endpoint_url = (
|
||||
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||
f"?room={document.id}&sessionKey={session_key}"
|
||||
)
|
||||
ws_resp = responses.get(endpoint_url, status=404)
|
||||
|
||||
django_cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/content/",
|
||||
{"content": get_sample_ydoc(), "websocket": False},
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert django_cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
|
||||
assert ws_resp.call_count == 1
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_content_update_force_websocket_param_to_true(settings):
|
||||
"""
|
||||
When the websocket parameter is set to true, the content should be updated without any check.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
session_key = client.session.session_key
|
||||
|
||||
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||
|
||||
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
|
||||
endpoint_url = (
|
||||
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||
f"?room={document.id}&sessionKey={session_key}"
|
||||
)
|
||||
ws_resp = responses.get(endpoint_url, status=500)
|
||||
|
||||
assert django_cache.get(f"docs:no-websocket:{document.id}") is None
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/content/",
|
||||
{"content": get_sample_ydoc(), "websocket": True},
|
||||
)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert get_s3_content(document) == get_sample_ydoc()
|
||||
assert django_cache.get(f"docs:no-websocket:{document.id}") is None
|
||||
assert ws_resp.call_count == 0
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_content_update_feature_flag_disabled(settings):
|
||||
"""
|
||||
When the feature flag is disabled, the content should be updated without any check.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
session_key = client.session.session_key
|
||||
|
||||
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||
|
||||
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = False
|
||||
endpoint_url = (
|
||||
f"{settings.COLLABORATION_API_URL}get-connections/"
|
||||
f"?room={document.id}&sessionKey={session_key}"
|
||||
)
|
||||
ws_resp = responses.get(endpoint_url, status=500)
|
||||
|
||||
assert django_cache.get(f"docs:no-websocket:{document.id}") is None
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/content/",
|
||||
{"content": get_sample_ydoc(), "websocket": False},
|
||||
)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert get_s3_content(document) == get_sample_ydoc()
|
||||
assert django_cache.get(f"docs:no-websocket:{document.id}") is None
|
||||
assert ws_resp.call_count == 0
|
||||
|
||||
|
||||
def test_api_documents_content_upadte_invalid_yjs_doc():
|
||||
"""sending an invalid yjs doc as content should return a 400."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role="editor")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
assert get_s3_content(document) == factories.YDOC_HELLO_WORLD_BASE64
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/content/",
|
||||
{
|
||||
"content": base64.b64encode(b"invalid yjs").decode("utf-8"),
|
||||
"websocket": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
@@ -55,6 +55,31 @@ def test_api_docs_cors_proxy_valid_url(mock_getaddrinfo):
|
||||
assert response.streaming_content
|
||||
|
||||
|
||||
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
|
||||
@responses.activate
|
||||
def test_api_docs_cors_proxy_url_with_surrounding_whitespace(mock_getaddrinfo):
|
||||
"""
|
||||
URLs with leading or trailing whitespace must still be proxied successfully,
|
||||
otherwise images whose `src` has stray whitespace are missing from the PDF export.
|
||||
"""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
|
||||
# Mock DNS resolution to return a public IP address
|
||||
mock_getaddrinfo.return_value = [
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
|
||||
]
|
||||
|
||||
client = APIClient()
|
||||
url_to_fetch = "https://external-url.com/assets/logo-gouv.png"
|
||||
responses.get(url_to_fetch, body=b"", status=200, content_type="image/png")
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url= {url_to_fetch} "
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.headers["Content-Type"] == "image/png"
|
||||
assert response.streaming_content
|
||||
|
||||
|
||||
def test_api_docs_cors_proxy_without_url_query_string():
|
||||
"""Test the CORS proxy API for documents without a URL query string."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
|
||||
@@ -1,807 +0,0 @@
|
||||
"""
|
||||
Tests for Documents API endpoint in impress's core app: descendants
|
||||
"""
|
||||
|
||||
import random
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_documents_descendants_list_anonymous_public_standalone():
|
||||
"""Anonymous users should be allowed to retrieve the descendants of a public document."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/descendants/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 3,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"abilities": child1.get_abilities(AnonymousUser()),
|
||||
"ancestors_link_reach": "public",
|
||||
"ancestors_link_role": document.link_role,
|
||||
"computed_link_reach": child1.computed_link_reach,
|
||||
"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),
|
||||
"is_favorite": False,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": None,
|
||||
},
|
||||
{
|
||||
"abilities": grand_child.get_abilities(AnonymousUser()),
|
||||
"ancestors_link_reach": "public",
|
||||
"ancestors_link_role": "editor"
|
||||
if (child1.link_reach == "public" and child1.link_role == "editor")
|
||||
else document.link_role,
|
||||
"computed_link_reach": "public",
|
||||
"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),
|
||||
"is_favorite": False,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": grand_child.path,
|
||||
"title": grand_child.title,
|
||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": None,
|
||||
},
|
||||
{
|
||||
"abilities": child2.get_abilities(AnonymousUser()),
|
||||
"ancestors_link_reach": "public",
|
||||
"ancestors_link_role": document.link_role,
|
||||
"computed_link_reach": "public",
|
||||
"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),
|
||||
"is_favorite": False,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": None,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_descendants_list_anonymous_public_parent():
|
||||
"""
|
||||
Anonymous users should be allowed to retrieve the descendants of a document who
|
||||
has a public ancestor.
|
||||
"""
|
||||
grand_parent = factories.DocumentFactory(link_reach="public")
|
||||
parent = factories.DocumentFactory(
|
||||
parent=grand_parent, link_reach=random.choice(["authenticated", "restricted"])
|
||||
)
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=random.choice(["authenticated", "restricted"]), parent=parent
|
||||
)
|
||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/descendants/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 3,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"abilities": child1.get_abilities(AnonymousUser()),
|
||||
"ancestors_link_reach": "public",
|
||||
"ancestors_link_role": grand_parent.link_role,
|
||||
"computed_link_reach": child1.computed_link_reach,
|
||||
"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),
|
||||
"is_favorite": False,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": None,
|
||||
},
|
||||
{
|
||||
"abilities": grand_child.get_abilities(AnonymousUser()),
|
||||
"ancestors_link_reach": "public",
|
||||
"ancestors_link_role": grand_child.ancestors_link_role,
|
||||
"computed_link_reach": "public",
|
||||
"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),
|
||||
"is_favorite": False,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": grand_child.path,
|
||||
"title": grand_child.title,
|
||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": None,
|
||||
},
|
||||
{
|
||||
"abilities": child2.get_abilities(AnonymousUser()),
|
||||
"ancestors_link_reach": "public",
|
||||
"ancestors_link_role": grand_parent.link_role,
|
||||
"computed_link_reach": "public",
|
||||
"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),
|
||||
"is_favorite": False,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": None,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", ["restricted", "authenticated"])
|
||||
def test_api_documents_descendants_list_anonymous_restricted_or_authenticated(reach):
|
||||
"""
|
||||
Anonymous users should not be able to retrieve descendants of a document that is not public.
|
||||
"""
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
child = factories.DocumentFactory(parent=document)
|
||||
_grand_child = factories.DocumentFactory(parent=child)
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/descendants/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", ["public", "authenticated"])
|
||||
def test_api_documents_descendants_list_authenticated_unrelated_public_or_authenticated(
|
||||
reach,
|
||||
):
|
||||
"""
|
||||
Authenticated users should be able to retrieve the descendants of a public/authenticated
|
||||
document to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach=reach)
|
||||
child1, child2 = factories.DocumentFactory.create_batch(
|
||||
2, parent=document, link_reach="restricted"
|
||||
)
|
||||
grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/descendants/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 3,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"abilities": child1.get_abilities(user),
|
||||
"ancestors_link_reach": reach,
|
||||
"ancestors_link_role": document.link_role,
|
||||
"computed_link_reach": child1.computed_link_reach,
|
||||
"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),
|
||||
"is_favorite": False,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": None,
|
||||
},
|
||||
{
|
||||
"abilities": grand_child.get_abilities(user),
|
||||
"ancestors_link_reach": reach,
|
||||
"ancestors_link_role": document.link_role,
|
||||
"computed_link_reach": grand_child.computed_link_reach,
|
||||
"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),
|
||||
"is_favorite": False,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": grand_child.path,
|
||||
"title": grand_child.title,
|
||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": None,
|
||||
},
|
||||
{
|
||||
"abilities": child2.get_abilities(user),
|
||||
"ancestors_link_reach": reach,
|
||||
"ancestors_link_role": document.link_role,
|
||||
"computed_link_reach": child2.computed_link_reach,
|
||||
"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),
|
||||
"is_favorite": False,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": None,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", ["public", "authenticated"])
|
||||
def test_api_documents_descendants_list_authenticated_public_or_authenticated_parent(
|
||||
reach,
|
||||
):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve the descendants of a document who
|
||||
has a public or authenticated ancestor.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
grand_parent = factories.DocumentFactory(link_reach=reach)
|
||||
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
|
||||
document = factories.DocumentFactory(link_reach="restricted", parent=parent)
|
||||
child1, child2 = factories.DocumentFactory.create_batch(
|
||||
2, parent=document, link_reach="restricted"
|
||||
)
|
||||
grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/descendants/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 3,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"abilities": child1.get_abilities(user),
|
||||
"ancestors_link_reach": reach,
|
||||
"ancestors_link_role": grand_parent.link_role,
|
||||
"computed_link_reach": child1.computed_link_reach,
|
||||
"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),
|
||||
"is_favorite": False,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": None,
|
||||
},
|
||||
{
|
||||
"abilities": grand_child.get_abilities(user),
|
||||
"ancestors_link_reach": reach,
|
||||
"ancestors_link_role": grand_parent.link_role,
|
||||
"computed_link_reach": grand_child.computed_link_reach,
|
||||
"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),
|
||||
"is_favorite": False,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": grand_child.path,
|
||||
"title": grand_child.title,
|
||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": None,
|
||||
},
|
||||
{
|
||||
"abilities": child2.get_abilities(user),
|
||||
"ancestors_link_reach": reach,
|
||||
"ancestors_link_role": grand_parent.link_role,
|
||||
"computed_link_reach": child2.computed_link_reach,
|
||||
"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),
|
||||
"is_favorite": False,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": None,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_descendants_list_authenticated_unrelated_restricted():
|
||||
"""
|
||||
Authenticated users should not be allowed to retrieve the descendants of a document that is
|
||||
restricted and to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
_grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/descendants/",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_descendants_list_authenticated_related_direct():
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve the descendants of a document
|
||||
to which they are directly related whatever the role.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
access = factories.UserDocumentAccessFactory(document=document, user=user)
|
||||
factories.UserDocumentAccessFactory(document=document)
|
||||
|
||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/descendants/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 3,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"abilities": child1.get_abilities(user),
|
||||
"ancestors_link_reach": child1.ancestors_link_reach,
|
||||
"ancestors_link_role": child1.ancestors_link_role,
|
||||
"computed_link_reach": child1.computed_link_reach,
|
||||
"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),
|
||||
"is_favorite": False,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
"nb_accesses_ancestors": 3,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": access.role,
|
||||
},
|
||||
{
|
||||
"abilities": grand_child.get_abilities(user),
|
||||
"ancestors_link_reach": grand_child.ancestors_link_reach,
|
||||
"ancestors_link_role": grand_child.ancestors_link_role,
|
||||
"computed_link_reach": grand_child.computed_link_reach,
|
||||
"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),
|
||||
"is_favorite": False,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 3,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": grand_child.path,
|
||||
"title": grand_child.title,
|
||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": access.role,
|
||||
},
|
||||
{
|
||||
"abilities": child2.get_abilities(user),
|
||||
"ancestors_link_reach": child2.ancestors_link_reach,
|
||||
"ancestors_link_role": child2.ancestors_link_role,
|
||||
"computed_link_reach": child2.computed_link_reach,
|
||||
"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),
|
||||
"is_favorite": False,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 2,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": access.role,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_descendants_list_authenticated_related_parent():
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve the descendants of a document if they
|
||||
are related to one of its ancestors whatever the role.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
grand_parent = factories.DocumentFactory(link_reach="restricted")
|
||||
grand_parent_access = factories.UserDocumentAccessFactory(
|
||||
document=grand_parent, user=user
|
||||
)
|
||||
|
||||
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
|
||||
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
|
||||
|
||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/descendants/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 3,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"abilities": child1.get_abilities(user),
|
||||
"ancestors_link_reach": child1.ancestors_link_reach,
|
||||
"ancestors_link_role": child1.ancestors_link_role,
|
||||
"computed_link_reach": child1.computed_link_reach,
|
||||
"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),
|
||||
"is_favorite": False,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
"nb_accesses_ancestors": 2,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": grand_parent_access.role,
|
||||
},
|
||||
{
|
||||
"abilities": grand_child.get_abilities(user),
|
||||
"ancestors_link_reach": grand_child.ancestors_link_reach,
|
||||
"ancestors_link_role": grand_child.ancestors_link_role,
|
||||
"computed_link_reach": grand_child.computed_link_reach,
|
||||
"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),
|
||||
"is_favorite": False,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 2,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": grand_child.path,
|
||||
"title": grand_child.title,
|
||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": grand_parent_access.role,
|
||||
},
|
||||
{
|
||||
"abilities": child2.get_abilities(user),
|
||||
"ancestors_link_reach": child2.ancestors_link_reach,
|
||||
"ancestors_link_role": child2.ancestors_link_role,
|
||||
"computed_link_reach": child2.computed_link_reach,
|
||||
"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),
|
||||
"is_favorite": False,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": grand_parent_access.role,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_descendants_list_authenticated_related_child():
|
||||
"""
|
||||
Authenticated users should not be allowed to retrieve all the descendants of a document
|
||||
as a result of being related to one of its children.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
_grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1, user=user)
|
||||
factories.UserDocumentAccessFactory(document=document)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/descendants/",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_descendants_list_authenticated_related_team_none(
|
||||
mock_user_teams,
|
||||
):
|
||||
"""
|
||||
Authenticated users should not be able to retrieve the descendants of a restricted document
|
||||
related to teams in which the user is not.
|
||||
"""
|
||||
mock_user_teams.return_value = []
|
||||
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
factories.DocumentFactory.create_batch(2, parent=document)
|
||||
|
||||
factories.TeamDocumentAccessFactory(document=document, team="myteam")
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/descendants/")
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_descendants_list_authenticated_related_team_members(
|
||||
mock_user_teams,
|
||||
):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve the descendants of a document to which they
|
||||
are related via a team whatever the role.
|
||||
"""
|
||||
mock_user_teams.return_value = ["myteam"]
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
access = factories.TeamDocumentAccessFactory(document=document, team="myteam")
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/descendants/")
|
||||
|
||||
# pylint: disable=R0801
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 3,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"abilities": child1.get_abilities(user),
|
||||
"ancestors_link_reach": child1.ancestors_link_reach,
|
||||
"ancestors_link_role": child1.ancestors_link_role,
|
||||
"computed_link_reach": child1.computed_link_reach,
|
||||
"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),
|
||||
"is_favorite": False,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": access.role,
|
||||
},
|
||||
{
|
||||
"abilities": grand_child.get_abilities(user),
|
||||
"ancestors_link_reach": grand_child.ancestors_link_reach,
|
||||
"ancestors_link_role": grand_child.ancestors_link_role,
|
||||
"computed_link_reach": grand_child.computed_link_reach,
|
||||
"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),
|
||||
"is_favorite": False,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": grand_child.path,
|
||||
"title": grand_child.title,
|
||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": access.role,
|
||||
},
|
||||
{
|
||||
"abilities": child2.get_abilities(user),
|
||||
"ancestors_link_reach": child2.ancestors_link_reach,
|
||||
"ancestors_link_role": child2.ancestors_link_role,
|
||||
"computed_link_reach": child2.computed_link_reach,
|
||||
"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),
|
||||
"is_favorite": False,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": access.role,
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
"""
|
||||
Tests for Documents API endpoint in impress's core app: list
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from faker import Faker
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.api.filters import remove_accents
|
||||
|
||||
fake = Faker()
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
# Filters: unknown field
|
||||
|
||||
|
||||
def test_api_documents_descendants_filter_unknown_field():
|
||||
"""
|
||||
Trying to filter by an unknown field should be ignored.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory()
|
||||
|
||||
document = factories.DocumentFactory(users=[user])
|
||||
expected_ids = {
|
||||
str(document.id)
|
||||
for document in factories.DocumentFactory.create_batch(2, parent=document)
|
||||
}
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/descendants/?unknown=true"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 2
|
||||
assert {result["id"] for result in results} == expected_ids
|
||||
|
||||
|
||||
# Filters: title
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"query,nb_results",
|
||||
[
|
||||
("Project Alpha", 1), # Exact match
|
||||
("project", 2), # Partial match (case-insensitive)
|
||||
("Guide", 2), # Word match within a title
|
||||
("Special", 0), # No match (nonexistent keyword)
|
||||
("2024", 2), # Match by numeric keyword
|
||||
("", 6), # Empty string
|
||||
("velo", 1), # Accent-insensitive match (velo vs vélo)
|
||||
("bêta", 1), # Accent-insensitive match (bêta vs beta)
|
||||
],
|
||||
)
|
||||
def test_api_documents_descendants_filter_title(query, nb_results):
|
||||
"""Authenticated users should be able to search documents by their unaccented title."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(users=[user])
|
||||
|
||||
# Create documents with predefined titles
|
||||
titles = [
|
||||
"Project Alpha Documentation",
|
||||
"Project Beta Overview",
|
||||
"User Guide",
|
||||
"Financial Report 2024",
|
||||
"Annual Review 2024",
|
||||
"Guide du vélo urbain", # <-- Title with accent for accent-insensitive test
|
||||
]
|
||||
for title in titles:
|
||||
factories.DocumentFactory(title=title, parent=document)
|
||||
|
||||
# Perform the search query
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/descendants/?title={query:s}"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == nb_results
|
||||
|
||||
# Ensure all results contain the query in their title
|
||||
for result in results:
|
||||
assert (
|
||||
remove_accents(query).lower().strip()
|
||||
in remove_accents(result["title"]).lower()
|
||||
)
|
||||
@@ -70,7 +70,6 @@ def test_api_document_favorite_list_authenticated_with_favorite():
|
||||
"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,
|
||||
"id": str(document.id),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Tests for Documents API endpoint in impress's core app: content
|
||||
Tests for Documents API endpoint in impress's core app: convert
|
||||
"""
|
||||
|
||||
import base64
|
||||
@@ -23,12 +23,14 @@ pytestmark = pytest.mark.django_db
|
||||
],
|
||||
)
|
||||
@patch("core.services.converter_services.YdocConverter.convert")
|
||||
def test_api_documents_content_public(mock_content, reach, role):
|
||||
def test_api_documents_formatted_content_public(mock_content, reach, role):
|
||||
"""Anonymous users should be allowed to access content of public documents."""
|
||||
document = factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
mock_content.return_value = {"some": "data"}
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/content/")
|
||||
response = APIClient().get(
|
||||
f"/api/v1.0/documents/{document.id!s}/formatted-content/"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
@@ -58,7 +60,9 @@ def test_api_documents_content_public(mock_content, reach, role):
|
||||
],
|
||||
)
|
||||
@patch("core.services.converter_services.YdocConverter.convert")
|
||||
def test_api_documents_content_not_public(mock_content, reach, doc_role, user_role):
|
||||
def test_api_documents_formatted_content_not_public(
|
||||
mock_content, reach, doc_role, user_role
|
||||
):
|
||||
"""Authenticated users need access to get non-public document content."""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(link_reach=reach, link_role=doc_role)
|
||||
@@ -66,14 +70,14 @@ def test_api_documents_content_not_public(mock_content, reach, doc_role, user_ro
|
||||
|
||||
# First anonymous request should fail
|
||||
client = APIClient()
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/content/")
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/formatted-content/")
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
mock_content.assert_not_called()
|
||||
|
||||
# Login and try again
|
||||
client.force_login(user)
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/content/")
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/formatted-content/")
|
||||
|
||||
# If restricted, we still should not have access
|
||||
if user_role is not None:
|
||||
@@ -85,7 +89,7 @@ def test_api_documents_content_not_public(mock_content, reach, doc_role, user_ro
|
||||
document=document, user=user, role=user_role
|
||||
)
|
||||
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/content/")
|
||||
response = client.get(f"/api/v1.0/documents/{document.id!s}/formatted-content/")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
@@ -108,13 +112,13 @@ def test_api_documents_content_not_public(mock_content, reach, doc_role, user_ro
|
||||
],
|
||||
)
|
||||
@patch("core.services.converter_services.YdocConverter.convert")
|
||||
def test_api_documents_content_format(mock_content, content_format, accept):
|
||||
"""Test that the content endpoint returns a specific format."""
|
||||
def test_api_documents_formatted_content_format(mock_content, content_format, accept):
|
||||
"""Test that the convert endpoint returns a specific format."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
mock_content.return_value = {"some": "data"}
|
||||
|
||||
response = APIClient().get(
|
||||
f"/api/v1.0/documents/{document.id!s}/content/?content_format={content_format}"
|
||||
f"/api/v1.0/documents/{document.id!s}/formatted-content/?content_format={content_format}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -128,45 +132,49 @@ def test_api_documents_content_format(mock_content, content_format, accept):
|
||||
|
||||
|
||||
@patch("core.services.converter_services.YdocConverter._request")
|
||||
def test_api_documents_content_invalid_format(mock_request):
|
||||
"""Test that the content endpoint rejects invalid formats."""
|
||||
def test_api_documents_formatted_content_invalid_format(mock_request):
|
||||
"""Test that the convert endpoint rejects invalid formats."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
|
||||
response = APIClient().get(
|
||||
f"/api/v1.0/documents/{document.id!s}/content/?content_format=invalid"
|
||||
f"/api/v1.0/documents/{document.id!s}/formatted-content/?content_format=invalid"
|
||||
)
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
mock_request.assert_not_called()
|
||||
|
||||
|
||||
@patch("core.services.converter_services.YdocConverter._request")
|
||||
def test_api_documents_content_yservice_error(mock_request):
|
||||
def test_api_documents_formatted_content_yservice_error(mock_request):
|
||||
"""Test that service errors are handled properly."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
mock_request.side_effect = requests.RequestException()
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/content/")
|
||||
response = APIClient().get(
|
||||
f"/api/v1.0/documents/{document.id!s}/formatted-content/"
|
||||
)
|
||||
mock_request.assert_called_once()
|
||||
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
|
||||
|
||||
@patch("core.services.converter_services.YdocConverter._request")
|
||||
def test_api_documents_content_nonexistent_document(mock_request):
|
||||
def test_api_documents_formatted_content_nonexistent_document(mock_request):
|
||||
"""Test that accessing a nonexistent document returns 404."""
|
||||
client = APIClient()
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/00000000-0000-0000-0000-000000000000/content/"
|
||||
"/api/v1.0/documents/00000000-0000-0000-0000-000000000000/formatted-content/"
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
mock_request.assert_not_called()
|
||||
|
||||
|
||||
@patch("core.services.converter_services.YdocConverter._request")
|
||||
def test_api_documents_content_empty_document(mock_request):
|
||||
def test_api_documents_formatted_content_empty_document(mock_request):
|
||||
"""Test that accessing an empty document returns empty content."""
|
||||
document = factories.DocumentFactory(link_reach="public", content="")
|
||||
|
||||
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/content/")
|
||||
response = APIClient().get(
|
||||
f"/api/v1.0/documents/{document.id!s}/formatted-content/"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
@@ -6,7 +6,6 @@ from io import BytesIO
|
||||
from urllib.parse import urlparse
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.storage import default_storage
|
||||
from django.utils import timezone
|
||||
|
||||
@@ -37,7 +36,7 @@ def test_api_documents_media_auth_unkown_document():
|
||||
assert models.Document.objects.exists() is False
|
||||
|
||||
|
||||
def test_api_documents_media_auth_anonymous_public():
|
||||
def test_api_documents_media_auth_anonymous_public(settings):
|
||||
"""Anonymous users should be able to retrieve attachments linked to a public document"""
|
||||
document_id = uuid4()
|
||||
filename = f"{uuid4()!s}.jpg"
|
||||
@@ -139,7 +138,7 @@ def test_api_documents_media_auth_anonymous_authenticated_or_restricted(reach):
|
||||
assert "Authorization" not in response
|
||||
|
||||
|
||||
def test_api_documents_media_auth_anonymous_attachments():
|
||||
def test_api_documents_media_auth_anonymous_attachments(settings):
|
||||
"""
|
||||
Declaring a media key as original attachment on a document to which
|
||||
a user has access should give them access to the attachment file
|
||||
@@ -202,7 +201,9 @@ def test_api_documents_media_auth_anonymous_attachments():
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", ["public", "authenticated"])
|
||||
def test_api_documents_media_auth_authenticated_public_or_authenticated(reach):
|
||||
def test_api_documents_media_auth_authenticated_public_or_authenticated(
|
||||
reach, settings
|
||||
):
|
||||
"""
|
||||
Authenticated users who are not related to a document should be able to retrieve
|
||||
attachments related to a document with public or authenticated link reach.
|
||||
@@ -284,7 +285,7 @@ def test_api_documents_media_auth_authenticated_restricted():
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_documents_media_auth_related(via, mock_user_teams):
|
||||
def test_api_documents_media_auth_related(via, mock_user_teams, settings):
|
||||
"""
|
||||
Users who have a specific access to a document, whatever the role, should be able to
|
||||
retrieve related attachments.
|
||||
@@ -368,7 +369,7 @@ def test_api_documents_media_auth_not_ready_status():
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_api_documents_media_auth_missing_status_metadata():
|
||||
def test_api_documents_media_auth_missing_status_metadata(settings):
|
||||
"""Attachments without status metadata should be considered as ready"""
|
||||
document_id = uuid4()
|
||||
filename = f"{uuid4()!s}.jpg"
|
||||
@@ -412,3 +413,51 @@ def test_api_documents_media_auth_missing_status_metadata():
|
||||
timeout=1,
|
||||
)
|
||||
assert response.content.decode("utf-8") == "my prose"
|
||||
|
||||
|
||||
def test_api_documents_media_auth_anonymous_public_custom_origin_header(settings):
|
||||
"""Changing the setting MEDIA_AUTH_ORIGINAL_URL_HEADER to match other header should work"""
|
||||
settings.MEDIA_AUTH_ORIGINAL_URL_HEADER = "HTTP_X_FORWARDED_URI"
|
||||
document_id = uuid4()
|
||||
filename = f"{uuid4()!s}.jpg"
|
||||
key = f"{document_id!s}/attachments/{filename:s}"
|
||||
default_storage.connection.meta.client.put_object(
|
||||
Bucket=default_storage.bucket_name,
|
||||
Key=key,
|
||||
Body=BytesIO(b"my prose"),
|
||||
ContentType="text/plain",
|
||||
Metadata={"status": DocumentAttachmentStatus.READY},
|
||||
)
|
||||
|
||||
factories.DocumentFactory(id=document_id, link_reach="public", attachments=[key])
|
||||
|
||||
original_url = f"http://localhost/media/{key:s}"
|
||||
now = timezone.now()
|
||||
with freeze_time(now):
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/documents/media-auth/", HTTP_X_FORWARDED_URI=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
authorization = response["Authorization"]
|
||||
assert "AWS4-HMAC-SHA256 Credential=" in authorization
|
||||
assert (
|
||||
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
|
||||
in authorization
|
||||
)
|
||||
assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ")
|
||||
|
||||
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
|
||||
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
|
||||
response = requests.get(
|
||||
file_url,
|
||||
headers={
|
||||
"authorization": authorization,
|
||||
"x-amz-date": response["x-amz-date"],
|
||||
"x-amz-content-sha256": response["x-amz-content-sha256"],
|
||||
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
|
||||
},
|
||||
timeout=1,
|
||||
)
|
||||
assert response.content.decode("utf-8") == "my prose"
|
||||
|
||||
@@ -438,3 +438,92 @@ def test_api_documents_move_authenticated_deleted_target_as_sibling(position):
|
||||
# Verify that the document has not moved
|
||||
document.refresh_from_db()
|
||||
assert document.is_root() is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("position", enums.MoveNodePositionChoices.values)
|
||||
def test_api_documents_move_to_descendant(position):
|
||||
"""
|
||||
Moving a document to one of its descendants should return a validation error.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Create a hierarchy: parent -> child -> grandchild
|
||||
parent = factories.DocumentFactory(users=[(user, "owner")])
|
||||
child = factories.DocumentFactory(parent=parent, users=[(user, "owner")])
|
||||
grandchild = factories.DocumentFactory(parent=child, users=[(user, "owner")])
|
||||
|
||||
# Try moving parent to child (descendant)
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{parent.id!s}/move/",
|
||||
data={"target_document_id": str(child.id), "position": position},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"target_document_id": "Cannot move a document to its own descendant."
|
||||
}
|
||||
|
||||
# Try moving parent to grandchild
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{parent.id!s}/move/",
|
||||
data={"target_document_id": str(grandchild.id), "position": position},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"target_document_id": "Cannot move a document to its own descendant."
|
||||
}
|
||||
|
||||
# Try moving child to grandchild (still descendant)
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{child.id!s}/move/",
|
||||
data={"target_document_id": str(grandchild.id), "position": position},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"target_document_id": "Cannot move a document to its own descendant."
|
||||
}
|
||||
|
||||
# Ensure documents have not moved
|
||||
parent.refresh_from_db()
|
||||
child.refresh_from_db()
|
||||
grandchild.refresh_from_db()
|
||||
assert parent.is_root() is True
|
||||
assert child.is_child_of(parent) is True
|
||||
assert grandchild.is_child_of(child) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"position",
|
||||
[
|
||||
enums.MoveNodePositionChoices.FIRST_CHILD,
|
||||
enums.MoveNodePositionChoices.LAST_CHILD,
|
||||
],
|
||||
)
|
||||
def test_api_documents_move_to_self(position):
|
||||
"""
|
||||
Moving a document to itself should return a validation error.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(users=[(user, "owner")])
|
||||
|
||||
# Try moving document to itself
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/move/",
|
||||
data={"target_document_id": str(document.id), "position": position},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"target_document_id": "Cannot move a document to its own descendant."
|
||||
}
|
||||
|
||||
# Ensure document has not moved
|
||||
document.refresh_from_db()
|
||||
assert document.is_root() is True
|
||||
|
||||
@@ -124,3 +124,22 @@ def test_api_documents_restore_authenticated_owner_expired():
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "Not found."}
|
||||
|
||||
|
||||
def test_api_documents_restore_authenticated_owner_not_deleted():
|
||||
"""Restoring a document that is not deleted should return a 400 error."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
|
||||
|
||||
response = client.post(f"/api/v1.0/documents/{document.id!s}/restore/")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"detail": "This document is not deleted."}
|
||||
|
||||
document.refresh_from_db()
|
||||
assert document.deleted_at is None
|
||||
assert document.ancestors_deleted_at is None
|
||||
|
||||
@@ -39,7 +39,7 @@ def test_api_documents_retrieve_anonymous_public_standalone():
|
||||
"collaboration_auth": True,
|
||||
"comment": document.link_role in ["commenter", "editor"],
|
||||
"cors_proxy": True,
|
||||
"content": True,
|
||||
"formatted_content": True,
|
||||
"descendants": True,
|
||||
"destroy": False,
|
||||
"duplicate": False,
|
||||
@@ -53,6 +53,8 @@ def test_api_documents_retrieve_anonymous_public_standalone():
|
||||
"restricted": None,
|
||||
},
|
||||
"mask": False,
|
||||
"content_patch": document.link_role == "editor",
|
||||
"content_retrieve": True,
|
||||
"media_auth": True,
|
||||
"media_check": True,
|
||||
"move": False,
|
||||
@@ -70,7 +72,6 @@ def test_api_documents_retrieve_anonymous_public_standalone():
|
||||
"ancestors_link_role": None,
|
||||
"computed_link_reach": document.computed_link_reach,
|
||||
"computed_link_role": document.computed_link_role,
|
||||
"content": document.content,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"deleted_at": None,
|
||||
@@ -120,7 +121,7 @@ def test_api_documents_retrieve_anonymous_public_parent():
|
||||
"comment": grand_parent.link_role in ["commenter", "editor"],
|
||||
"descendants": True,
|
||||
"cors_proxy": True,
|
||||
"content": True,
|
||||
"formatted_content": True,
|
||||
"destroy": False,
|
||||
"duplicate": False,
|
||||
# Anonymous user can't favorite a document even with read access
|
||||
@@ -131,6 +132,8 @@ def test_api_documents_retrieve_anonymous_public_parent():
|
||||
**links_definition
|
||||
),
|
||||
"mask": False,
|
||||
"content_patch": grand_parent.link_role == "editor",
|
||||
"content_retrieve": True,
|
||||
"media_auth": True,
|
||||
"media_check": True,
|
||||
"move": False,
|
||||
@@ -148,7 +151,6 @@ def test_api_documents_retrieve_anonymous_public_parent():
|
||||
"ancestors_link_role": grand_parent.link_role,
|
||||
"computed_link_reach": "public",
|
||||
"computed_link_role": grand_parent.link_role,
|
||||
"content": document.content,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"deleted_at": None,
|
||||
@@ -230,7 +232,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
|
||||
"comment": document.link_role in ["commenter", "editor"],
|
||||
"descendants": True,
|
||||
"cors_proxy": True,
|
||||
"content": True,
|
||||
"formatted_content": True,
|
||||
"destroy": False,
|
||||
"duplicate": True,
|
||||
"favorite": True,
|
||||
@@ -242,6 +244,8 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
|
||||
"restricted": None,
|
||||
},
|
||||
"mask": True,
|
||||
"content_patch": document.link_role == "editor",
|
||||
"content_retrieve": True,
|
||||
"media_auth": True,
|
||||
"media_check": True,
|
||||
"move": False,
|
||||
@@ -259,7 +263,6 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
|
||||
"ancestors_link_role": None,
|
||||
"computed_link_reach": document.computed_link_reach,
|
||||
"computed_link_role": document.computed_link_role,
|
||||
"content": document.content,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"depth": 1,
|
||||
@@ -317,7 +320,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
|
||||
"comment": grand_parent.link_role in ["commenter", "editor"],
|
||||
"descendants": True,
|
||||
"cors_proxy": True,
|
||||
"content": True,
|
||||
"formatted_content": True,
|
||||
"destroy": False,
|
||||
"duplicate": True,
|
||||
"favorite": True,
|
||||
@@ -328,6 +331,8 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
|
||||
),
|
||||
"mask": True,
|
||||
"move": False,
|
||||
"content_patch": grand_parent.link_role == "editor",
|
||||
"content_retrieve": True,
|
||||
"media_auth": True,
|
||||
"media_check": True,
|
||||
"partial_update": grand_parent.link_role == "editor",
|
||||
@@ -344,7 +349,6 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
|
||||
"ancestors_link_role": grand_parent.link_role,
|
||||
"computed_link_reach": document.computed_link_reach,
|
||||
"computed_link_role": document.computed_link_role,
|
||||
"content": document.content,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"depth": 3,
|
||||
@@ -459,7 +463,6 @@ def test_api_documents_retrieve_authenticated_related_direct():
|
||||
"ancestors_link_role": None,
|
||||
"computed_link_reach": document.computed_link_reach,
|
||||
"computed_link_role": document.computed_link_role,
|
||||
"content": document.content,
|
||||
"creator": str(document.creator.id),
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"deleted_at": None,
|
||||
@@ -517,7 +520,7 @@ def test_api_documents_retrieve_authenticated_related_parent():
|
||||
"comment": access.role != "reader",
|
||||
"descendants": True,
|
||||
"cors_proxy": True,
|
||||
"content": True,
|
||||
"formatted_content": True,
|
||||
"destroy": access.role in ["administrator", "owner"],
|
||||
"duplicate": True,
|
||||
"favorite": True,
|
||||
@@ -527,6 +530,8 @@ def test_api_documents_retrieve_authenticated_related_parent():
|
||||
**link_definition
|
||||
),
|
||||
"mask": True,
|
||||
"content_patch": access.role not in ["reader", "commenter"],
|
||||
"content_retrieve": True,
|
||||
"media_auth": True,
|
||||
"media_check": True,
|
||||
"move": access.role in ["administrator", "owner"],
|
||||
@@ -544,7 +549,6 @@ def test_api_documents_retrieve_authenticated_related_parent():
|
||||
"ancestors_link_role": None,
|
||||
"computed_link_reach": "restricted",
|
||||
"computed_link_role": None,
|
||||
"content": document.content,
|
||||
"creator": str(document.creator.id),
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"depth": 3,
|
||||
@@ -701,7 +705,6 @@ def test_api_documents_retrieve_authenticated_related_team_members(
|
||||
"ancestors_link_role": None,
|
||||
"computed_link_reach": document.computed_link_reach,
|
||||
"computed_link_role": document.computed_link_role,
|
||||
"content": document.content,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"deleted_at": None,
|
||||
@@ -768,7 +771,6 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
|
||||
"ancestors_link_role": None,
|
||||
"computed_link_reach": document.computed_link_reach,
|
||||
"computed_link_role": document.computed_link_role,
|
||||
"content": document.content,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"deleted_at": None,
|
||||
@@ -835,7 +837,6 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
|
||||
"ancestors_link_role": None,
|
||||
"computed_link_reach": document.computed_link_reach,
|
||||
"computed_link_role": document.computed_link_role,
|
||||
"content": document.content,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"deleted_at": None,
|
||||
@@ -1067,48 +1068,3 @@ def test_api_documents_retrieve_permanently_deleted_related(role, depth):
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "Not found."}
|
||||
|
||||
|
||||
def test_api_documents_retrieve_without_content():
|
||||
"""
|
||||
Test retrieve using without_content query string should remove the content in the response
|
||||
"""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
document = factories.DocumentFactory(creator=user, users=[(user, "owner")])
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
with mock.patch("core.models.Document.content") as mock_document_content:
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/?without_content=true"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
payload = response.json()
|
||||
assert "content" not in payload
|
||||
mock_document_content.assert_not_called()
|
||||
|
||||
|
||||
def test_api_documents_retrieve_without_content_invalid_value():
|
||||
"""
|
||||
Test retrieve using without_content query string but an invalid value
|
||||
should return a 400
|
||||
"""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
document = factories.DocumentFactory(creator=user, users=[(user, "owner")])
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/?without_content=invalid-value"
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
assert response.json() == ["Must be a valid boolean."]
|
||||
|
||||
@@ -68,8 +68,8 @@ def test_api_documents_search_descendants_list_anonymous_public_standalone():
|
||||
},
|
||||
{
|
||||
"abilities": child1.get_abilities(AnonymousUser()),
|
||||
"ancestors_link_reach": document.link_reach,
|
||||
"ancestors_link_role": document.link_role,
|
||||
"ancestors_link_reach": child1.ancestors_link_reach,
|
||||
"ancestors_link_role": child1.ancestors_link_role,
|
||||
"computed_link_reach": child1.computed_link_reach,
|
||||
"computed_link_role": child1.computed_link_role,
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -91,10 +91,8 @@ def test_api_documents_search_descendants_list_anonymous_public_standalone():
|
||||
},
|
||||
{
|
||||
"abilities": grand_child.get_abilities(AnonymousUser()),
|
||||
"ancestors_link_reach": document.link_reach,
|
||||
"ancestors_link_role": document.link_role
|
||||
if (child1.link_reach == "public" and child1.link_role == "editor")
|
||||
else document.link_role,
|
||||
"ancestors_link_reach": grand_child.ancestors_link_reach,
|
||||
"ancestors_link_role": grand_child.ancestors_link_role,
|
||||
"computed_link_reach": "public",
|
||||
"computed_link_role": grand_child.computed_link_role,
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -116,8 +114,8 @@ def test_api_documents_search_descendants_list_anonymous_public_standalone():
|
||||
},
|
||||
{
|
||||
"abilities": child2.get_abilities(AnonymousUser()),
|
||||
"ancestors_link_reach": document.link_reach,
|
||||
"ancestors_link_role": document.link_role,
|
||||
"ancestors_link_reach": child2.ancestors_link_reach,
|
||||
"ancestors_link_role": child2.ancestors_link_role,
|
||||
"computed_link_reach": "public",
|
||||
"computed_link_role": child2.computed_link_role,
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -180,7 +178,7 @@ def test_api_documents_search_descendants_list_anonymous_public_parent():
|
||||
# the search should include the parent document itself
|
||||
"abilities": document.get_abilities(AnonymousUser()),
|
||||
"ancestors_link_reach": "public",
|
||||
"ancestors_link_role": grand_parent.link_role,
|
||||
"ancestors_link_role": document.ancestors_link_role,
|
||||
"computed_link_reach": document.computed_link_reach,
|
||||
"computed_link_role": document.computed_link_role,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -203,7 +201,7 @@ def test_api_documents_search_descendants_list_anonymous_public_parent():
|
||||
{
|
||||
"abilities": child1.get_abilities(AnonymousUser()),
|
||||
"ancestors_link_reach": "public",
|
||||
"ancestors_link_role": grand_parent.link_role,
|
||||
"ancestors_link_role": child1.ancestors_link_role,
|
||||
"computed_link_reach": child1.computed_link_reach,
|
||||
"computed_link_role": child1.computed_link_role,
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -249,7 +247,7 @@ def test_api_documents_search_descendants_list_anonymous_public_parent():
|
||||
{
|
||||
"abilities": child2.get_abilities(AnonymousUser()),
|
||||
"ancestors_link_reach": "public",
|
||||
"ancestors_link_role": grand_parent.link_role,
|
||||
"ancestors_link_role": child2.ancestors_link_role,
|
||||
"computed_link_reach": "public",
|
||||
"computed_link_role": child2.computed_link_role,
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -327,7 +325,7 @@ def test_api_documents_search_descendants_list_authenticated_unrelated_public_or
|
||||
{
|
||||
"abilities": child1.get_abilities(user),
|
||||
"ancestors_link_reach": reach,
|
||||
"ancestors_link_role": document.link_role,
|
||||
"ancestors_link_role": child1.ancestors_link_role,
|
||||
"computed_link_reach": child1.computed_link_reach,
|
||||
"computed_link_role": child1.computed_link_role,
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -350,7 +348,7 @@ def test_api_documents_search_descendants_list_authenticated_unrelated_public_or
|
||||
{
|
||||
"abilities": grand_child.get_abilities(user),
|
||||
"ancestors_link_reach": reach,
|
||||
"ancestors_link_role": document.link_role,
|
||||
"ancestors_link_role": grand_child.ancestors_link_role,
|
||||
"computed_link_reach": grand_child.computed_link_reach,
|
||||
"computed_link_role": grand_child.computed_link_role,
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -373,7 +371,7 @@ def test_api_documents_search_descendants_list_authenticated_unrelated_public_or
|
||||
{
|
||||
"abilities": child2.get_abilities(user),
|
||||
"ancestors_link_reach": reach,
|
||||
"ancestors_link_role": document.link_role,
|
||||
"ancestors_link_role": child2.ancestors_link_role,
|
||||
"computed_link_reach": child2.computed_link_reach,
|
||||
"computed_link_role": child2.computed_link_role,
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -437,7 +435,7 @@ def test_api_documents_search_descendants_list_authenticated_public_or_authentic
|
||||
{
|
||||
"abilities": child1.get_abilities(user),
|
||||
"ancestors_link_reach": reach,
|
||||
"ancestors_link_role": grand_parent.link_role,
|
||||
"ancestors_link_role": child1.ancestors_link_role,
|
||||
"computed_link_reach": child1.computed_link_reach,
|
||||
"computed_link_role": child1.computed_link_role,
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -460,7 +458,7 @@ def test_api_documents_search_descendants_list_authenticated_public_or_authentic
|
||||
{
|
||||
"abilities": grand_child.get_abilities(user),
|
||||
"ancestors_link_reach": reach,
|
||||
"ancestors_link_role": grand_parent.link_role,
|
||||
"ancestors_link_role": grand_child.ancestors_link_role,
|
||||
"computed_link_reach": grand_child.computed_link_reach,
|
||||
"computed_link_role": grand_child.computed_link_role,
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
@@ -483,7 +481,7 @@ def test_api_documents_search_descendants_list_authenticated_public_or_authentic
|
||||
{
|
||||
"abilities": child2.get_abilities(user),
|
||||
"ancestors_link_reach": reach,
|
||||
"ancestors_link_role": grand_parent.link_role,
|
||||
"ancestors_link_role": child2.ancestors_link_role,
|
||||
"computed_link_reach": child2.computed_link_reach,
|
||||
"computed_link_role": child2.computed_link_role,
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
|
||||
@@ -83,7 +83,7 @@ def test_api_documents_trashbin_format():
|
||||
"descendants": False,
|
||||
"cors_proxy": False,
|
||||
"comment": False,
|
||||
"content": False,
|
||||
"formatted_content": False,
|
||||
"destroy": False,
|
||||
"duplicate": False,
|
||||
"favorite": False,
|
||||
@@ -95,6 +95,8 @@ def test_api_documents_trashbin_format():
|
||||
"restricted": None,
|
||||
},
|
||||
"mask": False,
|
||||
"content_patch": False,
|
||||
"content_retrieve": True,
|
||||
"media_auth": False,
|
||||
"media_check": False,
|
||||
"move": False, # Can't move a deleted document
|
||||
|
||||
@@ -19,25 +19,6 @@ from core.tests.conftest import TEAM, USER, VIA
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
# A valid Yjs document derived from YDOC_HELLO_WORLD_BASE64 with "Hello" replaced by "World",
|
||||
# used in PATCH tests to guarantee a real content change distinct from what DocumentFactory
|
||||
# produces.
|
||||
YDOC_UPDATED_CONTENT_BASE64 = (
|
||||
"AR717vLVDgAHAQ5kb2N1bWVudC1zdG9yZQMKYmxvY2tHcm91cAcA9e7y1Q4AAw5ibG9ja0NvbnRh"
|
||||
"aW5lcgcA9e7y1Q4BAwdoZWFkaW5nBwD17vLVDgIGBgD17vLVDgMGaXRhbGljAnt9hPXu8tUOBAVX"
|
||||
"b3JsZIb17vLVDgkGaXRhbGljBG51bGwoAPXu8tUOAg10ZXh0QWxpZ25tZW50AXcEbGVmdCgA9e7y"
|
||||
"1Q4CBWxldmVsAX0BKAD17vLVDgECaWQBdyQwNGQ2MjM0MS04MzI2LTQyMzYtYTA4My00ODdlMjZm"
|
||||
"YWQyMzAoAPXu8tUOAQl0ZXh0Q29sb3IBdwdkZWZhdWx0KAD17vLVDgEPYmFja2dyb3VuZENvbG9y"
|
||||
"AXcHZGVmYXVsdIf17vLVDgEDDmJsb2NrQ29udGFpbmVyBwD17vLVDhADDmJ1bGxldExpc3RJdGVt"
|
||||
"BwD17vLVDhEGBAD17vLVDhIBd4b17vLVDhMEYm9sZAJ7fYT17vLVDhQCb3KG9e7y1Q4WBGJvbGQE"
|
||||
"bnVsbIT17vLVDhcCbGQoAPXu8tUOEQ10ZXh0QWxpZ25tZW50AXcEbGVmdCgA9e7y1Q4QAmlkAXck"
|
||||
"ZDM1MWUwNjgtM2U1NS00MjI2LThlYTUtYWJiMjYzMTk4ZTJhKAD17vLVDhAJdGV4dENvbG9yAXcH"
|
||||
"ZGVmYXVsdCgA9e7y1Q4QD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHSH9e7y1Q4QAw5ibG9ja0Nv"
|
||||
"bnRhaW5lcgcA9e7y1Q4eAwlwYXJhZ3JhcGgoAPXu8tUOHw10ZXh0QWxpZ25tZW50AXcEbGVmdCgA"
|
||||
"9e7y1Q4eAmlkAXckODk3MDBjMDctZTBlMS00ZmUwLWFjYTItODQ5MzIwOWE3ZTQyKAD17vLVDh4J"
|
||||
"dGV4dENvbG9yAXcHZGVmYXVsdCgA9e7y1Q4eD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQA"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via_parent", [True, False])
|
||||
@pytest.mark.parametrize(
|
||||
@@ -736,25 +717,6 @@ def test_api_documents_update_administrator_or_owner_of_another(via, mock_user_t
|
||||
assert other_document_values == old_document_values
|
||||
|
||||
|
||||
def test_api_documents_update_invalid_content():
|
||||
"""
|
||||
Updating a document with a non base64 encoded content should raise a validation error.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(users=[[user, "owner"]])
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
{"content": "invalid content"},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"content": ["Invalid base64 content."]}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PATCH tests
|
||||
# =============================================================================
|
||||
@@ -784,11 +746,10 @@ def test_api_documents_patch_anonymous_forbidden(reach, role, via_parent):
|
||||
document = factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
|
||||
old_document_values = serializers.DocumentSerializer(instance=document).data
|
||||
new_content = YDOC_UPDATED_CONTENT_BASE64
|
||||
|
||||
response = APIClient().patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
{"content": new_content},
|
||||
{"title": "new title"},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 401
|
||||
@@ -828,11 +789,10 @@ def test_api_documents_patch_authenticated_unrelated_forbidden(reach, role, via_
|
||||
document = factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
|
||||
old_document_values = serializers.DocumentSerializer(instance=document).data
|
||||
new_content = YDOC_UPDATED_CONTENT_BASE64
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
{"content": new_content},
|
||||
{"title": "new title"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
@@ -876,11 +836,10 @@ def test_api_documents_patch_anonymous_or_authenticated_unrelated(
|
||||
|
||||
old_document_values = serializers.DocumentSerializer(instance=document).data
|
||||
old_path = document.path
|
||||
new_content = YDOC_UPDATED_CONTENT_BASE64
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
{"content": new_content, "websocket": True},
|
||||
{"title": "new title", "websocket": True},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -889,11 +848,10 @@ def test_api_documents_patch_anonymous_or_authenticated_unrelated(
|
||||
# Force reloading it by fetching the document in the database.
|
||||
document = models.Document.objects.get(id=document.id)
|
||||
assert document.path == old_path
|
||||
assert document.content == new_content
|
||||
assert document.title == "new title"
|
||||
document_values = serializers.DocumentSerializer(instance=document).data
|
||||
for key in [
|
||||
"id",
|
||||
"title",
|
||||
"link_reach",
|
||||
"link_role",
|
||||
"creator",
|
||||
@@ -933,11 +891,10 @@ def test_api_documents_patch_authenticated_reader(via, via_parent, mock_user_tea
|
||||
)
|
||||
|
||||
old_document_values = serializers.DocumentSerializer(instance=document).data
|
||||
new_content = YDOC_UPDATED_CONTENT_BASE64
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
{"content": new_content},
|
||||
{"title": "new title"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
@@ -983,11 +940,10 @@ def test_api_documents_patch_authenticated_editor_administrator_or_owner(
|
||||
|
||||
old_document_values = serializers.DocumentSerializer(instance=document).data
|
||||
old_path = document.path
|
||||
new_content = YDOC_UPDATED_CONTENT_BASE64
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
{"content": new_content, "websocket": True},
|
||||
{"title": "new title", "websocket": True},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -996,11 +952,10 @@ def test_api_documents_patch_authenticated_editor_administrator_or_owner(
|
||||
# Force reloading it by fetching the document in the database.
|
||||
document = models.Document.objects.get(id=document.id)
|
||||
assert document.path == old_path
|
||||
assert document.content == new_content
|
||||
assert document.title == "new title"
|
||||
document_values = serializers.DocumentSerializer(instance=document).data
|
||||
for key in [
|
||||
"id",
|
||||
"title",
|
||||
"link_reach",
|
||||
"link_role",
|
||||
"creator",
|
||||
@@ -1025,7 +980,6 @@ def test_api_documents_patch_authenticated_no_websocket(settings):
|
||||
session_key = client.session.session_key
|
||||
|
||||
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||
new_content = YDOC_UPDATED_CONTENT_BASE64
|
||||
|
||||
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||
@@ -1041,7 +995,7 @@ def test_api_documents_patch_authenticated_no_websocket(settings):
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
{"content": new_content},
|
||||
{"title": "new title"},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -1050,7 +1004,7 @@ def test_api_documents_patch_authenticated_no_websocket(settings):
|
||||
# Force reloading it by fetching the document from the database.
|
||||
document = models.Document.objects.get(id=document.id)
|
||||
assert document.path == old_path
|
||||
assert document.content == new_content
|
||||
assert document.title == "new title"
|
||||
assert cache.get(f"docs:no-websocket:{document.id}") == session_key
|
||||
assert ws_resp.call_count == 1
|
||||
|
||||
@@ -1067,7 +1021,6 @@ def test_api_documents_patch_authenticated_no_websocket_user_already_editing(set
|
||||
session_key = client.session.session_key
|
||||
|
||||
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||
new_content = YDOC_UPDATED_CONTENT_BASE64
|
||||
|
||||
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||
@@ -1082,7 +1035,7 @@ def test_api_documents_patch_authenticated_no_websocket_user_already_editing(set
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
{"content": new_content},
|
||||
{"title": "new title"},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
@@ -1103,7 +1056,6 @@ def test_api_documents_patch_no_websocket_other_user_connected_to_websocket(sett
|
||||
session_key = client.session.session_key
|
||||
|
||||
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||
new_content = YDOC_UPDATED_CONTENT_BASE64
|
||||
|
||||
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||
@@ -1118,7 +1070,7 @@ def test_api_documents_patch_no_websocket_other_user_connected_to_websocket(sett
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
{"content": new_content},
|
||||
{"title": "new title"},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
@@ -1139,7 +1091,6 @@ def test_api_documents_patch_user_connected_to_websocket(settings):
|
||||
session_key = client.session.session_key
|
||||
|
||||
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||
new_content = YDOC_UPDATED_CONTENT_BASE64
|
||||
|
||||
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||
@@ -1155,7 +1106,7 @@ def test_api_documents_patch_user_connected_to_websocket(settings):
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
{"content": new_content},
|
||||
{"title": "new title"},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -1164,7 +1115,7 @@ def test_api_documents_patch_user_connected_to_websocket(settings):
|
||||
# Force reloading it by fetching the document in the database.
|
||||
document = models.Document.objects.get(id=document.id)
|
||||
assert document.path == old_path
|
||||
assert document.content == new_content
|
||||
assert document.title == "new title"
|
||||
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||
assert ws_resp.call_count == 1
|
||||
|
||||
@@ -1183,7 +1134,6 @@ def test_api_documents_patch_websocket_server_unreachable_fallback_to_no_websock
|
||||
session_key = client.session.session_key
|
||||
|
||||
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||
new_content = YDOC_UPDATED_CONTENT_BASE64
|
||||
|
||||
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||
@@ -1199,7 +1149,7 @@ def test_api_documents_patch_websocket_server_unreachable_fallback_to_no_websock
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
{"content": new_content},
|
||||
{"title": "new title"},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -1208,7 +1158,7 @@ def test_api_documents_patch_websocket_server_unreachable_fallback_to_no_websock
|
||||
# Force reloading it by fetching the document from the database.
|
||||
document = models.Document.objects.get(id=document.id)
|
||||
assert document.path == old_path
|
||||
assert document.content == new_content
|
||||
assert document.title == "new title"
|
||||
assert cache.get(f"docs:no-websocket:{document.id}") == session_key
|
||||
assert ws_resp.call_count == 1
|
||||
|
||||
@@ -1227,7 +1177,6 @@ def test_api_documents_patch_websocket_server_unreachable_fallback_to_no_websock
|
||||
session_key = client.session.session_key
|
||||
|
||||
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||
new_content = YDOC_UPDATED_CONTENT_BASE64
|
||||
|
||||
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||
@@ -1242,7 +1191,7 @@ def test_api_documents_patch_websocket_server_unreachable_fallback_to_no_websock
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
{"content": new_content},
|
||||
{"title": "new title"},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
@@ -1265,7 +1214,6 @@ def test_api_documents_patch_websocket_server_room_not_found_fallback_to_no_webs
|
||||
session_key = client.session.session_key
|
||||
|
||||
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||
new_content = YDOC_UPDATED_CONTENT_BASE64
|
||||
|
||||
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||
@@ -1280,7 +1228,7 @@ def test_api_documents_patch_websocket_server_room_not_found_fallback_to_no_webs
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
{"content": new_content},
|
||||
{"title": "new title"},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 403
|
||||
@@ -1300,7 +1248,6 @@ def test_api_documents_patch_force_websocket_param_to_true(settings):
|
||||
session_key = client.session.session_key
|
||||
|
||||
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||
new_content = YDOC_UPDATED_CONTENT_BASE64
|
||||
|
||||
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||
@@ -1315,7 +1262,7 @@ def test_api_documents_patch_force_websocket_param_to_true(settings):
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
{"content": new_content, "websocket": True},
|
||||
{"title": "new title", "websocket": True},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -1324,7 +1271,7 @@ def test_api_documents_patch_force_websocket_param_to_true(settings):
|
||||
# Force reloading it by fetching the document from the database.
|
||||
document = models.Document.objects.get(id=document.id)
|
||||
assert document.path == old_path
|
||||
assert document.content == new_content
|
||||
assert document.title == "new title"
|
||||
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||
assert ws_resp.call_count == 0
|
||||
|
||||
@@ -1340,7 +1287,6 @@ def test_api_documents_patch_feature_flag_disabled(settings):
|
||||
session_key = client.session.session_key
|
||||
|
||||
document = factories.DocumentFactory(users=[(user, "editor")])
|
||||
new_content = YDOC_UPDATED_CONTENT_BASE64
|
||||
|
||||
settings.COLLABORATION_API_URL = "http://example.com/"
|
||||
settings.COLLABORATION_SERVER_SECRET = "secret-token"
|
||||
@@ -1356,7 +1302,7 @@ def test_api_documents_patch_feature_flag_disabled(settings):
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
{"content": new_content},
|
||||
{"title": "new title"},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -1365,7 +1311,7 @@ def test_api_documents_patch_feature_flag_disabled(settings):
|
||||
# Force reloading it by fetching the document from the database.
|
||||
document = models.Document.objects.get(id=document.id)
|
||||
assert document.path == old_path
|
||||
assert document.content == new_content
|
||||
assert document.title == "new title"
|
||||
assert cache.get(f"docs:no-websocket:{document.id}") is None
|
||||
assert ws_resp.call_count == 0
|
||||
|
||||
@@ -1396,11 +1342,10 @@ def test_api_documents_patch_administrator_or_owner_of_another(via, mock_user_te
|
||||
|
||||
other_document = factories.DocumentFactory(title="Old title", link_role="reader")
|
||||
old_document_values = serializers.DocumentSerializer(instance=other_document).data
|
||||
new_content = YDOC_UPDATED_CONTENT_BASE64
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{other_document.id!s}/",
|
||||
{"content": new_content},
|
||||
{"title": "new title"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
@@ -1413,25 +1358,6 @@ def test_api_documents_patch_administrator_or_owner_of_another(via, mock_user_te
|
||||
)
|
||||
|
||||
|
||||
def test_api_documents_patch_invalid_content():
|
||||
"""
|
||||
Patching a document with a non base64 encoded content should raise a validation error.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(users=[[user, "owner"]])
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
{"content": "invalid content"},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"content": ["Invalid base64 content."]}
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_patch_empty_body(settings):
|
||||
"""
|
||||
|
||||
@@ -14,7 +14,7 @@ from core import factories
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def get_ydoc_with_mages(image_keys):
|
||||
def get_ydoc_with_images(image_keys):
|
||||
"""Return a ydoc from text for testing purposes."""
|
||||
ydoc = pycrdt.Doc()
|
||||
fragment = pycrdt.XmlFragment(
|
||||
@@ -36,7 +36,7 @@ def test_api_documents_update_new_attachment_keys_anonymous(django_assert_num_qu
|
||||
"""
|
||||
image_keys = [f"{uuid4()!s}/attachments/{uuid4()!s}.png" for _ in range(4)]
|
||||
document = factories.DocumentFactory(
|
||||
content=get_ydoc_with_mages(image_keys[:1]),
|
||||
content=get_ydoc_with_images(image_keys[:1]),
|
||||
attachments=[image_keys[0]],
|
||||
link_reach="public",
|
||||
link_role="editor",
|
||||
@@ -47,13 +47,13 @@ def test_api_documents_update_new_attachment_keys_anonymous(django_assert_num_qu
|
||||
factories.DocumentFactory(attachments=[image_keys[3]], link_reach="restricted")
|
||||
expected_keys = {image_keys[i] for i in [0, 1]}
|
||||
|
||||
with django_assert_num_queries(11):
|
||||
response = APIClient().put(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
{"content": get_ydoc_with_mages(image_keys), "websocket": True},
|
||||
with django_assert_num_queries(9):
|
||||
response = APIClient().patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/content/",
|
||||
{"content": get_ydoc_with_images(image_keys)},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == 204
|
||||
|
||||
document.refresh_from_db()
|
||||
assert set(document.attachments) == expected_keys
|
||||
@@ -61,12 +61,12 @@ def test_api_documents_update_new_attachment_keys_anonymous(django_assert_num_qu
|
||||
# Check that the db query to check attachments readability for extracted
|
||||
# keys is not done if the content changes but no new keys are found
|
||||
with django_assert_num_queries(7):
|
||||
response = APIClient().put(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
{"content": get_ydoc_with_mages(image_keys[:2]), "websocket": True},
|
||||
response = APIClient().patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/content/",
|
||||
{"content": get_ydoc_with_images(image_keys[:2]), "websocket": True},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == 204
|
||||
|
||||
document.refresh_from_db()
|
||||
assert len(document.attachments) == 2
|
||||
@@ -87,7 +87,7 @@ def test_api_documents_update_new_attachment_keys_authenticated(
|
||||
|
||||
image_keys = [f"{uuid4()!s}/attachments/{uuid4()!s}.png" for _ in range(5)]
|
||||
document = factories.DocumentFactory(
|
||||
content=get_ydoc_with_mages(image_keys[:1]),
|
||||
content=get_ydoc_with_images(image_keys[:1]),
|
||||
attachments=[image_keys[0]],
|
||||
users=[(user, "editor")],
|
||||
)
|
||||
@@ -98,13 +98,13 @@ def test_api_documents_update_new_attachment_keys_authenticated(
|
||||
factories.DocumentFactory(attachments=[image_keys[4]], users=[user])
|
||||
expected_keys = {image_keys[i] for i in [0, 1, 2, 4]}
|
||||
|
||||
with django_assert_num_queries(12):
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
{"content": get_ydoc_with_mages(image_keys)},
|
||||
with django_assert_num_queries(10):
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/content/",
|
||||
{"content": get_ydoc_with_images(image_keys)},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == 204
|
||||
|
||||
document.refresh_from_db()
|
||||
assert set(document.attachments) == expected_keys
|
||||
@@ -112,12 +112,12 @@ def test_api_documents_update_new_attachment_keys_authenticated(
|
||||
# Check that the db query to check attachments readability for extracted
|
||||
# keys is not done if the content changes but no new keys are found
|
||||
with django_assert_num_queries(8):
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
{"content": get_ydoc_with_mages(image_keys[:2])},
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/content/",
|
||||
{"content": get_ydoc_with_images(image_keys[:2])},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == 204
|
||||
|
||||
document.refresh_from_db()
|
||||
assert len(document.attachments) == 4
|
||||
@@ -135,19 +135,19 @@ def test_api_documents_update_new_attachment_keys_duplicate():
|
||||
image_key1 = f"{uuid4()!s}/attachments/{uuid4()!s}.png"
|
||||
image_key2 = f"{uuid4()!s}/attachments/{uuid4()!s}.png"
|
||||
document = factories.DocumentFactory(
|
||||
content=get_ydoc_with_mages([image_key1]),
|
||||
content=get_ydoc_with_images([image_key1]),
|
||||
attachments=[image_key1],
|
||||
users=[(user, "editor")],
|
||||
)
|
||||
|
||||
factories.DocumentFactory(attachments=[image_key2], users=[user])
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/",
|
||||
{"content": get_ydoc_with_mages([image_key1, image_key2, image_key2])},
|
||||
response = client.patch(
|
||||
f"/api/v1.0/documents/{document.id!s}/content/",
|
||||
{"content": get_ydoc_with_images([image_key1, image_key2, image_key2])},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == 204
|
||||
|
||||
document.refresh_from_db()
|
||||
assert len(document.attachments) == 2
|
||||
|
||||
@@ -14,6 +14,7 @@ import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core.services.ai_services.legacy import get_legacy_ai_service
|
||||
from core.tests.documents.test_api_documents_ai_proxy import ( # pylint: disable=unused-import
|
||||
ai_settings,
|
||||
)
|
||||
@@ -23,6 +24,12 @@ pytestmark = pytest.mark.django_db
|
||||
# pylint: disable=unused-argument
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_openai_client_config():
|
||||
"""Clear the configure_legacy_openai_client cache."""
|
||||
get_legacy_ai_service.cache_clear()
|
||||
|
||||
|
||||
def test_external_api_documents_ai_transform_not_allowed(
|
||||
user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
@@ -219,7 +226,9 @@ def test_external_api_documents_ai_translate_can_be_allowed(
|
||||
"Translate the content in the html to the "
|
||||
"specified language Colombian Spanish. "
|
||||
"Check the translation for accuracy and make any necessary corrections. "
|
||||
"Do not provide any other information."
|
||||
"Do not provide any other information. "
|
||||
"Return the content directly without wrapping it in code blocks or markdown "
|
||||
"delimiters."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": "Hello"},
|
||||
@@ -241,7 +250,7 @@ def test_external_api_documents_ai_translate_can_be_allowed(
|
||||
}
|
||||
)
|
||||
@pytest.mark.usefixtures("ai_settings")
|
||||
@patch("core.services.ai_services.AIService.stream")
|
||||
@patch("core.services.ai_services.blocknote.AIService.stream")
|
||||
def test_external_api_documents_ai_proxy_can_be_allowed(
|
||||
mock_stream, user_token, resource_server_backend, user_specific_sub
|
||||
):
|
||||
|
||||
@@ -165,13 +165,15 @@ def test_models_documents_get_abilities_forbidden(
|
||||
"collaboration_auth": False,
|
||||
"descendants": False,
|
||||
"cors_proxy": False,
|
||||
"content": False,
|
||||
"formatted_content": False,
|
||||
"destroy": False,
|
||||
"duplicate": False,
|
||||
"favorite": False,
|
||||
"comment": False,
|
||||
"invite_owner": False,
|
||||
"mask": False,
|
||||
"content_patch": False,
|
||||
"content_retrieve": False,
|
||||
"media_auth": False,
|
||||
"media_check": False,
|
||||
"move": False,
|
||||
@@ -233,7 +235,7 @@ def test_models_documents_get_abilities_reader(
|
||||
"comment": False,
|
||||
"descendants": True,
|
||||
"cors_proxy": True,
|
||||
"content": True,
|
||||
"formatted_content": True,
|
||||
"destroy": False,
|
||||
"duplicate": is_authenticated,
|
||||
"favorite": is_authenticated,
|
||||
@@ -245,6 +247,8 @@ def test_models_documents_get_abilities_reader(
|
||||
"restricted": None,
|
||||
},
|
||||
"mask": is_authenticated,
|
||||
"content_patch": False,
|
||||
"content_retrieve": True,
|
||||
"media_auth": True,
|
||||
"media_check": True,
|
||||
"move": False,
|
||||
@@ -303,7 +307,7 @@ def test_models_documents_get_abilities_commenter(
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"comment": True,
|
||||
"content": True,
|
||||
"formatted_content": True,
|
||||
"descendants": True,
|
||||
"cors_proxy": True,
|
||||
"destroy": False,
|
||||
@@ -317,6 +321,8 @@ def test_models_documents_get_abilities_commenter(
|
||||
"restricted": None,
|
||||
},
|
||||
"mask": is_authenticated,
|
||||
"content_patch": False,
|
||||
"content_retrieve": True,
|
||||
"media_auth": True,
|
||||
"media_check": True,
|
||||
"move": False,
|
||||
@@ -374,7 +380,7 @@ def test_models_documents_get_abilities_editor(
|
||||
"comment": True,
|
||||
"descendants": True,
|
||||
"cors_proxy": True,
|
||||
"content": True,
|
||||
"formatted_content": True,
|
||||
"destroy": False,
|
||||
"duplicate": is_authenticated,
|
||||
"favorite": is_authenticated,
|
||||
@@ -386,6 +392,8 @@ def test_models_documents_get_abilities_editor(
|
||||
"restricted": None,
|
||||
},
|
||||
"mask": is_authenticated,
|
||||
"content_patch": True,
|
||||
"content_retrieve": True,
|
||||
"media_auth": True,
|
||||
"media_check": True,
|
||||
"move": False,
|
||||
@@ -432,7 +440,7 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
|
||||
"comment": True,
|
||||
"descendants": True,
|
||||
"cors_proxy": True,
|
||||
"content": True,
|
||||
"formatted_content": True,
|
||||
"destroy": True,
|
||||
"duplicate": True,
|
||||
"favorite": True,
|
||||
@@ -444,6 +452,8 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
|
||||
"restricted": None,
|
||||
},
|
||||
"mask": True,
|
||||
"content_patch": True,
|
||||
"content_retrieve": True,
|
||||
"media_auth": True,
|
||||
"media_check": True,
|
||||
"move": True,
|
||||
@@ -476,7 +486,7 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
|
||||
"comment": False,
|
||||
"descendants": False,
|
||||
"cors_proxy": False,
|
||||
"content": False,
|
||||
"formatted_content": False,
|
||||
"destroy": False,
|
||||
"duplicate": False,
|
||||
"favorite": False,
|
||||
@@ -488,6 +498,8 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
|
||||
"restricted": None,
|
||||
},
|
||||
"mask": False,
|
||||
"content_patch": False,
|
||||
"content_retrieve": True,
|
||||
"media_auth": False,
|
||||
"media_check": False,
|
||||
"move": False,
|
||||
@@ -524,7 +536,7 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
|
||||
"comment": True,
|
||||
"descendants": True,
|
||||
"cors_proxy": True,
|
||||
"content": True,
|
||||
"formatted_content": True,
|
||||
"destroy": False,
|
||||
"duplicate": True,
|
||||
"favorite": True,
|
||||
@@ -536,6 +548,8 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
|
||||
"restricted": None,
|
||||
},
|
||||
"mask": True,
|
||||
"content_patch": True,
|
||||
"content_retrieve": True,
|
||||
"media_auth": True,
|
||||
"media_check": True,
|
||||
"move": True,
|
||||
@@ -582,7 +596,7 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
|
||||
"comment": True,
|
||||
"descendants": True,
|
||||
"cors_proxy": True,
|
||||
"content": True,
|
||||
"formatted_content": True,
|
||||
"destroy": False,
|
||||
"duplicate": True,
|
||||
"favorite": True,
|
||||
@@ -594,6 +608,8 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
|
||||
"restricted": None,
|
||||
},
|
||||
"mask": True,
|
||||
"content_patch": True,
|
||||
"content_retrieve": True,
|
||||
"media_auth": True,
|
||||
"media_check": True,
|
||||
"move": False,
|
||||
@@ -648,7 +664,7 @@ def test_models_documents_get_abilities_reader_user(
|
||||
and document.link_role in ["commenter", "editor"],
|
||||
"descendants": True,
|
||||
"cors_proxy": True,
|
||||
"content": True,
|
||||
"formatted_content": True,
|
||||
"destroy": False,
|
||||
"duplicate": True,
|
||||
"favorite": True,
|
||||
@@ -660,6 +676,8 @@ def test_models_documents_get_abilities_reader_user(
|
||||
"restricted": None,
|
||||
},
|
||||
"mask": True,
|
||||
"content_patch": access_from_link,
|
||||
"content_retrieve": True,
|
||||
"media_auth": True,
|
||||
"media_check": True,
|
||||
"move": False,
|
||||
@@ -713,7 +731,7 @@ def test_models_documents_get_abilities_commenter_user(
|
||||
"children_list": True,
|
||||
"collaboration_auth": True,
|
||||
"comment": True,
|
||||
"content": True,
|
||||
"formatted_content": True,
|
||||
"descendants": True,
|
||||
"cors_proxy": True,
|
||||
"destroy": False,
|
||||
@@ -727,6 +745,8 @@ def test_models_documents_get_abilities_commenter_user(
|
||||
"restricted": None,
|
||||
},
|
||||
"mask": True,
|
||||
"content_patch": access_from_link,
|
||||
"content_retrieve": True,
|
||||
"media_auth": True,
|
||||
"media_check": True,
|
||||
"move": False,
|
||||
@@ -778,7 +798,7 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
|
||||
"comment": False,
|
||||
"descendants": True,
|
||||
"cors_proxy": True,
|
||||
"content": True,
|
||||
"formatted_content": True,
|
||||
"destroy": False,
|
||||
"duplicate": True,
|
||||
"favorite": True,
|
||||
@@ -790,6 +810,8 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
|
||||
"restricted": None,
|
||||
},
|
||||
"mask": True,
|
||||
"content_patch": False,
|
||||
"content_retrieve": True,
|
||||
"media_auth": True,
|
||||
"media_check": True,
|
||||
"move": False,
|
||||
|
||||
@@ -10,14 +10,23 @@ from django.core.exceptions import ImproperlyConfigured
|
||||
from django.test.utils import override_settings
|
||||
|
||||
import pytest
|
||||
from openai import OpenAIError
|
||||
from mistralai import Mistral
|
||||
from openai import OpenAI, OpenAIError
|
||||
from pydantic_ai.models.mistral import MistralModel
|
||||
from pydantic_ai.models.openai import OpenAIChatModel
|
||||
from pydantic_ai.ui.vercel_ai.request_types import TextUIPart, UIMessage
|
||||
|
||||
from core.services.ai_services import (
|
||||
from core.services.ai_services.blocknote import (
|
||||
BLOCKNOTE_TOOL_STRICT_PROMPT,
|
||||
AIService,
|
||||
configure_pydantic_model_provider,
|
||||
convert_async_generator_to_sync,
|
||||
)
|
||||
from core.services.ai_services.legacy import (
|
||||
LegacyAiServiceMistralClient,
|
||||
LegacyAiServiceOpenAiClient,
|
||||
get_legacy_ai_service,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -26,35 +35,129 @@ pytestmark = pytest.mark.django_db
|
||||
def ai_settings(settings):
|
||||
"""Fixture to set AI settings."""
|
||||
settings.AI_MODEL = "llama"
|
||||
settings.AI_BASE_URL = "http://example.com"
|
||||
settings.AI_API_KEY = "test-key"
|
||||
settings.OPENAI_SDK_BASE_URL = "http://example.com"
|
||||
settings.OPENAI_SDK_API_KEY = "test-key"
|
||||
settings.AI_FEATURE_ENABLED = True
|
||||
settings.AI_FEATURE_BLOCKNOTE_ENABLED = True
|
||||
settings.AI_FEATURE_LEGACY_ENABLED = True
|
||||
settings.LANGFUSE_PUBLIC_KEY = None
|
||||
settings.AI_VERCEL_SDK_VERSION = 6
|
||||
yield
|
||||
configure_pydantic_model_provider.cache_clear()
|
||||
get_legacy_ai_service.cache_clear()
|
||||
|
||||
|
||||
# -- AIService.__init__ --
|
||||
# -- AIService configure sdk--
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"setting_name, setting_value",
|
||||
[
|
||||
("AI_BASE_URL", None),
|
||||
("AI_API_KEY", None),
|
||||
("OPENAI_SDK_BASE_URL", None),
|
||||
("OPENAI_SDK_API_KEY", None),
|
||||
("AI_MODEL", None),
|
||||
],
|
||||
)
|
||||
def test_services_ai_setting_missing(setting_name, setting_value, settings):
|
||||
"""Setting should be set"""
|
||||
def test_ai_services_configure_open_ai_leagcy_client_missing_settings(
|
||||
setting_name, setting_value, settings
|
||||
):
|
||||
"""
|
||||
An exception must be raised if an expected settings is missing to configure the openai sdk.
|
||||
"""
|
||||
setattr(settings, setting_name, setting_value)
|
||||
|
||||
with pytest.raises(
|
||||
ImproperlyConfigured,
|
||||
match="AI configuration not set",
|
||||
):
|
||||
AIService()
|
||||
LegacyAiServiceOpenAiClient()
|
||||
|
||||
|
||||
def test_ai_services_configure_open_ai_leagcy_client(settings):
|
||||
"""With all required settings the OpenAi legacy client should be configured."""
|
||||
settings.AI_MODEL = "llama"
|
||||
settings.OPENAI_SDK_BASE_URL = "http://example.com"
|
||||
settings.OPENAI_SDK_API_KEY = "test-key"
|
||||
|
||||
legacy_openai_client = LegacyAiServiceOpenAiClient()
|
||||
|
||||
assert isinstance(legacy_openai_client.client, OpenAI)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"setting_name, setting_value",
|
||||
[
|
||||
("MISTRAL_SDK_BASE_URL", None),
|
||||
("MISTRAL_SDK_API_KEY", None),
|
||||
("AI_MODEL", None),
|
||||
],
|
||||
)
|
||||
def test_ai_services_configure_mistral_sdk_leagcy_client_missing_settings(
|
||||
setting_name, setting_value, settings
|
||||
):
|
||||
"""
|
||||
An exception must be raised if an expected settings is missing to configure the openai sdk.
|
||||
"""
|
||||
settings.OPENAI_SDK_BASE_URL = None
|
||||
settings.OPENAI_SDK_API_KEY = None
|
||||
setattr(settings, setting_name, setting_value)
|
||||
|
||||
with pytest.raises(
|
||||
ImproperlyConfigured,
|
||||
match="Mistral sdk configuration not set",
|
||||
):
|
||||
LegacyAiServiceMistralClient()
|
||||
|
||||
|
||||
def test_ai_services_configure_mistral_sdk_legacy_client(settings):
|
||||
"""With all required settings the Mistral sdk legacy client should be configured."""
|
||||
|
||||
settings.AI_MODEL = "llama"
|
||||
settings.OPENAI_SDK_BASE_URL = None
|
||||
settings.OPENAI_SDK_API_KEY = None
|
||||
settings.MISTRAL_SDK_API_KEY = "mistreal-sdk-key"
|
||||
settings.MISTRAL_SDK_BASE_URL = "https://mistral.base-url.com"
|
||||
|
||||
legacy_mistral_client = LegacyAiServiceMistralClient()
|
||||
|
||||
assert isinstance(legacy_mistral_client.client, Mistral)
|
||||
|
||||
|
||||
def test_ai_services_configure_pydantic_ai_model_openai(settings):
|
||||
"""When openai sdk settings are configured it should return an OpenAiChatModel."""
|
||||
settings.AI_MODEL = "llama"
|
||||
settings.OPENAI_SDK_BASE_URL = "http://example.com"
|
||||
settings.OPENAI_SDK_API_KEY = "test-key"
|
||||
|
||||
pydantic_ai_model = configure_pydantic_model_provider()
|
||||
assert isinstance(pydantic_ai_model, OpenAIChatModel)
|
||||
|
||||
|
||||
def test_ai_services_configure_pydantic_ai_model_mistral(settings):
|
||||
"""When mistral sdk settings are configured is should return a MistralModel."""
|
||||
settings.AI_MODEL = "llama"
|
||||
settings.OPENAI_SDK_BASE_URL = None
|
||||
settings.OPENAI_SDK_API_KEY = None
|
||||
settings.MISTRAL_SDK_API_KEY = "mistreal-sdk-key"
|
||||
settings.MISTRAL_SDK_BASE_URL = "https://mistral.base-url.com"
|
||||
|
||||
pydantic_ai_model = configure_pydantic_model_provider()
|
||||
assert isinstance(pydantic_ai_model, MistralModel)
|
||||
|
||||
|
||||
def test_ai_services_configure_pydantic_ai_model_no_settings(settings):
|
||||
"""When no settings are configured for a ai sdk it should raises an exception."""
|
||||
settings.AI_MODEL = None
|
||||
settings.OPENAI_SDK_BASE_URL = None
|
||||
settings.OPENAI_SDK_API_KEY = None
|
||||
settings.MISTRAL_SDK_API_KEY = None
|
||||
settings.MISTRAL_SDK_BASE_URL = None
|
||||
|
||||
with pytest.raises(
|
||||
ImproperlyConfigured,
|
||||
match="AI configuration not set",
|
||||
):
|
||||
configure_pydantic_model_provider()
|
||||
|
||||
|
||||
# -- AIService.transform --
|
||||
@@ -73,7 +176,7 @@ def test_services_ai_client_error(mock_create):
|
||||
OpenAIError,
|
||||
match="Mocked client error",
|
||||
):
|
||||
AIService().transform("hello", "prompt")
|
||||
get_legacy_ai_service().transform("hello", "prompt")
|
||||
|
||||
|
||||
@override_settings(
|
||||
@@ -91,7 +194,7 @@ def test_services_ai_client_invalid_response(mock_create):
|
||||
RuntimeError,
|
||||
match="AI response does not contain an answer",
|
||||
):
|
||||
AIService().transform("hello", "prompt")
|
||||
get_legacy_ai_service().transform("hello", "prompt")
|
||||
|
||||
|
||||
@override_settings(
|
||||
@@ -105,7 +208,7 @@ def test_services_ai_success(mock_create):
|
||||
choices=[MagicMock(message=MagicMock(content="Salut"))]
|
||||
)
|
||||
|
||||
response = AIService().transform("hello", "prompt")
|
||||
response = get_legacy_ai_service().transform("hello", "prompt")
|
||||
|
||||
assert response == {"answer": "Salut"}
|
||||
|
||||
@@ -121,7 +224,7 @@ def test_services_ai_translate_success(mock_create):
|
||||
choices=[MagicMock(message=MagicMock(content="Bonjour"))]
|
||||
)
|
||||
|
||||
response = AIService().translate("<p>Hello</p>", "fr")
|
||||
response = get_legacy_ai_service().translate("<p>Hello</p>", "fr")
|
||||
|
||||
assert response == {"answer": "Bonjour"}
|
||||
call_args = mock_create.call_args
|
||||
@@ -137,7 +240,7 @@ def test_services_ai_translate_unknown_language(mock_create):
|
||||
choices=[MagicMock(message=MagicMock(content="Translated"))]
|
||||
)
|
||||
|
||||
response = AIService().translate("<p>Hello</p>", "xx-unknown")
|
||||
response = get_legacy_ai_service().translate("<p>Hello</p>", "xx-unknown")
|
||||
|
||||
assert response == {"answer": "Translated"}
|
||||
call_args = mock_create.call_args
|
||||
@@ -448,7 +551,7 @@ def test_services_ai_stream_defaults_to_sync(mock_build, monkeypatch):
|
||||
# -- AIService._build_async_stream --
|
||||
|
||||
|
||||
@patch("core.services.ai_services.VercelAIAdapter")
|
||||
@patch("core.services.ai_services.blocknote.VercelAIAdapter")
|
||||
def test_services_ai_build_async_stream(mock_adapter_cls):
|
||||
"""_build_async_stream should build the pydantic-ai streaming pipeline."""
|
||||
|
||||
@@ -477,7 +580,7 @@ def test_services_ai_build_async_stream(mock_adapter_cls):
|
||||
mock_adapter_instance.encode_stream.assert_called_once()
|
||||
|
||||
|
||||
@patch("core.services.ai_services.VercelAIAdapter")
|
||||
@patch("core.services.ai_services.blocknote.VercelAIAdapter")
|
||||
def test_services_ai_build_async_stream_with_tool_definitions(mock_adapter_cls):
|
||||
"""_build_async_stream should build an ExternalToolset when
|
||||
toolDefinitions are present in the request."""
|
||||
@@ -514,7 +617,7 @@ def test_services_ai_build_async_stream_with_tool_definitions(mock_adapter_cls):
|
||||
assert len(call_kwargs["toolsets"]) == 1
|
||||
|
||||
|
||||
@patch("core.services.ai_services.VercelAIAdapter")
|
||||
@patch("core.services.ai_services.blocknote.VercelAIAdapter")
|
||||
def test_services_ai_build_async_stream_with_tool_definitions_required_system_prompt(
|
||||
mock_adapter_cls,
|
||||
):
|
||||
@@ -557,8 +660,8 @@ def test_services_ai_build_async_stream_with_tool_definitions_required_system_pr
|
||||
assert mock_run_input.messages[0].parts[0].text == BLOCKNOTE_TOOL_STRICT_PROMPT
|
||||
|
||||
|
||||
@patch("core.services.ai_services.Agent")
|
||||
@patch("core.services.ai_services.VercelAIAdapter")
|
||||
@patch("core.services.ai_services.blocknote.Agent")
|
||||
@patch("core.services.ai_services.blocknote.VercelAIAdapter")
|
||||
def test_services_ai_build_async_stream_langfuse_enabled(
|
||||
mock_adapter_cls, mock_agent_cls, settings
|
||||
):
|
||||
|
||||
@@ -110,8 +110,11 @@ def test_docspec_convert_success(mock_post, settings):
|
||||
# Verify the request was made correctly
|
||||
mock_post.assert_called_once_with(
|
||||
"http://docspec.test/convert",
|
||||
headers={"Accept": mime_types.BLOCKNOTE},
|
||||
files={"file": ("document.docx", docx_data, mime_types.DOCX)},
|
||||
headers={
|
||||
"Content-Type": mime_types.DOCX,
|
||||
"Accept": mime_types.BLOCKNOTE,
|
||||
},
|
||||
data=docx_data,
|
||||
timeout=5,
|
||||
verify=False,
|
||||
)
|
||||
|
||||
@@ -161,7 +161,8 @@
|
||||
},
|
||||
"onboarding": {
|
||||
"enabled": true,
|
||||
"learn_more_url": ""
|
||||
"learn_more_url": "",
|
||||
"ready_template_url": ""
|
||||
},
|
||||
"help": {
|
||||
"documentation_url": ""
|
||||
|
||||
@@ -18,6 +18,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import sentry_sdk
|
||||
from configurations import Configuration, values
|
||||
from corsheaders.defaults import default_headers
|
||||
from csp.constants import NONE
|
||||
from lasuite.configuration.values import SecretFileValue
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
@@ -129,6 +130,12 @@ class Base(Configuration):
|
||||
default=50, environ_name="SEARCH_INDEXER_QUERY_LIMIT", environ_prefix=None
|
||||
)
|
||||
|
||||
MEDIA_AUTH_ORIGINAL_URL_HEADER = values.Value(
|
||||
default="HTTP_X_ORIGINAL_URL",
|
||||
environ_name="MEDIA_AUTH_ORIGINAL_URL_HEADER",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
STATIC_URL = "/static/"
|
||||
STATIC_ROOT = os.path.join(DATA_DIR, "static")
|
||||
@@ -801,8 +808,30 @@ class Base(Configuration):
|
||||
environ_name="AI_ALLOW_REACH_FROM",
|
||||
environ_prefix=None,
|
||||
)
|
||||
AI_API_KEY = SecretFileValue(None, environ_name="AI_API_KEY", environ_prefix=None)
|
||||
AI_BASE_URL = values.Value(None, environ_name="AI_BASE_URL", environ_prefix=None)
|
||||
|
||||
MISTRAL_SDK_BASE_URL = values.Value(
|
||||
None, environ_name="MISTRAL_SDK_BASE_URL", environ_prefix=None
|
||||
)
|
||||
MISTRAL_SDK_API_KEY = SecretFileValue(
|
||||
None, environ_name="MISTRAL_SDK_API_KEY", environ_prefix=None
|
||||
)
|
||||
|
||||
OPENAI_SDK_API_KEY = SecretFileValue(
|
||||
default=SecretFileValue( # retrocompatibility
|
||||
None,
|
||||
environ_name="AI_API_KEY",
|
||||
environ_prefix=None,
|
||||
),
|
||||
environ_name="OPENAI_SDK_API_KEY",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OPENAI_SDK_BASE_URL = values.Value(
|
||||
default=values.Value( # retrocompatibility
|
||||
None, environ_name="AI_BASE_URL", environ_prefix=None
|
||||
),
|
||||
environ_name="OPENAI_SDK_BASE_URL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
AI_BOT = values.DictValue(
|
||||
default={
|
||||
"name": _("Docs AI"),
|
||||
@@ -1048,6 +1077,10 @@ class Base(Configuration):
|
||||
),
|
||||
}
|
||||
|
||||
CONTENT_METADATA_CACHE_TIMEOUT = values.IntegerValue(
|
||||
60 * 60 * 24, environ_name="CONTENT_METADATA_CACHE_TIMEOUT", environ_prefix=None
|
||||
)
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@property
|
||||
def ENVIRONMENT(self):
|
||||
@@ -1138,6 +1171,11 @@ class Base(Configuration):
|
||||
}
|
||||
)
|
||||
|
||||
if cls.OPENAI_SDK_API_KEY and cls.MISTRAL_SDK_API_KEY:
|
||||
raise ValueError(
|
||||
"Both OPENAI_SDK and MISTRAL_SDK parameters can not be set simultaneously."
|
||||
)
|
||||
|
||||
|
||||
class Build(Base):
|
||||
"""Settings used when the application is built.
|
||||
@@ -1170,6 +1208,12 @@ class Development(Base):
|
||||
ALLOWED_HOSTS = ["*"]
|
||||
CORS_ALLOW_ALL_ORIGINS = True
|
||||
CSRF_TRUSTED_ORIGINS = ["http://localhost:8072", "http://localhost:3000"]
|
||||
CORS_ALLOW_HEADERS = (
|
||||
*default_headers,
|
||||
"if-none-match",
|
||||
"if-modified-since",
|
||||
)
|
||||
CORS_EXPOSE_HEADERS = ["ETag"]
|
||||
DEBUG = True
|
||||
|
||||
USE_SWAGGER = True
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-04-02 09:37+0000\n"
|
||||
"PO-Revision-Date: 2026-04-08 13:28\n"
|
||||
"POT-Creation-Date: 2026-04-30 12:37+0000\n"
|
||||
"PO-Revision-Date: 2026-04-30 13:05\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Breton\n"
|
||||
"Language: br_FR\n"
|
||||
@@ -17,28 +17,28 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:30 core/admin.py:30
|
||||
#: build/lib/core/admin.py:33 core/admin.py:33
|
||||
msgid "Personal info"
|
||||
msgstr "Titouroù personel"
|
||||
|
||||
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
|
||||
#: core/admin.py:161
|
||||
#: build/lib/core/admin.py:46 build/lib/core/admin.py:166 core/admin.py:46
|
||||
#: core/admin.py:166
|
||||
msgid "Permissions"
|
||||
msgstr "Aotreoù"
|
||||
|
||||
#: build/lib/core/admin.py:55 core/admin.py:55
|
||||
#: build/lib/core/admin.py:58 core/admin.py:58
|
||||
msgid "Important dates"
|
||||
msgstr "Deiziadoù a-bouez"
|
||||
|
||||
#: build/lib/core/admin.py:112 core/admin.py:112
|
||||
#: build/lib/core/admin.py:117 core/admin.py:117
|
||||
msgid "Import job created and queued."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:116 core/admin.py:116
|
||||
#: build/lib/core/admin.py:121 core/admin.py:121
|
||||
msgid "Process selected user reconciliations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:171 core/admin.py:171
|
||||
#: build/lib/core/admin.py:176 core/admin.py:176
|
||||
msgid "Tree structure"
|
||||
msgstr "Gwezennadur"
|
||||
|
||||
@@ -62,24 +62,24 @@ msgstr "Kuzhet"
|
||||
msgid "Favorite"
|
||||
msgstr "Sinedoù"
|
||||
|
||||
#: build/lib/core/api/serializers.py:544 core/api/serializers.py:544
|
||||
#: build/lib/core/api/serializers.py:507 core/api/serializers.py:507
|
||||
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:548 core/api/serializers.py:548
|
||||
#: build/lib/core/api/serializers.py:511 core/api/serializers.py:511
|
||||
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:584 core/api/serializers.py:584
|
||||
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
|
||||
msgid "This field is required."
|
||||
msgstr "Ar vaezienn-mañ a zo rekis."
|
||||
|
||||
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
|
||||
#: build/lib/core/api/serializers.py:558 core/api/serializers.py:558
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1312 core/api/viewsets.py:1312
|
||||
#: build/lib/core/api/viewsets.py:1299 core/api/viewsets.py:1299
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "eilenn {title}"
|
||||
@@ -375,151 +375,151 @@ msgstr "Restr"
|
||||
msgid "Documents"
|
||||
msgstr "Restroù"
|
||||
|
||||
#: build/lib/core/models.py:940 build/lib/core/models.py:1345
|
||||
#: core/models.py:940 core/models.py:1345
|
||||
#: build/lib/core/models.py:940 build/lib/core/models.py:1347
|
||||
#: core/models.py:940 core/models.py:1347
|
||||
msgid "Untitled Document"
|
||||
msgstr "Restr hep titl"
|
||||
|
||||
#: build/lib/core/models.py:1346 core/models.py:1346
|
||||
#: build/lib/core/models.py:1348 core/models.py:1348
|
||||
msgid "Open"
|
||||
msgstr "Digeriñ"
|
||||
|
||||
#: build/lib/core/models.py:1381 core/models.py:1381
|
||||
#: build/lib/core/models.py:1383 core/models.py:1383
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} en deus rannet ur restr ganeoc'h!"
|
||||
|
||||
#: build/lib/core/models.py:1385 core/models.py:1385
|
||||
#: build/lib/core/models.py:1387 core/models.py:1387
|
||||
#, 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:1391 core/models.py:1391
|
||||
#: build/lib/core/models.py:1393 core/models.py:1393
|
||||
#, 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:1492 core/models.py:1492
|
||||
#: build/lib/core/models.py:1494 core/models.py:1494
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Roud liamm ar restr/an implijer"
|
||||
|
||||
#: build/lib/core/models.py:1493 core/models.py:1493
|
||||
#: build/lib/core/models.py:1495 core/models.py:1495
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Roudoù liamm ar restr/an implijer"
|
||||
|
||||
#: build/lib/core/models.py:1499 core/models.py:1499
|
||||
#: build/lib/core/models.py:1501 core/models.py:1501
|
||||
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:1522 core/models.py:1522
|
||||
#: build/lib/core/models.py:1524 core/models.py:1524
|
||||
msgid "Document favorite"
|
||||
msgstr "Restr muiañ-karet"
|
||||
|
||||
#: build/lib/core/models.py:1523 core/models.py:1523
|
||||
#: build/lib/core/models.py:1525 core/models.py:1525
|
||||
msgid "Document favorites"
|
||||
msgstr "Restroù muiañ-karet"
|
||||
|
||||
#: build/lib/core/models.py:1529 core/models.py:1529
|
||||
#: build/lib/core/models.py:1531 core/models.py:1531
|
||||
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:1551 core/models.py:1551
|
||||
#: build/lib/core/models.py:1553 core/models.py:1553
|
||||
msgid "Document/user relation"
|
||||
msgstr "Liamm restr/implijer"
|
||||
|
||||
#: build/lib/core/models.py:1552 core/models.py:1552
|
||||
#: build/lib/core/models.py:1554 core/models.py:1554
|
||||
msgid "Document/user relations"
|
||||
msgstr "Liammoù restr/implijer"
|
||||
|
||||
#: build/lib/core/models.py:1558 core/models.py:1558
|
||||
#: build/lib/core/models.py:1560 core/models.py:1560
|
||||
msgid "This user is already in this document."
|
||||
msgstr "An implijer-mañ a zo dija er restr-mañ."
|
||||
|
||||
#: build/lib/core/models.py:1564 core/models.py:1564
|
||||
#: build/lib/core/models.py:1566 core/models.py:1566
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Ar skipailh-mañ a zo dija en restr-mañ."
|
||||
|
||||
#: build/lib/core/models.py:1570 core/models.py:1570
|
||||
#: build/lib/core/models.py:1572 core/models.py:1572
|
||||
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:1721 core/models.py:1721
|
||||
#: build/lib/core/models.py:1723 core/models.py:1723
|
||||
msgid "Document ask for access"
|
||||
msgstr "Goulenn tizhout ar restr"
|
||||
|
||||
#: build/lib/core/models.py:1722 core/models.py:1722
|
||||
#: build/lib/core/models.py:1724 core/models.py:1724
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "Goulennoù tizhout ar restr"
|
||||
|
||||
#: build/lib/core/models.py:1728 core/models.py:1728
|
||||
#: build/lib/core/models.py:1730 core/models.py:1730
|
||||
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:1785 core/models.py:1785
|
||||
#: build/lib/core/models.py:1787 core/models.py:1787
|
||||
#, 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:1789 core/models.py:1789
|
||||
#: build/lib/core/models.py:1791 core/models.py:1791
|
||||
#, 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:1795 core/models.py:1795
|
||||
#: build/lib/core/models.py:1797 core/models.py:1797
|
||||
#, 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:1837 core/models.py:1837
|
||||
#: build/lib/core/models.py:1839 core/models.py:1839
|
||||
msgid "Thread"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1838 core/models.py:1838
|
||||
#: build/lib/core/models.py:1840 core/models.py:1840
|
||||
msgid "Threads"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1841 build/lib/core/models.py:1893
|
||||
#: core/models.py:1841 core/models.py:1893
|
||||
#: build/lib/core/models.py:1843 build/lib/core/models.py:1895
|
||||
#: core/models.py:1843 core/models.py:1895
|
||||
msgid "Anonymous"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1888 core/models.py:1888
|
||||
#: build/lib/core/models.py:1890 core/models.py:1890
|
||||
msgid "Comment"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1889 core/models.py:1889
|
||||
#: build/lib/core/models.py:1891 core/models.py:1891
|
||||
msgid "Comments"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1938 core/models.py:1938
|
||||
#: build/lib/core/models.py:1940 core/models.py:1940
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1942 core/models.py:1942
|
||||
#: build/lib/core/models.py:1944 core/models.py:1944
|
||||
msgid "Reaction"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1943 core/models.py:1943
|
||||
#: build/lib/core/models.py:1945 core/models.py:1945
|
||||
msgid "Reactions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1953 core/models.py:1953
|
||||
#: build/lib/core/models.py:1955 core/models.py:1955
|
||||
msgid "email address"
|
||||
msgstr "postel"
|
||||
|
||||
#: build/lib/core/models.py:1972 core/models.py:1972
|
||||
#: build/lib/core/models.py:1974 core/models.py:1974
|
||||
msgid "Document invitation"
|
||||
msgstr "Pedadenn d'ur restr"
|
||||
|
||||
#: build/lib/core/models.py:1973 core/models.py:1973
|
||||
#: build/lib/core/models.py:1975 core/models.py:1975
|
||||
msgid "Document invitations"
|
||||
msgstr "Pedadennoù d'ur restr"
|
||||
|
||||
#: build/lib/core/models.py:1993 core/models.py:1993
|
||||
#: build/lib/core/models.py:1995 core/models.py:1995
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Ar postel-mañ a zo liammet ouzh un implijer enskrivet."
|
||||
|
||||
#: build/lib/impress/settings.py:808 impress/settings.py:808
|
||||
#: build/lib/impress/settings.py:837 impress/settings.py:837
|
||||
msgid "Docs AI"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-04-02 09:37+0000\n"
|
||||
"PO-Revision-Date: 2026-04-08 13:28\n"
|
||||
"POT-Creation-Date: 2026-04-30 12:37+0000\n"
|
||||
"PO-Revision-Date: 2026-04-30 13:05\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: German\n"
|
||||
"Language: de_DE\n"
|
||||
@@ -17,28 +17,28 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:30 core/admin.py:30
|
||||
#: build/lib/core/admin.py:33 core/admin.py:33
|
||||
msgid "Personal info"
|
||||
msgstr "Persönliche Daten"
|
||||
|
||||
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
|
||||
#: core/admin.py:161
|
||||
#: build/lib/core/admin.py:46 build/lib/core/admin.py:166 core/admin.py:46
|
||||
#: core/admin.py:166
|
||||
msgid "Permissions"
|
||||
msgstr "Berechtigungen"
|
||||
|
||||
#: build/lib/core/admin.py:55 core/admin.py:55
|
||||
#: build/lib/core/admin.py:58 core/admin.py:58
|
||||
msgid "Important dates"
|
||||
msgstr "Wichtige Termine"
|
||||
|
||||
#: build/lib/core/admin.py:112 core/admin.py:112
|
||||
#: build/lib/core/admin.py:117 core/admin.py:117
|
||||
msgid "Import job created and queued."
|
||||
msgstr "Import-Job erstellt und in der Warteschlange."
|
||||
|
||||
#: build/lib/core/admin.py:116 core/admin.py:116
|
||||
#: build/lib/core/admin.py:121 core/admin.py:121
|
||||
msgid "Process selected user reconciliations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:171 core/admin.py:171
|
||||
#: build/lib/core/admin.py:176 core/admin.py:176
|
||||
msgid "Tree structure"
|
||||
msgstr "Baumstruktur"
|
||||
|
||||
@@ -62,24 +62,24 @@ msgstr "Maskiert"
|
||||
msgid "Favorite"
|
||||
msgstr "Favorit"
|
||||
|
||||
#: build/lib/core/api/serializers.py:544 core/api/serializers.py:544
|
||||
#: build/lib/core/api/serializers.py:507 core/api/serializers.py:507
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "Ein neues Dokument wurde in Ihrem Namen erstellt!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#: build/lib/core/api/serializers.py:511 core/api/serializers.py:511
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr "Sie sind Besitzer eines neuen Dokuments:"
|
||||
|
||||
#: build/lib/core/api/serializers.py:584 core/api/serializers.py:584
|
||||
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
|
||||
msgid "This field is required."
|
||||
msgstr "Dies ist ein Pflichtfeld."
|
||||
|
||||
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
|
||||
#: build/lib/core/api/serializers.py:558 core/api/serializers.py:558
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr "Der Zugriff auf den Link '%(link_reach)s' ist aufgrund der Konfiguration übergeordneter Dokumente nicht erlaubt."
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1312 core/api/viewsets.py:1312
|
||||
#: build/lib/core/api/viewsets.py:1299 core/api/viewsets.py:1299
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "Kopie von {title}"
|
||||
@@ -149,15 +149,15 @@ msgstr "Rechts"
|
||||
|
||||
#: build/lib/core/models.py:80 core/models.py:80
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
msgstr "ID"
|
||||
|
||||
#: build/lib/core/models.py:81 core/models.py:81
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "primärer Schlüssel für den Datensatz als UUID"
|
||||
msgstr "Primärschlüssel für den Datensatz als UUID"
|
||||
|
||||
#: build/lib/core/models.py:87 core/models.py:87
|
||||
msgid "created on"
|
||||
msgstr "Erstellt"
|
||||
msgstr "Erstellt am"
|
||||
|
||||
#: build/lib/core/models.py:88 core/models.py:88
|
||||
msgid "date and time at which a record was created"
|
||||
@@ -375,151 +375,151 @@ msgstr "Dokument"
|
||||
msgid "Documents"
|
||||
msgstr "Dokumente"
|
||||
|
||||
#: build/lib/core/models.py:940 build/lib/core/models.py:1345
|
||||
#: core/models.py:940 core/models.py:1345
|
||||
#: build/lib/core/models.py:940 build/lib/core/models.py:1347
|
||||
#: core/models.py:940 core/models.py:1347
|
||||
msgid "Untitled Document"
|
||||
msgstr "Unbenanntes Dokument"
|
||||
|
||||
#: build/lib/core/models.py:1346 core/models.py:1346
|
||||
#: build/lib/core/models.py:1348 core/models.py:1348
|
||||
msgid "Open"
|
||||
msgstr "Öffnen"
|
||||
|
||||
#: build/lib/core/models.py:1381 core/models.py:1381
|
||||
#: build/lib/core/models.py:1383 core/models.py:1383
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} hat ein Dokument mit Ihnen geteilt!"
|
||||
|
||||
#: build/lib/core/models.py:1385 core/models.py:1385
|
||||
#: build/lib/core/models.py:1387 core/models.py:1387
|
||||
#, 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:1391 core/models.py:1391
|
||||
#: build/lib/core/models.py:1393 core/models.py:1393
|
||||
#, 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:1492 core/models.py:1492
|
||||
#: build/lib/core/models.py:1494 core/models.py:1494
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Dokument/Benutzer Linkverfolgung"
|
||||
|
||||
#: build/lib/core/models.py:1493 core/models.py:1493
|
||||
#: build/lib/core/models.py:1495 core/models.py:1495
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Dokument/Benutzer Linkverfolgung"
|
||||
|
||||
#: build/lib/core/models.py:1499 core/models.py:1499
|
||||
#: build/lib/core/models.py:1501 core/models.py:1501
|
||||
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:1522 core/models.py:1522
|
||||
#: build/lib/core/models.py:1524 core/models.py:1524
|
||||
msgid "Document favorite"
|
||||
msgstr "Dokumentenfavorit"
|
||||
|
||||
#: build/lib/core/models.py:1523 core/models.py:1523
|
||||
#: build/lib/core/models.py:1525 core/models.py:1525
|
||||
msgid "Document favorites"
|
||||
msgstr "Dokumentfavoriten"
|
||||
|
||||
#: build/lib/core/models.py:1529 core/models.py:1529
|
||||
#: build/lib/core/models.py:1531 core/models.py:1531
|
||||
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:1551 core/models.py:1551
|
||||
#: build/lib/core/models.py:1553 core/models.py:1553
|
||||
msgid "Document/user relation"
|
||||
msgstr "Dokument/Benutzerbeziehung"
|
||||
|
||||
#: build/lib/core/models.py:1552 core/models.py:1552
|
||||
#: build/lib/core/models.py:1554 core/models.py:1554
|
||||
msgid "Document/user relations"
|
||||
msgstr "Dokument/Benutzerbeziehungen"
|
||||
|
||||
#: build/lib/core/models.py:1558 core/models.py:1558
|
||||
#: build/lib/core/models.py:1560 core/models.py:1560
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Dieser Benutzer befindet sich bereits in diesem Dokument."
|
||||
|
||||
#: build/lib/core/models.py:1564 core/models.py:1564
|
||||
#: build/lib/core/models.py:1566 core/models.py:1566
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Dieses Team befindet sich bereits in diesem Dokument."
|
||||
|
||||
#: build/lib/core/models.py:1570 core/models.py:1570
|
||||
#: build/lib/core/models.py:1572 core/models.py:1572
|
||||
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:1721 core/models.py:1721
|
||||
#: build/lib/core/models.py:1723 core/models.py:1723
|
||||
msgid "Document ask for access"
|
||||
msgstr "Dokument um Zugriff bitten"
|
||||
|
||||
#: build/lib/core/models.py:1722 core/models.py:1722
|
||||
#: build/lib/core/models.py:1724 core/models.py:1724
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "Dokumentenabfragen"
|
||||
|
||||
#: build/lib/core/models.py:1728 core/models.py:1728
|
||||
#: build/lib/core/models.py:1730 core/models.py:1730
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr "Dieser Benutzer hat bereits um Zugang zu diesem Dokument gebeten."
|
||||
|
||||
#: build/lib/core/models.py:1785 core/models.py:1785
|
||||
#: build/lib/core/models.py:1787 core/models.py:1787
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr "{name} möchte Zugriff auf ein Dokument erhalten!"
|
||||
|
||||
#: build/lib/core/models.py:1789 core/models.py:1789
|
||||
#: build/lib/core/models.py:1791 core/models.py:1791
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr "{name} möchte auf das folgende Dokument zugreifen:"
|
||||
|
||||
#: build/lib/core/models.py:1795 core/models.py:1795
|
||||
#: build/lib/core/models.py:1797 core/models.py:1797
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr "{name} bittet um Zugang zum Dokument: {title}"
|
||||
|
||||
#: build/lib/core/models.py:1837 core/models.py:1837
|
||||
#: build/lib/core/models.py:1839 core/models.py:1839
|
||||
msgid "Thread"
|
||||
msgstr "Thread"
|
||||
|
||||
#: build/lib/core/models.py:1838 core/models.py:1838
|
||||
#: build/lib/core/models.py:1840 core/models.py:1840
|
||||
msgid "Threads"
|
||||
msgstr "Threads"
|
||||
|
||||
#: build/lib/core/models.py:1841 build/lib/core/models.py:1893
|
||||
#: core/models.py:1841 core/models.py:1893
|
||||
#: build/lib/core/models.py:1843 build/lib/core/models.py:1895
|
||||
#: core/models.py:1843 core/models.py:1895
|
||||
msgid "Anonymous"
|
||||
msgstr "Gast"
|
||||
|
||||
#: build/lib/core/models.py:1888 core/models.py:1888
|
||||
#: build/lib/core/models.py:1890 core/models.py:1890
|
||||
msgid "Comment"
|
||||
msgstr "Kommentar"
|
||||
|
||||
#: build/lib/core/models.py:1889 core/models.py:1889
|
||||
#: build/lib/core/models.py:1891 core/models.py:1891
|
||||
msgid "Comments"
|
||||
msgstr "Kommentare"
|
||||
|
||||
#: build/lib/core/models.py:1938 core/models.py:1938
|
||||
#: build/lib/core/models.py:1940 core/models.py:1940
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1942 core/models.py:1942
|
||||
#: build/lib/core/models.py:1944 core/models.py:1944
|
||||
msgid "Reaction"
|
||||
msgstr "Reaktion"
|
||||
|
||||
#: build/lib/core/models.py:1943 core/models.py:1943
|
||||
#: build/lib/core/models.py:1945 core/models.py:1945
|
||||
msgid "Reactions"
|
||||
msgstr "Reaktionen"
|
||||
|
||||
#: build/lib/core/models.py:1953 core/models.py:1953
|
||||
#: build/lib/core/models.py:1955 core/models.py:1955
|
||||
msgid "email address"
|
||||
msgstr "E-Mail-Adresse"
|
||||
|
||||
#: build/lib/core/models.py:1972 core/models.py:1972
|
||||
#: build/lib/core/models.py:1974 core/models.py:1974
|
||||
msgid "Document invitation"
|
||||
msgstr "Einladung zum Dokument"
|
||||
|
||||
#: build/lib/core/models.py:1973 core/models.py:1973
|
||||
#: build/lib/core/models.py:1975 core/models.py:1975
|
||||
msgid "Document invitations"
|
||||
msgstr "Dokumenteinladungen"
|
||||
|
||||
#: build/lib/core/models.py:1993 core/models.py:1993
|
||||
#: build/lib/core/models.py:1995 core/models.py:1995
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Diese E-Mail ist bereits einem registrierten Benutzer zugeordnet."
|
||||
|
||||
#: build/lib/impress/settings.py:808 impress/settings.py:808
|
||||
#: build/lib/impress/settings.py:837 impress/settings.py:837
|
||||
msgid "Docs AI"
|
||||
msgstr "Docs AI"
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-04-02 09:37+0000\n"
|
||||
"PO-Revision-Date: 2026-04-08 13:28\n"
|
||||
"POT-Creation-Date: 2026-04-30 12:37+0000\n"
|
||||
"PO-Revision-Date: 2026-04-30 13:05\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Greek\n"
|
||||
"Language: el_GR\n"
|
||||
@@ -17,28 +17,28 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:30 core/admin.py:30
|
||||
#: build/lib/core/admin.py:33 core/admin.py:33
|
||||
msgid "Personal info"
|
||||
msgstr "Προσωπικές πληροφορίες"
|
||||
|
||||
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
|
||||
#: core/admin.py:161
|
||||
#: build/lib/core/admin.py:46 build/lib/core/admin.py:166 core/admin.py:46
|
||||
#: core/admin.py:166
|
||||
msgid "Permissions"
|
||||
msgstr "Δικαιώματα"
|
||||
|
||||
#: build/lib/core/admin.py:55 core/admin.py:55
|
||||
#: build/lib/core/admin.py:58 core/admin.py:58
|
||||
msgid "Important dates"
|
||||
msgstr "Σημαντικές ημερομηνίες"
|
||||
|
||||
#: build/lib/core/admin.py:112 core/admin.py:112
|
||||
#: build/lib/core/admin.py:117 core/admin.py:117
|
||||
msgid "Import job created and queued."
|
||||
msgstr "Η εργασία εισαγωγής δημιουργήθηκε και μπήκε στην ουρά."
|
||||
|
||||
#: build/lib/core/admin.py:116 core/admin.py:116
|
||||
#: build/lib/core/admin.py:121 core/admin.py:121
|
||||
msgid "Process selected user reconciliations"
|
||||
msgstr "Επεξεργασία επιλεγμένων συμφωνιών χρηστών"
|
||||
|
||||
#: build/lib/core/admin.py:171 core/admin.py:171
|
||||
#: build/lib/core/admin.py:176 core/admin.py:176
|
||||
msgid "Tree structure"
|
||||
msgstr "Δομή δέντρου"
|
||||
|
||||
@@ -62,24 +62,24 @@ msgstr "Με κάλυψη"
|
||||
msgid "Favorite"
|
||||
msgstr "Αγαπημένο"
|
||||
|
||||
#: build/lib/core/api/serializers.py:544 core/api/serializers.py:544
|
||||
#: build/lib/core/api/serializers.py:507 core/api/serializers.py:507
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "Ένα νέο έγγραφο δημιουργήθηκε εκ μέρους σας!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#: build/lib/core/api/serializers.py:511 core/api/serializers.py:511
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr "Σας παραχωρήθηκε η ιδιοκτησία ενός νέου εγγράφου:"
|
||||
|
||||
#: build/lib/core/api/serializers.py:584 core/api/serializers.py:584
|
||||
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
|
||||
msgid "This field is required."
|
||||
msgstr "Αυτό το πεδίο είναι υποχρεωτικό."
|
||||
|
||||
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
|
||||
#: build/lib/core/api/serializers.py:558 core/api/serializers.py:558
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr "Η εμβέλεια συνδέσμου '%(link_reach)s' δεν επιτρέπεται βάσει της διαμόρφωσης του γονικού εγγράφου."
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1312 core/api/viewsets.py:1312
|
||||
#: build/lib/core/api/viewsets.py:1299 core/api/viewsets.py:1299
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "αντίγραφο του {title}"
|
||||
@@ -382,151 +382,151 @@ msgstr "Έγγραφο"
|
||||
msgid "Documents"
|
||||
msgstr "Έγγραφα"
|
||||
|
||||
#: build/lib/core/models.py:940 build/lib/core/models.py:1345
|
||||
#: core/models.py:940 core/models.py:1345
|
||||
#: build/lib/core/models.py:940 build/lib/core/models.py:1347
|
||||
#: core/models.py:940 core/models.py:1347
|
||||
msgid "Untitled Document"
|
||||
msgstr "Έγγραφο χωρίς τίτλο"
|
||||
|
||||
#: build/lib/core/models.py:1346 core/models.py:1346
|
||||
#: build/lib/core/models.py:1348 core/models.py:1348
|
||||
msgid "Open"
|
||||
msgstr "Άνοιγμα"
|
||||
|
||||
#: build/lib/core/models.py:1381 core/models.py:1381
|
||||
#: build/lib/core/models.py:1383 core/models.py:1383
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "Ο/Η {name} μοιράστηκε ένα έγγραφο μαζί σας!"
|
||||
|
||||
#: build/lib/core/models.py:1385 core/models.py:1385
|
||||
#: build/lib/core/models.py:1387 core/models.py:1387
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "Ο/Η {name} σας προσκάλεσε με τον ρόλο \"{role}\" στο ακόλουθο έγγραφο:"
|
||||
|
||||
#: build/lib/core/models.py:1391 core/models.py:1391
|
||||
#: build/lib/core/models.py:1393 core/models.py:1393
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "Ο/Η {name} μοιράστηκε ένα έγγραφο μαζί σας: {title}"
|
||||
|
||||
#: build/lib/core/models.py:1492 core/models.py:1492
|
||||
#: build/lib/core/models.py:1494 core/models.py:1494
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Ίχνος συνδέσμου εγγράφου/χρήστη"
|
||||
|
||||
#: build/lib/core/models.py:1493 core/models.py:1493
|
||||
#: build/lib/core/models.py:1495 core/models.py:1495
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Ίχνη συνδέσμου εγγράφου/χρήστη"
|
||||
|
||||
#: build/lib/core/models.py:1499 core/models.py:1499
|
||||
#: build/lib/core/models.py:1501 core/models.py:1501
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "Ένα ίχνος συνδέσμου υπάρχει ήδη για αυτό το έγγραφο/χρήστη."
|
||||
|
||||
#: build/lib/core/models.py:1522 core/models.py:1522
|
||||
#: build/lib/core/models.py:1524 core/models.py:1524
|
||||
msgid "Document favorite"
|
||||
msgstr "Αγαπημένο έγγραφο"
|
||||
|
||||
#: build/lib/core/models.py:1523 core/models.py:1523
|
||||
#: build/lib/core/models.py:1525 core/models.py:1525
|
||||
msgid "Document favorites"
|
||||
msgstr "Αγαπημένα έγγραφα"
|
||||
|
||||
#: build/lib/core/models.py:1529 core/models.py:1529
|
||||
#: build/lib/core/models.py:1531 core/models.py:1531
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "Αυτό το έγγραφο στοχεύεται ήδη από μια σχέση αγαπημένου για τον ίδιο χρήστη."
|
||||
|
||||
#: build/lib/core/models.py:1551 core/models.py:1551
|
||||
#: build/lib/core/models.py:1553 core/models.py:1553
|
||||
msgid "Document/user relation"
|
||||
msgstr "Σχέση εγγράφου/χρήστη"
|
||||
|
||||
#: build/lib/core/models.py:1552 core/models.py:1552
|
||||
#: build/lib/core/models.py:1554 core/models.py:1554
|
||||
msgid "Document/user relations"
|
||||
msgstr "Σχέσεις εγγράφου/χρήστη"
|
||||
|
||||
#: build/lib/core/models.py:1558 core/models.py:1558
|
||||
#: build/lib/core/models.py:1560 core/models.py:1560
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Αυτός ο χρήστης συμμετέχει ήδη σε αυτό το έγγραφο."
|
||||
|
||||
#: build/lib/core/models.py:1564 core/models.py:1564
|
||||
#: build/lib/core/models.py:1566 core/models.py:1566
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Αυτή η ομάδα συμμετέχει ήδη σε αυτό το έγγραφο."
|
||||
|
||||
#: build/lib/core/models.py:1570 core/models.py:1570
|
||||
#: build/lib/core/models.py:1572 core/models.py:1572
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Πρέπει να οριστεί είτε χρήστης είτε ομάδα, όχι και τα δύο."
|
||||
|
||||
#: build/lib/core/models.py:1721 core/models.py:1721
|
||||
#: build/lib/core/models.py:1723 core/models.py:1723
|
||||
msgid "Document ask for access"
|
||||
msgstr "Αίτημα πρόσβασης σε έγγραφο"
|
||||
|
||||
#: build/lib/core/models.py:1722 core/models.py:1722
|
||||
#: build/lib/core/models.py:1724 core/models.py:1724
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "Αιτήματα πρόσβασης σε έγγραφα"
|
||||
|
||||
#: build/lib/core/models.py:1728 core/models.py:1728
|
||||
#: build/lib/core/models.py:1730 core/models.py:1730
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr "Αυτός ο χρήστης έχει ήδη ζητήσει πρόσβαση σε αυτό το έγγραφο."
|
||||
|
||||
#: build/lib/core/models.py:1785 core/models.py:1785
|
||||
#: build/lib/core/models.py:1787 core/models.py:1787
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr "Ο/Η {name} θα ήθελε πρόσβαση σε ένα έγγραφο!"
|
||||
|
||||
#: build/lib/core/models.py:1789 core/models.py:1789
|
||||
#: build/lib/core/models.py:1791 core/models.py:1791
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr "Ο/Η {name} θα ήθελε πρόσβαση στο ακόλουθο έγγραφο:"
|
||||
|
||||
#: build/lib/core/models.py:1795 core/models.py:1795
|
||||
#: build/lib/core/models.py:1797 core/models.py:1797
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr "Ο/Η {name} ζητά πρόσβαση στο έγγραφο: {title}"
|
||||
|
||||
#: build/lib/core/models.py:1837 core/models.py:1837
|
||||
#: build/lib/core/models.py:1839 core/models.py:1839
|
||||
msgid "Thread"
|
||||
msgstr "Νήμα"
|
||||
|
||||
#: build/lib/core/models.py:1838 core/models.py:1838
|
||||
#: build/lib/core/models.py:1840 core/models.py:1840
|
||||
msgid "Threads"
|
||||
msgstr "Νήματα"
|
||||
|
||||
#: build/lib/core/models.py:1841 build/lib/core/models.py:1893
|
||||
#: core/models.py:1841 core/models.py:1893
|
||||
#: build/lib/core/models.py:1843 build/lib/core/models.py:1895
|
||||
#: core/models.py:1843 core/models.py:1895
|
||||
msgid "Anonymous"
|
||||
msgstr "Ανώνυμος"
|
||||
|
||||
#: build/lib/core/models.py:1888 core/models.py:1888
|
||||
#: build/lib/core/models.py:1890 core/models.py:1890
|
||||
msgid "Comment"
|
||||
msgstr "Σχόλιο"
|
||||
|
||||
#: build/lib/core/models.py:1889 core/models.py:1889
|
||||
#: build/lib/core/models.py:1891 core/models.py:1891
|
||||
msgid "Comments"
|
||||
msgstr "Σχόλια"
|
||||
|
||||
#: build/lib/core/models.py:1938 core/models.py:1938
|
||||
#: build/lib/core/models.py:1940 core/models.py:1940
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr "Αυτό το emoji έχει χρησιμοποιηθεί ήδη ως αντίδραση σε αυτό το σχόλιο."
|
||||
|
||||
#: build/lib/core/models.py:1942 core/models.py:1942
|
||||
#: build/lib/core/models.py:1944 core/models.py:1944
|
||||
msgid "Reaction"
|
||||
msgstr "Αντίδραση"
|
||||
|
||||
#: build/lib/core/models.py:1943 core/models.py:1943
|
||||
#: build/lib/core/models.py:1945 core/models.py:1945
|
||||
msgid "Reactions"
|
||||
msgstr "Αντιδράσεις"
|
||||
|
||||
#: build/lib/core/models.py:1953 core/models.py:1953
|
||||
#: build/lib/core/models.py:1955 core/models.py:1955
|
||||
msgid "email address"
|
||||
msgstr "διεύθυνση email"
|
||||
|
||||
#: build/lib/core/models.py:1972 core/models.py:1972
|
||||
#: build/lib/core/models.py:1974 core/models.py:1974
|
||||
msgid "Document invitation"
|
||||
msgstr "Πρόσκληση σε έγγραφο"
|
||||
|
||||
#: build/lib/core/models.py:1973 core/models.py:1973
|
||||
#: build/lib/core/models.py:1975 core/models.py:1975
|
||||
msgid "Document invitations"
|
||||
msgstr "Προσκλήσεις εγγράφου"
|
||||
|
||||
#: build/lib/core/models.py:1993 core/models.py:1993
|
||||
#: build/lib/core/models.py:1995 core/models.py:1995
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Αυτό το email σχετίζεται ήδη με έναν εγγεγραμμένο χρήστη."
|
||||
|
||||
#: build/lib/impress/settings.py:808 impress/settings.py:808
|
||||
#: build/lib/impress/settings.py:837 impress/settings.py:837
|
||||
msgid "Docs AI"
|
||||
msgstr "Τεχνητή Νοημοσύνη (AI) Docs"
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-04-02 09:37+0000\n"
|
||||
"PO-Revision-Date: 2026-04-08 13:28\n"
|
||||
"POT-Creation-Date: 2026-04-30 12:37+0000\n"
|
||||
"PO-Revision-Date: 2026-04-30 13:05\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
"Language: en_US\n"
|
||||
@@ -17,28 +17,28 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:30 core/admin.py:30
|
||||
#: build/lib/core/admin.py:33 core/admin.py:33
|
||||
msgid "Personal info"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
|
||||
#: core/admin.py:161
|
||||
#: build/lib/core/admin.py:46 build/lib/core/admin.py:166 core/admin.py:46
|
||||
#: core/admin.py:166
|
||||
msgid "Permissions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:55 core/admin.py:55
|
||||
#: build/lib/core/admin.py:58 core/admin.py:58
|
||||
msgid "Important dates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:112 core/admin.py:112
|
||||
#: build/lib/core/admin.py:117 core/admin.py:117
|
||||
msgid "Import job created and queued."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:116 core/admin.py:116
|
||||
#: build/lib/core/admin.py:121 core/admin.py:121
|
||||
msgid "Process selected user reconciliations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:171 core/admin.py:171
|
||||
#: build/lib/core/admin.py:176 core/admin.py:176
|
||||
msgid "Tree structure"
|
||||
msgstr ""
|
||||
|
||||
@@ -62,24 +62,24 @@ msgstr ""
|
||||
msgid "Favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:544 core/api/serializers.py:544
|
||||
#: build/lib/core/api/serializers.py:507 core/api/serializers.py:507
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#: build/lib/core/api/serializers.py:511 core/api/serializers.py:511
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:584 core/api/serializers.py:584
|
||||
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
|
||||
msgid "This field is required."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
|
||||
#: build/lib/core/api/serializers.py:558 core/api/serializers.py:558
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1312 core/api/viewsets.py:1312
|
||||
#: build/lib/core/api/viewsets.py:1299 core/api/viewsets.py:1299
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr ""
|
||||
@@ -375,151 +375,151 @@ msgstr ""
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:940 build/lib/core/models.py:1345
|
||||
#: core/models.py:940 core/models.py:1345
|
||||
#: build/lib/core/models.py:940 build/lib/core/models.py:1347
|
||||
#: core/models.py:940 core/models.py:1347
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1346 core/models.py:1346
|
||||
#: build/lib/core/models.py:1348 core/models.py:1348
|
||||
msgid "Open"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1381 core/models.py:1381
|
||||
#: build/lib/core/models.py:1383 core/models.py:1383
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1385 core/models.py:1385
|
||||
#: build/lib/core/models.py:1387 core/models.py:1387
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1391 core/models.py:1391
|
||||
#: build/lib/core/models.py:1393 core/models.py:1393
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1492 core/models.py:1492
|
||||
#: build/lib/core/models.py:1494 core/models.py:1494
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1493 core/models.py:1493
|
||||
#: build/lib/core/models.py:1495 core/models.py:1495
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1499 core/models.py:1499
|
||||
#: build/lib/core/models.py:1501 core/models.py:1501
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1522 core/models.py:1522
|
||||
#: build/lib/core/models.py:1524 core/models.py:1524
|
||||
msgid "Document favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1523 core/models.py:1523
|
||||
#: build/lib/core/models.py:1525 core/models.py:1525
|
||||
msgid "Document favorites"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1529 core/models.py:1529
|
||||
#: build/lib/core/models.py:1531 core/models.py:1531
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1551 core/models.py:1551
|
||||
#: build/lib/core/models.py:1553 core/models.py:1553
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1552 core/models.py:1552
|
||||
#: build/lib/core/models.py:1554 core/models.py:1554
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1558 core/models.py:1558
|
||||
#: build/lib/core/models.py:1560 core/models.py:1560
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1564 core/models.py:1564
|
||||
#: build/lib/core/models.py:1566 core/models.py:1566
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1570 core/models.py:1570
|
||||
#: build/lib/core/models.py:1572 core/models.py:1572
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1721 core/models.py:1721
|
||||
#: build/lib/core/models.py:1723 core/models.py:1723
|
||||
msgid "Document ask for access"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1722 core/models.py:1722
|
||||
#: build/lib/core/models.py:1724 core/models.py:1724
|
||||
msgid "Document ask for accesses"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1728 core/models.py:1728
|
||||
#: build/lib/core/models.py:1730 core/models.py:1730
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1785 core/models.py:1785
|
||||
#: build/lib/core/models.py:1787 core/models.py:1787
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1789 core/models.py:1789
|
||||
#: build/lib/core/models.py:1791 core/models.py:1791
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1795 core/models.py:1795
|
||||
#: build/lib/core/models.py:1797 core/models.py:1797
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1837 core/models.py:1837
|
||||
#: build/lib/core/models.py:1839 core/models.py:1839
|
||||
msgid "Thread"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1838 core/models.py:1838
|
||||
#: build/lib/core/models.py:1840 core/models.py:1840
|
||||
msgid "Threads"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1841 build/lib/core/models.py:1893
|
||||
#: core/models.py:1841 core/models.py:1893
|
||||
#: build/lib/core/models.py:1843 build/lib/core/models.py:1895
|
||||
#: core/models.py:1843 core/models.py:1895
|
||||
msgid "Anonymous"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1888 core/models.py:1888
|
||||
#: build/lib/core/models.py:1890 core/models.py:1890
|
||||
msgid "Comment"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1889 core/models.py:1889
|
||||
#: build/lib/core/models.py:1891 core/models.py:1891
|
||||
msgid "Comments"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1938 core/models.py:1938
|
||||
#: build/lib/core/models.py:1940 core/models.py:1940
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1942 core/models.py:1942
|
||||
#: build/lib/core/models.py:1944 core/models.py:1944
|
||||
msgid "Reaction"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1943 core/models.py:1943
|
||||
#: build/lib/core/models.py:1945 core/models.py:1945
|
||||
msgid "Reactions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1953 core/models.py:1953
|
||||
#: build/lib/core/models.py:1955 core/models.py:1955
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1972 core/models.py:1972
|
||||
#: build/lib/core/models.py:1974 core/models.py:1974
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1973 core/models.py:1973
|
||||
#: build/lib/core/models.py:1975 core/models.py:1975
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1993 core/models.py:1993
|
||||
#: build/lib/core/models.py:1995 core/models.py:1995
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/impress/settings.py:808 impress/settings.py:808
|
||||
#: build/lib/impress/settings.py:837 impress/settings.py:837
|
||||
msgid "Docs AI"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-04-02 09:37+0000\n"
|
||||
"PO-Revision-Date: 2026-04-08 13:28\n"
|
||||
"POT-Creation-Date: 2026-04-30 12:37+0000\n"
|
||||
"PO-Revision-Date: 2026-04-30 13:05\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Spanish\n"
|
||||
"Language: es_ES\n"
|
||||
@@ -17,28 +17,28 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:30 core/admin.py:30
|
||||
#: build/lib/core/admin.py:33 core/admin.py:33
|
||||
msgid "Personal info"
|
||||
msgstr "Información Personal"
|
||||
|
||||
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
|
||||
#: core/admin.py:161
|
||||
#: build/lib/core/admin.py:46 build/lib/core/admin.py:166 core/admin.py:46
|
||||
#: core/admin.py:166
|
||||
msgid "Permissions"
|
||||
msgstr "Permisos"
|
||||
|
||||
#: build/lib/core/admin.py:55 core/admin.py:55
|
||||
#: build/lib/core/admin.py:58 core/admin.py:58
|
||||
msgid "Important dates"
|
||||
msgstr "Fechas importantes"
|
||||
|
||||
#: build/lib/core/admin.py:112 core/admin.py:112
|
||||
#: build/lib/core/admin.py:117 core/admin.py:117
|
||||
msgid "Import job created and queued."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:116 core/admin.py:116
|
||||
#: build/lib/core/admin.py:121 core/admin.py:121
|
||||
msgid "Process selected user reconciliations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:171 core/admin.py:171
|
||||
#: build/lib/core/admin.py:176 core/admin.py:176
|
||||
msgid "Tree structure"
|
||||
msgstr "Estructura en árbol"
|
||||
|
||||
@@ -62,24 +62,24 @@ msgstr "Enmascarado"
|
||||
msgid "Favorite"
|
||||
msgstr "Favorito"
|
||||
|
||||
#: build/lib/core/api/serializers.py:544 core/api/serializers.py:544
|
||||
#: build/lib/core/api/serializers.py:507 core/api/serializers.py:507
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "¡Un nuevo documento se ha creado por ti!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#: build/lib/core/api/serializers.py:511 core/api/serializers.py:511
|
||||
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:584 core/api/serializers.py:584
|
||||
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
|
||||
msgid "This field is required."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
|
||||
#: build/lib/core/api/serializers.py:558 core/api/serializers.py:558
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1312 core/api/viewsets.py:1312
|
||||
#: build/lib/core/api/viewsets.py:1299 core/api/viewsets.py:1299
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "copia de {title}"
|
||||
@@ -375,151 +375,151 @@ msgstr "Documento"
|
||||
msgid "Documents"
|
||||
msgstr "Documentos"
|
||||
|
||||
#: build/lib/core/models.py:940 build/lib/core/models.py:1345
|
||||
#: core/models.py:940 core/models.py:1345
|
||||
#: build/lib/core/models.py:940 build/lib/core/models.py:1347
|
||||
#: core/models.py:940 core/models.py:1347
|
||||
msgid "Untitled Document"
|
||||
msgstr "Documento sin título"
|
||||
|
||||
#: build/lib/core/models.py:1346 core/models.py:1346
|
||||
#: build/lib/core/models.py:1348 core/models.py:1348
|
||||
msgid "Open"
|
||||
msgstr "Abrir"
|
||||
|
||||
#: build/lib/core/models.py:1381 core/models.py:1381
|
||||
#: build/lib/core/models.py:1383 core/models.py:1383
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "¡{name} ha compartido un documento contigo!"
|
||||
|
||||
#: build/lib/core/models.py:1385 core/models.py:1385
|
||||
#: build/lib/core/models.py:1387 core/models.py:1387
|
||||
#, 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:1391 core/models.py:1391
|
||||
#: build/lib/core/models.py:1393 core/models.py:1393
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} ha compartido un documento contigo: {title}"
|
||||
|
||||
#: build/lib/core/models.py:1492 core/models.py:1492
|
||||
#: build/lib/core/models.py:1494 core/models.py:1494
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Traza del enlace de documento/usuario"
|
||||
|
||||
#: build/lib/core/models.py:1493 core/models.py:1493
|
||||
#: build/lib/core/models.py:1495 core/models.py:1495
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Trazas del enlace de documento/usuario"
|
||||
|
||||
#: build/lib/core/models.py:1499 core/models.py:1499
|
||||
#: build/lib/core/models.py:1501 core/models.py:1501
|
||||
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:1522 core/models.py:1522
|
||||
#: build/lib/core/models.py:1524 core/models.py:1524
|
||||
msgid "Document favorite"
|
||||
msgstr "Documento favorito"
|
||||
|
||||
#: build/lib/core/models.py:1523 core/models.py:1523
|
||||
#: build/lib/core/models.py:1525 core/models.py:1525
|
||||
msgid "Document favorites"
|
||||
msgstr "Documentos favoritos"
|
||||
|
||||
#: build/lib/core/models.py:1529 core/models.py:1529
|
||||
#: build/lib/core/models.py:1531 core/models.py:1531
|
||||
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:1551 core/models.py:1551
|
||||
#: build/lib/core/models.py:1553 core/models.py:1553
|
||||
msgid "Document/user relation"
|
||||
msgstr "Relación documento/usuario"
|
||||
|
||||
#: build/lib/core/models.py:1552 core/models.py:1552
|
||||
#: build/lib/core/models.py:1554 core/models.py:1554
|
||||
msgid "Document/user relations"
|
||||
msgstr "Relaciones documento/usuario"
|
||||
|
||||
#: build/lib/core/models.py:1558 core/models.py:1558
|
||||
#: build/lib/core/models.py:1560 core/models.py:1560
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Este usuario ya forma parte del documento."
|
||||
|
||||
#: build/lib/core/models.py:1564 core/models.py:1564
|
||||
#: build/lib/core/models.py:1566 core/models.py:1566
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Este equipo ya forma parte del documento."
|
||||
|
||||
#: build/lib/core/models.py:1570 core/models.py:1570
|
||||
#: build/lib/core/models.py:1572 core/models.py:1572
|
||||
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:1721 core/models.py:1721
|
||||
#: build/lib/core/models.py:1723 core/models.py:1723
|
||||
msgid "Document ask for access"
|
||||
msgstr "Solicitud de acceso"
|
||||
|
||||
#: build/lib/core/models.py:1722 core/models.py:1722
|
||||
#: build/lib/core/models.py:1724 core/models.py:1724
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "Solicitud de accesos"
|
||||
|
||||
#: build/lib/core/models.py:1728 core/models.py:1728
|
||||
#: build/lib/core/models.py:1730 core/models.py:1730
|
||||
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:1785 core/models.py:1785
|
||||
#: build/lib/core/models.py:1787 core/models.py:1787
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr "¡{name} desea acceder a un documento!"
|
||||
|
||||
#: build/lib/core/models.py:1789 core/models.py:1789
|
||||
#: build/lib/core/models.py:1791 core/models.py:1791
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr "{name} desea acceso al siguiente documento:"
|
||||
|
||||
#: build/lib/core/models.py:1795 core/models.py:1795
|
||||
#: build/lib/core/models.py:1797 core/models.py:1797
|
||||
#, 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:1837 core/models.py:1837
|
||||
#: build/lib/core/models.py:1839 core/models.py:1839
|
||||
msgid "Thread"
|
||||
msgstr "Thread"
|
||||
|
||||
#: build/lib/core/models.py:1838 core/models.py:1838
|
||||
#: build/lib/core/models.py:1840 core/models.py:1840
|
||||
msgid "Threads"
|
||||
msgstr "Threads"
|
||||
|
||||
#: build/lib/core/models.py:1841 build/lib/core/models.py:1893
|
||||
#: core/models.py:1841 core/models.py:1893
|
||||
#: build/lib/core/models.py:1843 build/lib/core/models.py:1895
|
||||
#: core/models.py:1843 core/models.py:1895
|
||||
msgid "Anonymous"
|
||||
msgstr "Anónimo"
|
||||
|
||||
#: build/lib/core/models.py:1888 core/models.py:1888
|
||||
#: build/lib/core/models.py:1890 core/models.py:1890
|
||||
msgid "Comment"
|
||||
msgstr "Comentario"
|
||||
|
||||
#: build/lib/core/models.py:1889 core/models.py:1889
|
||||
#: build/lib/core/models.py:1891 core/models.py:1891
|
||||
msgid "Comments"
|
||||
msgstr "Comentarios"
|
||||
|
||||
#: build/lib/core/models.py:1938 core/models.py:1938
|
||||
#: build/lib/core/models.py:1940 core/models.py:1940
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1942 core/models.py:1942
|
||||
#: build/lib/core/models.py:1944 core/models.py:1944
|
||||
msgid "Reaction"
|
||||
msgstr "Reacción"
|
||||
|
||||
#: build/lib/core/models.py:1943 core/models.py:1943
|
||||
#: build/lib/core/models.py:1945 core/models.py:1945
|
||||
msgid "Reactions"
|
||||
msgstr "Reacciones"
|
||||
|
||||
#: build/lib/core/models.py:1953 core/models.py:1953
|
||||
#: build/lib/core/models.py:1955 core/models.py:1955
|
||||
msgid "email address"
|
||||
msgstr "dirección de correo electrónico"
|
||||
|
||||
#: build/lib/core/models.py:1972 core/models.py:1972
|
||||
#: build/lib/core/models.py:1974 core/models.py:1974
|
||||
msgid "Document invitation"
|
||||
msgstr "Invitación al documento"
|
||||
|
||||
#: build/lib/core/models.py:1973 core/models.py:1973
|
||||
#: build/lib/core/models.py:1975 core/models.py:1975
|
||||
msgid "Document invitations"
|
||||
msgstr "Invitaciones a documentos"
|
||||
|
||||
#: build/lib/core/models.py:1993 core/models.py:1993
|
||||
#: build/lib/core/models.py:1995 core/models.py:1995
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Este correo electrónico está asociado a un usuario registrado."
|
||||
|
||||
#: build/lib/impress/settings.py:808 impress/settings.py:808
|
||||
#: build/lib/impress/settings.py:837 impress/settings.py:837
|
||||
msgid "Docs AI"
|
||||
msgstr "Docs AI"
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-04-02 09:37+0000\n"
|
||||
"PO-Revision-Date: 2026-04-08 13:28\n"
|
||||
"POT-Creation-Date: 2026-04-30 12:37+0000\n"
|
||||
"PO-Revision-Date: 2026-04-30 13:05\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: French\n"
|
||||
"Language: fr_FR\n"
|
||||
@@ -17,28 +17,28 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:30 core/admin.py:30
|
||||
#: build/lib/core/admin.py:33 core/admin.py:33
|
||||
msgid "Personal info"
|
||||
msgstr "Infos Personnelles"
|
||||
|
||||
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
|
||||
#: core/admin.py:161
|
||||
#: build/lib/core/admin.py:46 build/lib/core/admin.py:166 core/admin.py:46
|
||||
#: core/admin.py:166
|
||||
msgid "Permissions"
|
||||
msgstr "Permissions"
|
||||
|
||||
#: build/lib/core/admin.py:55 core/admin.py:55
|
||||
#: build/lib/core/admin.py:58 core/admin.py:58
|
||||
msgid "Important dates"
|
||||
msgstr "Dates importantes"
|
||||
|
||||
#: build/lib/core/admin.py:112 core/admin.py:112
|
||||
#: build/lib/core/admin.py:117 core/admin.py:117
|
||||
msgid "Import job created and queued."
|
||||
msgstr "Tâche d'importation créée et mise en file d'attente."
|
||||
|
||||
#: build/lib/core/admin.py:116 core/admin.py:116
|
||||
#: build/lib/core/admin.py:121 core/admin.py:121
|
||||
msgid "Process selected user reconciliations"
|
||||
msgstr "Traiter les rapprochements de l'utilisateur sélectionné"
|
||||
|
||||
#: build/lib/core/admin.py:171 core/admin.py:171
|
||||
#: build/lib/core/admin.py:176 core/admin.py:176
|
||||
msgid "Tree structure"
|
||||
msgstr "Arborescence"
|
||||
|
||||
@@ -62,24 +62,24 @@ msgstr "Masqué"
|
||||
msgid "Favorite"
|
||||
msgstr "Favoris"
|
||||
|
||||
#: build/lib/core/api/serializers.py:544 core/api/serializers.py:544
|
||||
#: build/lib/core/api/serializers.py:507 core/api/serializers.py:507
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "Un nouveau document a été créé pour vous !"
|
||||
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#: build/lib/core/api/serializers.py:511 core/api/serializers.py:511
|
||||
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:584 core/api/serializers.py:584
|
||||
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
|
||||
msgid "This field is required."
|
||||
msgstr "Ce champ est obligatoire."
|
||||
|
||||
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
|
||||
#: build/lib/core/api/serializers.py:558 core/api/serializers.py:558
|
||||
#, 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/viewsets.py:1312 core/api/viewsets.py:1312
|
||||
#: build/lib/core/api/viewsets.py:1299 core/api/viewsets.py:1299
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "copie de {title}"
|
||||
@@ -382,151 +382,151 @@ msgstr "Document"
|
||||
msgid "Documents"
|
||||
msgstr "Documents"
|
||||
|
||||
#: build/lib/core/models.py:940 build/lib/core/models.py:1345
|
||||
#: core/models.py:940 core/models.py:1345
|
||||
#: build/lib/core/models.py:940 build/lib/core/models.py:1347
|
||||
#: core/models.py:940 core/models.py:1347
|
||||
msgid "Untitled Document"
|
||||
msgstr "Document sans titre"
|
||||
|
||||
#: build/lib/core/models.py:1346 core/models.py:1346
|
||||
#: build/lib/core/models.py:1348 core/models.py:1348
|
||||
msgid "Open"
|
||||
msgstr "Ouvrir"
|
||||
|
||||
#: build/lib/core/models.py:1381 core/models.py:1381
|
||||
#: build/lib/core/models.py:1383 core/models.py:1383
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} a partagé un document avec vous!"
|
||||
|
||||
#: build/lib/core/models.py:1385 core/models.py:1385
|
||||
#: build/lib/core/models.py:1387 core/models.py:1387
|
||||
#, 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:1391 core/models.py:1391
|
||||
#: build/lib/core/models.py:1393 core/models.py:1393
|
||||
#, 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:1492 core/models.py:1492
|
||||
#: build/lib/core/models.py:1494 core/models.py:1494
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Trace du lien document/utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:1493 core/models.py:1493
|
||||
#: build/lib/core/models.py:1495 core/models.py:1495
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Traces du lien document/utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:1499 core/models.py:1499
|
||||
#: build/lib/core/models.py:1501 core/models.py:1501
|
||||
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:1522 core/models.py:1522
|
||||
#: build/lib/core/models.py:1524 core/models.py:1524
|
||||
msgid "Document favorite"
|
||||
msgstr "Document favori"
|
||||
|
||||
#: build/lib/core/models.py:1523 core/models.py:1523
|
||||
#: build/lib/core/models.py:1525 core/models.py:1525
|
||||
msgid "Document favorites"
|
||||
msgstr "Documents favoris"
|
||||
|
||||
#: build/lib/core/models.py:1529 core/models.py:1529
|
||||
#: build/lib/core/models.py:1531 core/models.py:1531
|
||||
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:1551 core/models.py:1551
|
||||
#: build/lib/core/models.py:1553 core/models.py:1553
|
||||
msgid "Document/user relation"
|
||||
msgstr "Relation document/utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:1552 core/models.py:1552
|
||||
#: build/lib/core/models.py:1554 core/models.py:1554
|
||||
msgid "Document/user relations"
|
||||
msgstr "Relations document/utilisateur"
|
||||
|
||||
#: build/lib/core/models.py:1558 core/models.py:1558
|
||||
#: build/lib/core/models.py:1560 core/models.py:1560
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Cet utilisateur est déjà dans ce document."
|
||||
|
||||
#: build/lib/core/models.py:1564 core/models.py:1564
|
||||
#: build/lib/core/models.py:1566 core/models.py:1566
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Cette équipe est déjà dans ce document."
|
||||
|
||||
#: build/lib/core/models.py:1570 core/models.py:1570
|
||||
#: build/lib/core/models.py:1572 core/models.py:1572
|
||||
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:1721 core/models.py:1721
|
||||
#: build/lib/core/models.py:1723 core/models.py:1723
|
||||
msgid "Document ask for access"
|
||||
msgstr "Demande d'accès au document"
|
||||
|
||||
#: build/lib/core/models.py:1722 core/models.py:1722
|
||||
#: build/lib/core/models.py:1724 core/models.py:1724
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "Demande d'accès au document"
|
||||
|
||||
#: build/lib/core/models.py:1728 core/models.py:1728
|
||||
#: build/lib/core/models.py:1730 core/models.py:1730
|
||||
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:1785 core/models.py:1785
|
||||
#: build/lib/core/models.py:1787 core/models.py:1787
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr "{name} souhaiterait accéder au document suivant !"
|
||||
|
||||
#: build/lib/core/models.py:1789 core/models.py:1789
|
||||
#: build/lib/core/models.py:1791 core/models.py:1791
|
||||
#, 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:1795 core/models.py:1795
|
||||
#: build/lib/core/models.py:1797 core/models.py:1797
|
||||
#, 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:1837 core/models.py:1837
|
||||
#: build/lib/core/models.py:1839 core/models.py:1839
|
||||
msgid "Thread"
|
||||
msgstr "Conversation"
|
||||
|
||||
#: build/lib/core/models.py:1838 core/models.py:1838
|
||||
#: build/lib/core/models.py:1840 core/models.py:1840
|
||||
msgid "Threads"
|
||||
msgstr "Conversations"
|
||||
|
||||
#: build/lib/core/models.py:1841 build/lib/core/models.py:1893
|
||||
#: core/models.py:1841 core/models.py:1893
|
||||
#: build/lib/core/models.py:1843 build/lib/core/models.py:1895
|
||||
#: core/models.py:1843 core/models.py:1895
|
||||
msgid "Anonymous"
|
||||
msgstr "Anonyme"
|
||||
|
||||
#: build/lib/core/models.py:1888 core/models.py:1888
|
||||
#: build/lib/core/models.py:1890 core/models.py:1890
|
||||
msgid "Comment"
|
||||
msgstr "Commentaire"
|
||||
|
||||
#: build/lib/core/models.py:1889 core/models.py:1889
|
||||
#: build/lib/core/models.py:1891 core/models.py:1891
|
||||
msgid "Comments"
|
||||
msgstr "Commentaires"
|
||||
|
||||
#: build/lib/core/models.py:1938 core/models.py:1938
|
||||
#: build/lib/core/models.py:1940 core/models.py:1940
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr "Cet émoji a déjà été réagi à ce commentaire."
|
||||
|
||||
#: build/lib/core/models.py:1942 core/models.py:1942
|
||||
#: build/lib/core/models.py:1944 core/models.py:1944
|
||||
msgid "Reaction"
|
||||
msgstr "Réaction"
|
||||
|
||||
#: build/lib/core/models.py:1943 core/models.py:1943
|
||||
#: build/lib/core/models.py:1945 core/models.py:1945
|
||||
msgid "Reactions"
|
||||
msgstr "Réactions"
|
||||
|
||||
#: build/lib/core/models.py:1953 core/models.py:1953
|
||||
#: build/lib/core/models.py:1955 core/models.py:1955
|
||||
msgid "email address"
|
||||
msgstr "adresse e-mail"
|
||||
|
||||
#: build/lib/core/models.py:1972 core/models.py:1972
|
||||
#: build/lib/core/models.py:1974 core/models.py:1974
|
||||
msgid "Document invitation"
|
||||
msgstr "Invitation à un document"
|
||||
|
||||
#: build/lib/core/models.py:1973 core/models.py:1973
|
||||
#: build/lib/core/models.py:1975 core/models.py:1975
|
||||
msgid "Document invitations"
|
||||
msgstr "Invitations à un document"
|
||||
|
||||
#: build/lib/core/models.py:1993 core/models.py:1993
|
||||
#: build/lib/core/models.py:1995 core/models.py:1995
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Cette adresse email est déjà associée à un utilisateur inscrit."
|
||||
|
||||
#: build/lib/impress/settings.py:808 impress/settings.py:808
|
||||
#: build/lib/impress/settings.py:837 impress/settings.py:837
|
||||
msgid "Docs AI"
|
||||
msgstr "Docs IA"
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-04-02 09:37+0000\n"
|
||||
"PO-Revision-Date: 2026-04-08 13:28\n"
|
||||
"POT-Creation-Date: 2026-04-30 12:37+0000\n"
|
||||
"PO-Revision-Date: 2026-04-30 13:05\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Italian\n"
|
||||
"Language: it_IT\n"
|
||||
@@ -17,28 +17,28 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:30 core/admin.py:30
|
||||
#: build/lib/core/admin.py:33 core/admin.py:33
|
||||
msgid "Personal info"
|
||||
msgstr "Informazioni personali"
|
||||
|
||||
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
|
||||
#: core/admin.py:161
|
||||
#: build/lib/core/admin.py:46 build/lib/core/admin.py:166 core/admin.py:46
|
||||
#: core/admin.py:166
|
||||
msgid "Permissions"
|
||||
msgstr "Permessi"
|
||||
|
||||
#: build/lib/core/admin.py:55 core/admin.py:55
|
||||
#: build/lib/core/admin.py:58 core/admin.py:58
|
||||
msgid "Important dates"
|
||||
msgstr "Date importanti"
|
||||
|
||||
#: build/lib/core/admin.py:112 core/admin.py:112
|
||||
#: build/lib/core/admin.py:117 core/admin.py:117
|
||||
msgid "Import job created and queued."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:116 core/admin.py:116
|
||||
#: build/lib/core/admin.py:121 core/admin.py:121
|
||||
msgid "Process selected user reconciliations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:171 core/admin.py:171
|
||||
#: build/lib/core/admin.py:176 core/admin.py:176
|
||||
msgid "Tree structure"
|
||||
msgstr "Struttura ad albero"
|
||||
|
||||
@@ -62,24 +62,24 @@ msgstr ""
|
||||
msgid "Favorite"
|
||||
msgstr "Preferiti"
|
||||
|
||||
#: build/lib/core/api/serializers.py:544 core/api/serializers.py:544
|
||||
#: build/lib/core/api/serializers.py:507 core/api/serializers.py:507
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "Un nuovo documento è stato creato a tuo nome!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#: build/lib/core/api/serializers.py:511 core/api/serializers.py:511
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr "Sei ora proprietario di un nuovo documento:"
|
||||
|
||||
#: build/lib/core/api/serializers.py:584 core/api/serializers.py:584
|
||||
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
|
||||
msgid "This field is required."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
|
||||
#: build/lib/core/api/serializers.py:558 core/api/serializers.py:558
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1312 core/api/viewsets.py:1312
|
||||
#: build/lib/core/api/viewsets.py:1299 core/api/viewsets.py:1299
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "copia di {title}"
|
||||
@@ -375,151 +375,151 @@ msgstr "Documento"
|
||||
msgid "Documents"
|
||||
msgstr "Documenti"
|
||||
|
||||
#: build/lib/core/models.py:940 build/lib/core/models.py:1345
|
||||
#: core/models.py:940 core/models.py:1345
|
||||
#: build/lib/core/models.py:940 build/lib/core/models.py:1347
|
||||
#: core/models.py:940 core/models.py:1347
|
||||
msgid "Untitled Document"
|
||||
msgstr "Documento senza titolo"
|
||||
|
||||
#: build/lib/core/models.py:1346 core/models.py:1346
|
||||
#: build/lib/core/models.py:1348 core/models.py:1348
|
||||
msgid "Open"
|
||||
msgstr "Apri"
|
||||
|
||||
#: build/lib/core/models.py:1381 core/models.py:1381
|
||||
#: build/lib/core/models.py:1383 core/models.py:1383
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} ha condiviso un documento con te!"
|
||||
|
||||
#: build/lib/core/models.py:1385 core/models.py:1385
|
||||
#: build/lib/core/models.py:1387 core/models.py:1387
|
||||
#, 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:1391 core/models.py:1391
|
||||
#: build/lib/core/models.py:1393 core/models.py:1393
|
||||
#, 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:1492 core/models.py:1492
|
||||
#: build/lib/core/models.py:1494 core/models.py:1494
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1493 core/models.py:1493
|
||||
#: build/lib/core/models.py:1495 core/models.py:1495
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1499 core/models.py:1499
|
||||
#: build/lib/core/models.py:1501 core/models.py:1501
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1522 core/models.py:1522
|
||||
#: build/lib/core/models.py:1524 core/models.py:1524
|
||||
msgid "Document favorite"
|
||||
msgstr "Documento preferito"
|
||||
|
||||
#: build/lib/core/models.py:1523 core/models.py:1523
|
||||
#: build/lib/core/models.py:1525 core/models.py:1525
|
||||
msgid "Document favorites"
|
||||
msgstr "Documenti preferiti"
|
||||
|
||||
#: build/lib/core/models.py:1529 core/models.py:1529
|
||||
#: build/lib/core/models.py:1531 core/models.py:1531
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1551 core/models.py:1551
|
||||
#: build/lib/core/models.py:1553 core/models.py:1553
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1552 core/models.py:1552
|
||||
#: build/lib/core/models.py:1554 core/models.py:1554
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1558 core/models.py:1558
|
||||
#: build/lib/core/models.py:1560 core/models.py:1560
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Questo utente è già presente in questo documento."
|
||||
|
||||
#: build/lib/core/models.py:1564 core/models.py:1564
|
||||
#: build/lib/core/models.py:1566 core/models.py:1566
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Questo team è già presente in questo documento."
|
||||
|
||||
#: build/lib/core/models.py:1570 core/models.py:1570
|
||||
#: build/lib/core/models.py:1572 core/models.py:1572
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1721 core/models.py:1721
|
||||
#: build/lib/core/models.py:1723 core/models.py:1723
|
||||
msgid "Document ask for access"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1722 core/models.py:1722
|
||||
#: build/lib/core/models.py:1724 core/models.py:1724
|
||||
msgid "Document ask for accesses"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1728 core/models.py:1728
|
||||
#: build/lib/core/models.py:1730 core/models.py:1730
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1785 core/models.py:1785
|
||||
#: build/lib/core/models.py:1787 core/models.py:1787
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1789 core/models.py:1789
|
||||
#: build/lib/core/models.py:1791 core/models.py:1791
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1795 core/models.py:1795
|
||||
#: build/lib/core/models.py:1797 core/models.py:1797
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1837 core/models.py:1837
|
||||
#: build/lib/core/models.py:1839 core/models.py:1839
|
||||
msgid "Thread"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1838 core/models.py:1838
|
||||
#: build/lib/core/models.py:1840 core/models.py:1840
|
||||
msgid "Threads"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1841 build/lib/core/models.py:1893
|
||||
#: core/models.py:1841 core/models.py:1893
|
||||
#: build/lib/core/models.py:1843 build/lib/core/models.py:1895
|
||||
#: core/models.py:1843 core/models.py:1895
|
||||
msgid "Anonymous"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1888 core/models.py:1888
|
||||
#: build/lib/core/models.py:1890 core/models.py:1890
|
||||
msgid "Comment"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1889 core/models.py:1889
|
||||
#: build/lib/core/models.py:1891 core/models.py:1891
|
||||
msgid "Comments"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1938 core/models.py:1938
|
||||
#: build/lib/core/models.py:1940 core/models.py:1940
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1942 core/models.py:1942
|
||||
#: build/lib/core/models.py:1944 core/models.py:1944
|
||||
msgid "Reaction"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1943 core/models.py:1943
|
||||
#: build/lib/core/models.py:1945 core/models.py:1945
|
||||
msgid "Reactions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1953 core/models.py:1953
|
||||
#: build/lib/core/models.py:1955 core/models.py:1955
|
||||
msgid "email address"
|
||||
msgstr "indirizzo e-mail"
|
||||
|
||||
#: build/lib/core/models.py:1972 core/models.py:1972
|
||||
#: build/lib/core/models.py:1974 core/models.py:1974
|
||||
msgid "Document invitation"
|
||||
msgstr "Invito al documento"
|
||||
|
||||
#: build/lib/core/models.py:1973 core/models.py:1973
|
||||
#: build/lib/core/models.py:1975 core/models.py:1975
|
||||
msgid "Document invitations"
|
||||
msgstr "Inviti al documento"
|
||||
|
||||
#: build/lib/core/models.py:1993 core/models.py:1993
|
||||
#: build/lib/core/models.py:1995 core/models.py:1995
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Questa email è già associata a un utente registrato."
|
||||
|
||||
#: build/lib/impress/settings.py:808 impress/settings.py:808
|
||||
#: build/lib/impress/settings.py:837 impress/settings.py:837
|
||||
msgid "Docs AI"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-04-02 09:37+0000\n"
|
||||
"PO-Revision-Date: 2026-04-08 13:28\n"
|
||||
"POT-Creation-Date: 2026-04-30 12:37+0000\n"
|
||||
"PO-Revision-Date: 2026-04-30 13:05\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Dutch\n"
|
||||
"Language: nl_NL\n"
|
||||
@@ -17,28 +17,28 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:30 core/admin.py:30
|
||||
#: build/lib/core/admin.py:33 core/admin.py:33
|
||||
msgid "Personal info"
|
||||
msgstr "Persoonlijke informatie"
|
||||
|
||||
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
|
||||
#: core/admin.py:161
|
||||
#: build/lib/core/admin.py:46 build/lib/core/admin.py:166 core/admin.py:46
|
||||
#: core/admin.py:166
|
||||
msgid "Permissions"
|
||||
msgstr "Machtigingen"
|
||||
|
||||
#: build/lib/core/admin.py:55 core/admin.py:55
|
||||
#: build/lib/core/admin.py:58 core/admin.py:58
|
||||
msgid "Important dates"
|
||||
msgstr "Belangrijke data"
|
||||
|
||||
#: build/lib/core/admin.py:112 core/admin.py:112
|
||||
#: build/lib/core/admin.py:117 core/admin.py:117
|
||||
msgid "Import job created and queued."
|
||||
msgstr "Import taak gemaakt en in de wachtrij geplaatst."
|
||||
|
||||
#: build/lib/core/admin.py:116 core/admin.py:116
|
||||
#: build/lib/core/admin.py:121 core/admin.py:121
|
||||
msgid "Process selected user reconciliations"
|
||||
msgstr "Verwerk geselecteerde gebruikers samenvoeging"
|
||||
|
||||
#: build/lib/core/admin.py:171 core/admin.py:171
|
||||
#: build/lib/core/admin.py:176 core/admin.py:176
|
||||
msgid "Tree structure"
|
||||
msgstr "Boomstructuur"
|
||||
|
||||
@@ -62,24 +62,24 @@ msgstr "Gemaskeerd"
|
||||
msgid "Favorite"
|
||||
msgstr "Favoriet"
|
||||
|
||||
#: build/lib/core/api/serializers.py:544 core/api/serializers.py:544
|
||||
#: build/lib/core/api/serializers.py:507 core/api/serializers.py:507
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "Een nieuw document is namens u gemaakt!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#: build/lib/core/api/serializers.py:511 core/api/serializers.py:511
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr "U heeft eigenaarschap van een nieuw document gekregen:"
|
||||
|
||||
#: build/lib/core/api/serializers.py:584 core/api/serializers.py:584
|
||||
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
|
||||
msgid "This field is required."
|
||||
msgstr "Dit veld is verplicht."
|
||||
|
||||
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
|
||||
#: build/lib/core/api/serializers.py:558 core/api/serializers.py:558
|
||||
#, 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/viewsets.py:1312 core/api/viewsets.py:1312
|
||||
#: build/lib/core/api/viewsets.py:1299 core/api/viewsets.py:1299
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "kopie van {title}"
|
||||
@@ -382,151 +382,151 @@ msgstr "Document"
|
||||
msgid "Documents"
|
||||
msgstr "Documenten"
|
||||
|
||||
#: build/lib/core/models.py:940 build/lib/core/models.py:1345
|
||||
#: core/models.py:940 core/models.py:1345
|
||||
#: build/lib/core/models.py:940 build/lib/core/models.py:1347
|
||||
#: core/models.py:940 core/models.py:1347
|
||||
msgid "Untitled Document"
|
||||
msgstr "Naamloos Document"
|
||||
|
||||
#: build/lib/core/models.py:1346 core/models.py:1346
|
||||
#: build/lib/core/models.py:1348 core/models.py:1348
|
||||
msgid "Open"
|
||||
msgstr "Open"
|
||||
|
||||
#: build/lib/core/models.py:1381 core/models.py:1381
|
||||
#: build/lib/core/models.py:1383 core/models.py:1383
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} heeft een document met u gedeeld!"
|
||||
|
||||
#: build/lib/core/models.py:1385 core/models.py:1385
|
||||
#: build/lib/core/models.py:1387 core/models.py:1387
|
||||
#, 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:1391 core/models.py:1391
|
||||
#: build/lib/core/models.py:1393 core/models.py:1393
|
||||
#, 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:1492 core/models.py:1492
|
||||
#: build/lib/core/models.py:1494 core/models.py:1494
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Document/gebruiker link"
|
||||
|
||||
#: build/lib/core/models.py:1493 core/models.py:1493
|
||||
#: build/lib/core/models.py:1495 core/models.py:1495
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Document/gebruiker link"
|
||||
|
||||
#: build/lib/core/models.py:1499 core/models.py:1499
|
||||
#: build/lib/core/models.py:1501 core/models.py:1501
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "Een link bestaat al voor dit document/deze gebruiker."
|
||||
|
||||
#: build/lib/core/models.py:1522 core/models.py:1522
|
||||
#: build/lib/core/models.py:1524 core/models.py:1524
|
||||
msgid "Document favorite"
|
||||
msgstr "Document favoriet"
|
||||
|
||||
#: build/lib/core/models.py:1523 core/models.py:1523
|
||||
#: build/lib/core/models.py:1525 core/models.py:1525
|
||||
msgid "Document favorites"
|
||||
msgstr "Document favorieten"
|
||||
|
||||
#: build/lib/core/models.py:1529 core/models.py:1529
|
||||
#: build/lib/core/models.py:1531 core/models.py:1531
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "Dit document is al in gebruik als favoriet door dezelfde gebruiker."
|
||||
|
||||
#: build/lib/core/models.py:1551 core/models.py:1551
|
||||
#: build/lib/core/models.py:1553 core/models.py:1553
|
||||
msgid "Document/user relation"
|
||||
msgstr "Document/gebruiker relatie"
|
||||
|
||||
#: build/lib/core/models.py:1552 core/models.py:1552
|
||||
#: build/lib/core/models.py:1554 core/models.py:1554
|
||||
msgid "Document/user relations"
|
||||
msgstr "Document/gebruiker relaties"
|
||||
|
||||
#: build/lib/core/models.py:1558 core/models.py:1558
|
||||
#: build/lib/core/models.py:1560 core/models.py:1560
|
||||
msgid "This user is already in this document."
|
||||
msgstr "De gebruiker bestaat al in dit document."
|
||||
|
||||
#: build/lib/core/models.py:1564 core/models.py:1564
|
||||
#: build/lib/core/models.py:1566 core/models.py:1566
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Dit team bestaat al in dit document."
|
||||
|
||||
#: build/lib/core/models.py:1570 core/models.py:1570
|
||||
#: build/lib/core/models.py:1572 core/models.py:1572
|
||||
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:1721 core/models.py:1721
|
||||
#: build/lib/core/models.py:1723 core/models.py:1723
|
||||
msgid "Document ask for access"
|
||||
msgstr "Document verzoekt om toegang"
|
||||
|
||||
#: build/lib/core/models.py:1722 core/models.py:1722
|
||||
#: build/lib/core/models.py:1724 core/models.py:1724
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "Document verzoekt om toegangen"
|
||||
|
||||
#: build/lib/core/models.py:1728 core/models.py:1728
|
||||
#: build/lib/core/models.py:1730 core/models.py:1730
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr "Deze gebruiker heeft al om toegang tot dit document gevraagd."
|
||||
|
||||
#: build/lib/core/models.py:1785 core/models.py:1785
|
||||
#: build/lib/core/models.py:1787 core/models.py:1787
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr "{name} verzoekt toegang tot een document!"
|
||||
|
||||
#: build/lib/core/models.py:1789 core/models.py:1789
|
||||
#: build/lib/core/models.py:1791 core/models.py:1791
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr "{name} verzoekt toegang tot het volgende document:"
|
||||
|
||||
#: build/lib/core/models.py:1795 core/models.py:1795
|
||||
#: build/lib/core/models.py:1797 core/models.py:1797
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr "{name} verzoekt toegang tot het document: {title}"
|
||||
|
||||
#: build/lib/core/models.py:1837 core/models.py:1837
|
||||
#: build/lib/core/models.py:1839 core/models.py:1839
|
||||
msgid "Thread"
|
||||
msgstr "Kanaal"
|
||||
|
||||
#: build/lib/core/models.py:1838 core/models.py:1838
|
||||
#: build/lib/core/models.py:1840 core/models.py:1840
|
||||
msgid "Threads"
|
||||
msgstr "Kanalen"
|
||||
|
||||
#: build/lib/core/models.py:1841 build/lib/core/models.py:1893
|
||||
#: core/models.py:1841 core/models.py:1893
|
||||
#: build/lib/core/models.py:1843 build/lib/core/models.py:1895
|
||||
#: core/models.py:1843 core/models.py:1895
|
||||
msgid "Anonymous"
|
||||
msgstr "Anoniem"
|
||||
|
||||
#: build/lib/core/models.py:1888 core/models.py:1888
|
||||
#: build/lib/core/models.py:1890 core/models.py:1890
|
||||
msgid "Comment"
|
||||
msgstr "Reactie"
|
||||
|
||||
#: build/lib/core/models.py:1889 core/models.py:1889
|
||||
#: build/lib/core/models.py:1891 core/models.py:1891
|
||||
msgid "Comments"
|
||||
msgstr "Reacties"
|
||||
|
||||
#: build/lib/core/models.py:1938 core/models.py:1938
|
||||
#: build/lib/core/models.py:1940 core/models.py:1940
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr "Deze emoji is al op deze opmerking gereageerd."
|
||||
|
||||
#: build/lib/core/models.py:1942 core/models.py:1942
|
||||
#: build/lib/core/models.py:1944 core/models.py:1944
|
||||
msgid "Reaction"
|
||||
msgstr "Reactie"
|
||||
|
||||
#: build/lib/core/models.py:1943 core/models.py:1943
|
||||
#: build/lib/core/models.py:1945 core/models.py:1945
|
||||
msgid "Reactions"
|
||||
msgstr "Reacties"
|
||||
|
||||
#: build/lib/core/models.py:1953 core/models.py:1953
|
||||
#: build/lib/core/models.py:1955 core/models.py:1955
|
||||
msgid "email address"
|
||||
msgstr "e-mailadres"
|
||||
|
||||
#: build/lib/core/models.py:1972 core/models.py:1972
|
||||
#: build/lib/core/models.py:1974 core/models.py:1974
|
||||
msgid "Document invitation"
|
||||
msgstr "Document uitnodiging"
|
||||
|
||||
#: build/lib/core/models.py:1973 core/models.py:1973
|
||||
#: build/lib/core/models.py:1975 core/models.py:1975
|
||||
msgid "Document invitations"
|
||||
msgstr "Document uitnodigingen"
|
||||
|
||||
#: build/lib/core/models.py:1993 core/models.py:1993
|
||||
#: build/lib/core/models.py:1995 core/models.py:1995
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Deze email is al geassocieerd met een geregistreerde gebruiker."
|
||||
|
||||
#: build/lib/impress/settings.py:808 impress/settings.py:808
|
||||
#: build/lib/impress/settings.py:837 impress/settings.py:837
|
||||
msgid "Docs AI"
|
||||
msgstr "Docs AI"
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-04-02 09:37+0000\n"
|
||||
"PO-Revision-Date: 2026-04-08 13:28\n"
|
||||
"POT-Creation-Date: 2026-04-30 12:37+0000\n"
|
||||
"PO-Revision-Date: 2026-04-30 13:05\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Portuguese\n"
|
||||
"Language: pt_PT\n"
|
||||
@@ -17,28 +17,28 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:30 core/admin.py:30
|
||||
#: build/lib/core/admin.py:33 core/admin.py:33
|
||||
msgid "Personal info"
|
||||
msgstr "Informações Pessoais"
|
||||
|
||||
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
|
||||
#: core/admin.py:161
|
||||
#: build/lib/core/admin.py:46 build/lib/core/admin.py:166 core/admin.py:46
|
||||
#: core/admin.py:166
|
||||
msgid "Permissions"
|
||||
msgstr "Permissões"
|
||||
|
||||
#: build/lib/core/admin.py:55 core/admin.py:55
|
||||
#: build/lib/core/admin.py:58 core/admin.py:58
|
||||
msgid "Important dates"
|
||||
msgstr "Datas importantes"
|
||||
|
||||
#: build/lib/core/admin.py:112 core/admin.py:112
|
||||
#: build/lib/core/admin.py:117 core/admin.py:117
|
||||
msgid "Import job created and queued."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:116 core/admin.py:116
|
||||
#: build/lib/core/admin.py:121 core/admin.py:121
|
||||
msgid "Process selected user reconciliations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:171 core/admin.py:171
|
||||
#: build/lib/core/admin.py:176 core/admin.py:176
|
||||
msgid "Tree structure"
|
||||
msgstr "Estrutura de árvore"
|
||||
|
||||
@@ -62,24 +62,24 @@ msgstr ""
|
||||
msgid "Favorite"
|
||||
msgstr "Favorito"
|
||||
|
||||
#: build/lib/core/api/serializers.py:544 core/api/serializers.py:544
|
||||
#: build/lib/core/api/serializers.py:507 core/api/serializers.py:507
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "Um novo documento foi criado em seu nome!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#: build/lib/core/api/serializers.py:511 core/api/serializers.py:511
|
||||
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:584 core/api/serializers.py:584
|
||||
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
|
||||
msgid "This field is required."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
|
||||
#: build/lib/core/api/serializers.py:558 core/api/serializers.py:558
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1312 core/api/viewsets.py:1312
|
||||
#: build/lib/core/api/viewsets.py:1299 core/api/viewsets.py:1299
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "cópia de {title}"
|
||||
@@ -375,151 +375,151 @@ msgstr ""
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:940 build/lib/core/models.py:1345
|
||||
#: core/models.py:940 core/models.py:1345
|
||||
#: build/lib/core/models.py:940 build/lib/core/models.py:1347
|
||||
#: core/models.py:940 core/models.py:1347
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1346 core/models.py:1346
|
||||
#: build/lib/core/models.py:1348 core/models.py:1348
|
||||
msgid "Open"
|
||||
msgstr "Abrir"
|
||||
|
||||
#: build/lib/core/models.py:1381 core/models.py:1381
|
||||
#: build/lib/core/models.py:1383 core/models.py:1383
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1385 core/models.py:1385
|
||||
#: build/lib/core/models.py:1387 core/models.py:1387
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1391 core/models.py:1391
|
||||
#: build/lib/core/models.py:1393 core/models.py:1393
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1492 core/models.py:1492
|
||||
#: build/lib/core/models.py:1494 core/models.py:1494
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1493 core/models.py:1493
|
||||
#: build/lib/core/models.py:1495 core/models.py:1495
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1499 core/models.py:1499
|
||||
#: build/lib/core/models.py:1501 core/models.py:1501
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1522 core/models.py:1522
|
||||
#: build/lib/core/models.py:1524 core/models.py:1524
|
||||
msgid "Document favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1523 core/models.py:1523
|
||||
#: build/lib/core/models.py:1525 core/models.py:1525
|
||||
msgid "Document favorites"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1529 core/models.py:1529
|
||||
#: build/lib/core/models.py:1531 core/models.py:1531
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1551 core/models.py:1551
|
||||
#: build/lib/core/models.py:1553 core/models.py:1553
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1552 core/models.py:1552
|
||||
#: build/lib/core/models.py:1554 core/models.py:1554
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1558 core/models.py:1558
|
||||
#: build/lib/core/models.py:1560 core/models.py:1560
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1564 core/models.py:1564
|
||||
#: build/lib/core/models.py:1566 core/models.py:1566
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1570 core/models.py:1570
|
||||
#: build/lib/core/models.py:1572 core/models.py:1572
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1721 core/models.py:1721
|
||||
#: build/lib/core/models.py:1723 core/models.py:1723
|
||||
msgid "Document ask for access"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1722 core/models.py:1722
|
||||
#: build/lib/core/models.py:1724 core/models.py:1724
|
||||
msgid "Document ask for accesses"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1728 core/models.py:1728
|
||||
#: build/lib/core/models.py:1730 core/models.py:1730
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1785 core/models.py:1785
|
||||
#: build/lib/core/models.py:1787 core/models.py:1787
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1789 core/models.py:1789
|
||||
#: build/lib/core/models.py:1791 core/models.py:1791
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1795 core/models.py:1795
|
||||
#: build/lib/core/models.py:1797 core/models.py:1797
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1837 core/models.py:1837
|
||||
#: build/lib/core/models.py:1839 core/models.py:1839
|
||||
msgid "Thread"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1838 core/models.py:1838
|
||||
#: build/lib/core/models.py:1840 core/models.py:1840
|
||||
msgid "Threads"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1841 build/lib/core/models.py:1893
|
||||
#: core/models.py:1841 core/models.py:1893
|
||||
#: build/lib/core/models.py:1843 build/lib/core/models.py:1895
|
||||
#: core/models.py:1843 core/models.py:1895
|
||||
msgid "Anonymous"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1888 core/models.py:1888
|
||||
#: build/lib/core/models.py:1890 core/models.py:1890
|
||||
msgid "Comment"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1889 core/models.py:1889
|
||||
#: build/lib/core/models.py:1891 core/models.py:1891
|
||||
msgid "Comments"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1938 core/models.py:1938
|
||||
#: build/lib/core/models.py:1940 core/models.py:1940
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1942 core/models.py:1942
|
||||
#: build/lib/core/models.py:1944 core/models.py:1944
|
||||
msgid "Reaction"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1943 core/models.py:1943
|
||||
#: build/lib/core/models.py:1945 core/models.py:1945
|
||||
msgid "Reactions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1953 core/models.py:1953
|
||||
#: build/lib/core/models.py:1955 core/models.py:1955
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1972 core/models.py:1972
|
||||
#: build/lib/core/models.py:1974 core/models.py:1974
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1973 core/models.py:1973
|
||||
#: build/lib/core/models.py:1975 core/models.py:1975
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1993 core/models.py:1993
|
||||
#: build/lib/core/models.py:1995 core/models.py:1995
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/impress/settings.py:808 impress/settings.py:808
|
||||
#: build/lib/impress/settings.py:837 impress/settings.py:837
|
||||
msgid "Docs AI"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-04-02 09:37+0000\n"
|
||||
"PO-Revision-Date: 2026-04-08 13:28\n"
|
||||
"POT-Creation-Date: 2026-04-30 12:37+0000\n"
|
||||
"PO-Revision-Date: 2026-04-30 13:05\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Russian\n"
|
||||
"Language: ru_RU\n"
|
||||
@@ -17,28 +17,28 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:30 core/admin.py:30
|
||||
#: build/lib/core/admin.py:33 core/admin.py:33
|
||||
msgid "Personal info"
|
||||
msgstr "Личная информация"
|
||||
|
||||
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
|
||||
#: core/admin.py:161
|
||||
#: build/lib/core/admin.py:46 build/lib/core/admin.py:166 core/admin.py:46
|
||||
#: core/admin.py:166
|
||||
msgid "Permissions"
|
||||
msgstr "Разрешения"
|
||||
|
||||
#: build/lib/core/admin.py:55 core/admin.py:55
|
||||
#: build/lib/core/admin.py:58 core/admin.py:58
|
||||
msgid "Important dates"
|
||||
msgstr "Важные даты"
|
||||
|
||||
#: build/lib/core/admin.py:112 core/admin.py:112
|
||||
#: build/lib/core/admin.py:117 core/admin.py:117
|
||||
msgid "Import job created and queued."
|
||||
msgstr "Задание по импорту создано и поставлено в очередь."
|
||||
|
||||
#: build/lib/core/admin.py:116 core/admin.py:116
|
||||
#: build/lib/core/admin.py:121 core/admin.py:121
|
||||
msgid "Process selected user reconciliations"
|
||||
msgstr "Обработка выбранных пользовательских сверок"
|
||||
|
||||
#: build/lib/core/admin.py:171 core/admin.py:171
|
||||
#: build/lib/core/admin.py:176 core/admin.py:176
|
||||
msgid "Tree structure"
|
||||
msgstr "Древовидная структура"
|
||||
|
||||
@@ -62,24 +62,24 @@ msgstr "Скрытый"
|
||||
msgid "Favorite"
|
||||
msgstr "Избранное"
|
||||
|
||||
#: build/lib/core/api/serializers.py:544 core/api/serializers.py:544
|
||||
#: build/lib/core/api/serializers.py:507 core/api/serializers.py:507
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "Новый документ был создан от вашего имени!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#: build/lib/core/api/serializers.py:511 core/api/serializers.py:511
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr "Вы назначены владельцем для нового документа:"
|
||||
|
||||
#: build/lib/core/api/serializers.py:584 core/api/serializers.py:584
|
||||
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
|
||||
msgid "This field is required."
|
||||
msgstr "Это поле обязательное."
|
||||
|
||||
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
|
||||
#: build/lib/core/api/serializers.py:558 core/api/serializers.py:558
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr "Доступ по ссылке '%(link_reach)s' запрещён в соответствии с настройками родительского документа."
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1312 core/api/viewsets.py:1312
|
||||
#: build/lib/core/api/viewsets.py:1299 core/api/viewsets.py:1299
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "копия {title}"
|
||||
@@ -382,151 +382,151 @@ msgstr "Документ"
|
||||
msgid "Documents"
|
||||
msgstr "Документы"
|
||||
|
||||
#: build/lib/core/models.py:940 build/lib/core/models.py:1345
|
||||
#: core/models.py:940 core/models.py:1345
|
||||
#: build/lib/core/models.py:940 build/lib/core/models.py:1347
|
||||
#: core/models.py:940 core/models.py:1347
|
||||
msgid "Untitled Document"
|
||||
msgstr "Безымянный документ"
|
||||
|
||||
#: build/lib/core/models.py:1346 core/models.py:1346
|
||||
#: build/lib/core/models.py:1348 core/models.py:1348
|
||||
msgid "Open"
|
||||
msgstr "Открыть"
|
||||
|
||||
#: build/lib/core/models.py:1381 core/models.py:1381
|
||||
#: build/lib/core/models.py:1383 core/models.py:1383
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} делится с вами документом!"
|
||||
|
||||
#: build/lib/core/models.py:1385 core/models.py:1385
|
||||
#: build/lib/core/models.py:1387 core/models.py:1387
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} приглашает вас присоединиться к следующему документу с ролью \"{role}\":"
|
||||
|
||||
#: build/lib/core/models.py:1391 core/models.py:1391
|
||||
#: build/lib/core/models.py:1393 core/models.py:1393
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} делится с вами документом: {title}"
|
||||
|
||||
#: build/lib/core/models.py:1492 core/models.py:1492
|
||||
#: build/lib/core/models.py:1494 core/models.py:1494
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Трассировка связи документ/пользователь"
|
||||
|
||||
#: build/lib/core/models.py:1493 core/models.py:1493
|
||||
#: build/lib/core/models.py:1495 core/models.py:1495
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Трассировка связей документ/пользователь"
|
||||
|
||||
#: build/lib/core/models.py:1499 core/models.py:1499
|
||||
#: build/lib/core/models.py:1501 core/models.py:1501
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "Для этого документа/пользователя уже существует трассировка ссылки."
|
||||
|
||||
#: build/lib/core/models.py:1522 core/models.py:1522
|
||||
#: build/lib/core/models.py:1524 core/models.py:1524
|
||||
msgid "Document favorite"
|
||||
msgstr "Избранный документ"
|
||||
|
||||
#: build/lib/core/models.py:1523 core/models.py:1523
|
||||
#: build/lib/core/models.py:1525 core/models.py:1525
|
||||
msgid "Document favorites"
|
||||
msgstr "Избранные документы"
|
||||
|
||||
#: build/lib/core/models.py:1529 core/models.py:1529
|
||||
#: build/lib/core/models.py:1531 core/models.py:1531
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "Этот документ уже помечен как избранный для этого пользователя."
|
||||
|
||||
#: build/lib/core/models.py:1551 core/models.py:1551
|
||||
#: build/lib/core/models.py:1553 core/models.py:1553
|
||||
msgid "Document/user relation"
|
||||
msgstr "Отношение документ/пользователь"
|
||||
|
||||
#: build/lib/core/models.py:1552 core/models.py:1552
|
||||
#: build/lib/core/models.py:1554 core/models.py:1554
|
||||
msgid "Document/user relations"
|
||||
msgstr "Отношения документ/пользователь"
|
||||
|
||||
#: build/lib/core/models.py:1558 core/models.py:1558
|
||||
#: build/lib/core/models.py:1560 core/models.py:1560
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Этот пользователь уже имеет доступ к этому документу."
|
||||
|
||||
#: build/lib/core/models.py:1564 core/models.py:1564
|
||||
#: build/lib/core/models.py:1566 core/models.py:1566
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Эта команда уже имеет доступ к этому документу."
|
||||
|
||||
#: build/lib/core/models.py:1570 core/models.py:1570
|
||||
#: build/lib/core/models.py:1572 core/models.py:1572
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Может быть выбран либо пользователь, либо команда, но не оба варианта сразу."
|
||||
|
||||
#: build/lib/core/models.py:1721 core/models.py:1721
|
||||
#: build/lib/core/models.py:1723 core/models.py:1723
|
||||
msgid "Document ask for access"
|
||||
msgstr "Документ запрашивает доступ"
|
||||
|
||||
#: build/lib/core/models.py:1722 core/models.py:1722
|
||||
#: build/lib/core/models.py:1724 core/models.py:1724
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "Документ запрашивает доступы"
|
||||
|
||||
#: build/lib/core/models.py:1728 core/models.py:1728
|
||||
#: build/lib/core/models.py:1730 core/models.py:1730
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr "Этот пользователь уже запросил доступ к этому документу."
|
||||
|
||||
#: build/lib/core/models.py:1785 core/models.py:1785
|
||||
#: build/lib/core/models.py:1787 core/models.py:1787
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr "{name} хочет получить доступ к документу!"
|
||||
|
||||
#: build/lib/core/models.py:1789 core/models.py:1789
|
||||
#: build/lib/core/models.py:1791 core/models.py:1791
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr "{name} хочет получить доступ к следующему документу:"
|
||||
|
||||
#: build/lib/core/models.py:1795 core/models.py:1795
|
||||
#: build/lib/core/models.py:1797 core/models.py:1797
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr "{name} запрашивает доступ к документу: {title}"
|
||||
|
||||
#: build/lib/core/models.py:1837 core/models.py:1837
|
||||
#: build/lib/core/models.py:1839 core/models.py:1839
|
||||
msgid "Thread"
|
||||
msgstr "Обсуждение"
|
||||
|
||||
#: build/lib/core/models.py:1838 core/models.py:1838
|
||||
#: build/lib/core/models.py:1840 core/models.py:1840
|
||||
msgid "Threads"
|
||||
msgstr "Обсуждения"
|
||||
|
||||
#: build/lib/core/models.py:1841 build/lib/core/models.py:1893
|
||||
#: core/models.py:1841 core/models.py:1893
|
||||
#: build/lib/core/models.py:1843 build/lib/core/models.py:1895
|
||||
#: core/models.py:1843 core/models.py:1895
|
||||
msgid "Anonymous"
|
||||
msgstr "Аноним"
|
||||
|
||||
#: build/lib/core/models.py:1888 core/models.py:1888
|
||||
#: build/lib/core/models.py:1890 core/models.py:1890
|
||||
msgid "Comment"
|
||||
msgstr "Комментарий"
|
||||
|
||||
#: build/lib/core/models.py:1889 core/models.py:1889
|
||||
#: build/lib/core/models.py:1891 core/models.py:1891
|
||||
msgid "Comments"
|
||||
msgstr "Комментарии"
|
||||
|
||||
#: build/lib/core/models.py:1938 core/models.py:1938
|
||||
#: build/lib/core/models.py:1940 core/models.py:1940
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr "Этот эмодзи уже использован в этом комментарии."
|
||||
|
||||
#: build/lib/core/models.py:1942 core/models.py:1942
|
||||
#: build/lib/core/models.py:1944 core/models.py:1944
|
||||
msgid "Reaction"
|
||||
msgstr "Реакция"
|
||||
|
||||
#: build/lib/core/models.py:1943 core/models.py:1943
|
||||
#: build/lib/core/models.py:1945 core/models.py:1945
|
||||
msgid "Reactions"
|
||||
msgstr "Реакции"
|
||||
|
||||
#: build/lib/core/models.py:1953 core/models.py:1953
|
||||
#: build/lib/core/models.py:1955 core/models.py:1955
|
||||
msgid "email address"
|
||||
msgstr "адрес электронной почты"
|
||||
|
||||
#: build/lib/core/models.py:1972 core/models.py:1972
|
||||
#: build/lib/core/models.py:1974 core/models.py:1974
|
||||
msgid "Document invitation"
|
||||
msgstr "Приглашение для документа"
|
||||
|
||||
#: build/lib/core/models.py:1973 core/models.py:1973
|
||||
#: build/lib/core/models.py:1975 core/models.py:1975
|
||||
msgid "Document invitations"
|
||||
msgstr "Приглашения для документов"
|
||||
|
||||
#: build/lib/core/models.py:1993 core/models.py:1993
|
||||
#: build/lib/core/models.py:1995 core/models.py:1995
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Этот адрес уже связан с зарегистрированным пользователем."
|
||||
|
||||
#: build/lib/impress/settings.py:808 impress/settings.py:808
|
||||
#: build/lib/impress/settings.py:837 impress/settings.py:837
|
||||
msgid "Docs AI"
|
||||
msgstr "Docs ИИ"
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-04-02 09:37+0000\n"
|
||||
"PO-Revision-Date: 2026-04-08 13:28\n"
|
||||
"POT-Creation-Date: 2026-04-30 12:37+0000\n"
|
||||
"PO-Revision-Date: 2026-04-30 13:05\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Slovenian\n"
|
||||
"Language: sl_SI\n"
|
||||
@@ -17,28 +17,28 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:30 core/admin.py:30
|
||||
#: build/lib/core/admin.py:33 core/admin.py:33
|
||||
msgid "Personal info"
|
||||
msgstr "Osebni podatki"
|
||||
|
||||
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
|
||||
#: core/admin.py:161
|
||||
#: build/lib/core/admin.py:46 build/lib/core/admin.py:166 core/admin.py:46
|
||||
#: core/admin.py:166
|
||||
msgid "Permissions"
|
||||
msgstr "Dovoljenja"
|
||||
|
||||
#: build/lib/core/admin.py:55 core/admin.py:55
|
||||
#: build/lib/core/admin.py:58 core/admin.py:58
|
||||
msgid "Important dates"
|
||||
msgstr "Pomembni datumi"
|
||||
|
||||
#: build/lib/core/admin.py:112 core/admin.py:112
|
||||
#: build/lib/core/admin.py:117 core/admin.py:117
|
||||
msgid "Import job created and queued."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:116 core/admin.py:116
|
||||
#: build/lib/core/admin.py:121 core/admin.py:121
|
||||
msgid "Process selected user reconciliations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:171 core/admin.py:171
|
||||
#: build/lib/core/admin.py:176 core/admin.py:176
|
||||
msgid "Tree structure"
|
||||
msgstr "Drevesna struktura"
|
||||
|
||||
@@ -62,24 +62,24 @@ msgstr ""
|
||||
msgid "Favorite"
|
||||
msgstr "Priljubljena"
|
||||
|
||||
#: build/lib/core/api/serializers.py:544 core/api/serializers.py:544
|
||||
#: build/lib/core/api/serializers.py:507 core/api/serializers.py:507
|
||||
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:548 core/api/serializers.py:548
|
||||
#: build/lib/core/api/serializers.py:511 core/api/serializers.py:511
|
||||
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:584 core/api/serializers.py:584
|
||||
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
|
||||
msgid "This field is required."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
|
||||
#: build/lib/core/api/serializers.py:558 core/api/serializers.py:558
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1312 core/api/viewsets.py:1312
|
||||
#: build/lib/core/api/viewsets.py:1299 core/api/viewsets.py:1299
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr ""
|
||||
@@ -375,151 +375,151 @@ msgstr "Dokument"
|
||||
msgid "Documents"
|
||||
msgstr "Dokumenti"
|
||||
|
||||
#: build/lib/core/models.py:940 build/lib/core/models.py:1345
|
||||
#: core/models.py:940 core/models.py:1345
|
||||
#: build/lib/core/models.py:940 build/lib/core/models.py:1347
|
||||
#: core/models.py:940 core/models.py:1347
|
||||
msgid "Untitled Document"
|
||||
msgstr "Dokument brez naslova"
|
||||
|
||||
#: build/lib/core/models.py:1346 core/models.py:1346
|
||||
#: build/lib/core/models.py:1348 core/models.py:1348
|
||||
msgid "Open"
|
||||
msgstr "Odpri"
|
||||
|
||||
#: build/lib/core/models.py:1381 core/models.py:1381
|
||||
#: build/lib/core/models.py:1383 core/models.py:1383
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} je delil dokument z vami!"
|
||||
|
||||
#: build/lib/core/models.py:1385 core/models.py:1385
|
||||
#: build/lib/core/models.py:1387 core/models.py:1387
|
||||
#, 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:1391 core/models.py:1391
|
||||
#: build/lib/core/models.py:1393 core/models.py:1393
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} je delil dokument z vami: {title}"
|
||||
|
||||
#: build/lib/core/models.py:1492 core/models.py:1492
|
||||
#: build/lib/core/models.py:1494 core/models.py:1494
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Dokument/sled povezave uporabnika"
|
||||
|
||||
#: build/lib/core/models.py:1493 core/models.py:1493
|
||||
#: build/lib/core/models.py:1495 core/models.py:1495
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Sledi povezav dokumenta/uporabnika"
|
||||
|
||||
#: build/lib/core/models.py:1499 core/models.py:1499
|
||||
#: build/lib/core/models.py:1501 core/models.py:1501
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "Za ta dokument/uporabnika že obstaja sled povezave."
|
||||
|
||||
#: build/lib/core/models.py:1522 core/models.py:1522
|
||||
#: build/lib/core/models.py:1524 core/models.py:1524
|
||||
msgid "Document favorite"
|
||||
msgstr "Priljubljeni dokument"
|
||||
|
||||
#: build/lib/core/models.py:1523 core/models.py:1523
|
||||
#: build/lib/core/models.py:1525 core/models.py:1525
|
||||
msgid "Document favorites"
|
||||
msgstr "Priljubljeni dokumenti"
|
||||
|
||||
#: build/lib/core/models.py:1529 core/models.py:1529
|
||||
#: build/lib/core/models.py:1531 core/models.py:1531
|
||||
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:1551 core/models.py:1551
|
||||
#: build/lib/core/models.py:1553 core/models.py:1553
|
||||
msgid "Document/user relation"
|
||||
msgstr "Odnos dokument/uporabnik"
|
||||
|
||||
#: build/lib/core/models.py:1552 core/models.py:1552
|
||||
#: build/lib/core/models.py:1554 core/models.py:1554
|
||||
msgid "Document/user relations"
|
||||
msgstr "Odnosi dokument/uporabnik"
|
||||
|
||||
#: build/lib/core/models.py:1558 core/models.py:1558
|
||||
#: build/lib/core/models.py:1560 core/models.py:1560
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Ta uporabnik je že v tem dokumentu."
|
||||
|
||||
#: build/lib/core/models.py:1564 core/models.py:1564
|
||||
#: build/lib/core/models.py:1566 core/models.py:1566
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Ta ekipa je že v tem dokumentu."
|
||||
|
||||
#: build/lib/core/models.py:1570 core/models.py:1570
|
||||
#: build/lib/core/models.py:1572 core/models.py:1572
|
||||
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:1721 core/models.py:1721
|
||||
#: build/lib/core/models.py:1723 core/models.py:1723
|
||||
msgid "Document ask for access"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1722 core/models.py:1722
|
||||
#: build/lib/core/models.py:1724 core/models.py:1724
|
||||
msgid "Document ask for accesses"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1728 core/models.py:1728
|
||||
#: build/lib/core/models.py:1730 core/models.py:1730
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1785 core/models.py:1785
|
||||
#: build/lib/core/models.py:1787 core/models.py:1787
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1789 core/models.py:1789
|
||||
#: build/lib/core/models.py:1791 core/models.py:1791
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1795 core/models.py:1795
|
||||
#: build/lib/core/models.py:1797 core/models.py:1797
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1837 core/models.py:1837
|
||||
#: build/lib/core/models.py:1839 core/models.py:1839
|
||||
msgid "Thread"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1838 core/models.py:1838
|
||||
#: build/lib/core/models.py:1840 core/models.py:1840
|
||||
msgid "Threads"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1841 build/lib/core/models.py:1893
|
||||
#: core/models.py:1841 core/models.py:1893
|
||||
#: build/lib/core/models.py:1843 build/lib/core/models.py:1895
|
||||
#: core/models.py:1843 core/models.py:1895
|
||||
msgid "Anonymous"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1888 core/models.py:1888
|
||||
#: build/lib/core/models.py:1890 core/models.py:1890
|
||||
msgid "Comment"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1889 core/models.py:1889
|
||||
#: build/lib/core/models.py:1891 core/models.py:1891
|
||||
msgid "Comments"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1938 core/models.py:1938
|
||||
#: build/lib/core/models.py:1940 core/models.py:1940
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1942 core/models.py:1942
|
||||
#: build/lib/core/models.py:1944 core/models.py:1944
|
||||
msgid "Reaction"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1943 core/models.py:1943
|
||||
#: build/lib/core/models.py:1945 core/models.py:1945
|
||||
msgid "Reactions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1953 core/models.py:1953
|
||||
#: build/lib/core/models.py:1955 core/models.py:1955
|
||||
msgid "email address"
|
||||
msgstr "elektronski naslov"
|
||||
|
||||
#: build/lib/core/models.py:1972 core/models.py:1972
|
||||
#: build/lib/core/models.py:1974 core/models.py:1974
|
||||
msgid "Document invitation"
|
||||
msgstr "Vabilo na dokument"
|
||||
|
||||
#: build/lib/core/models.py:1973 core/models.py:1973
|
||||
#: build/lib/core/models.py:1975 core/models.py:1975
|
||||
msgid "Document invitations"
|
||||
msgstr "Vabila na dokument"
|
||||
|
||||
#: build/lib/core/models.py:1993 core/models.py:1993
|
||||
#: build/lib/core/models.py:1995 core/models.py:1995
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Ta e-poštni naslov je že povezan z registriranim uporabnikom."
|
||||
|
||||
#: build/lib/impress/settings.py:808 impress/settings.py:808
|
||||
#: build/lib/impress/settings.py:837 impress/settings.py:837
|
||||
msgid "Docs AI"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-04-02 09:37+0000\n"
|
||||
"PO-Revision-Date: 2026-04-08 13:28\n"
|
||||
"POT-Creation-Date: 2026-04-30 12:37+0000\n"
|
||||
"PO-Revision-Date: 2026-04-30 13:05\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Swedish\n"
|
||||
"Language: sv_SE\n"
|
||||
@@ -17,28 +17,28 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:30 core/admin.py:30
|
||||
#: build/lib/core/admin.py:33 core/admin.py:33
|
||||
msgid "Personal info"
|
||||
msgstr "Personuppgifter"
|
||||
|
||||
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
|
||||
#: core/admin.py:161
|
||||
#: build/lib/core/admin.py:46 build/lib/core/admin.py:166 core/admin.py:46
|
||||
#: core/admin.py:166
|
||||
msgid "Permissions"
|
||||
msgstr "Behörigheter"
|
||||
|
||||
#: build/lib/core/admin.py:55 core/admin.py:55
|
||||
#: build/lib/core/admin.py:58 core/admin.py:58
|
||||
msgid "Important dates"
|
||||
msgstr "Viktiga datum"
|
||||
|
||||
#: build/lib/core/admin.py:112 core/admin.py:112
|
||||
#: build/lib/core/admin.py:117 core/admin.py:117
|
||||
msgid "Import job created and queued."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:116 core/admin.py:116
|
||||
#: build/lib/core/admin.py:121 core/admin.py:121
|
||||
msgid "Process selected user reconciliations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:171 core/admin.py:171
|
||||
#: build/lib/core/admin.py:176 core/admin.py:176
|
||||
msgid "Tree structure"
|
||||
msgstr ""
|
||||
|
||||
@@ -62,24 +62,24 @@ msgstr ""
|
||||
msgid "Favorite"
|
||||
msgstr "Favoriter"
|
||||
|
||||
#: build/lib/core/api/serializers.py:544 core/api/serializers.py:544
|
||||
#: build/lib/core/api/serializers.py:507 core/api/serializers.py:507
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "Ett nytt dokument skapades åt dig!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#: build/lib/core/api/serializers.py:511 core/api/serializers.py:511
|
||||
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:584 core/api/serializers.py:584
|
||||
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
|
||||
msgid "This field is required."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
|
||||
#: build/lib/core/api/serializers.py:558 core/api/serializers.py:558
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1312 core/api/viewsets.py:1312
|
||||
#: build/lib/core/api/viewsets.py:1299 core/api/viewsets.py:1299
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr ""
|
||||
@@ -375,151 +375,151 @@ msgstr ""
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:940 build/lib/core/models.py:1345
|
||||
#: core/models.py:940 core/models.py:1345
|
||||
#: build/lib/core/models.py:940 build/lib/core/models.py:1347
|
||||
#: core/models.py:940 core/models.py:1347
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1346 core/models.py:1346
|
||||
#: build/lib/core/models.py:1348 core/models.py:1348
|
||||
msgid "Open"
|
||||
msgstr "Öppna"
|
||||
|
||||
#: build/lib/core/models.py:1381 core/models.py:1381
|
||||
#: build/lib/core/models.py:1383 core/models.py:1383
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1385 core/models.py:1385
|
||||
#: build/lib/core/models.py:1387 core/models.py:1387
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1391 core/models.py:1391
|
||||
#: build/lib/core/models.py:1393 core/models.py:1393
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1492 core/models.py:1492
|
||||
#: build/lib/core/models.py:1494 core/models.py:1494
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1493 core/models.py:1493
|
||||
#: build/lib/core/models.py:1495 core/models.py:1495
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1499 core/models.py:1499
|
||||
#: build/lib/core/models.py:1501 core/models.py:1501
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1522 core/models.py:1522
|
||||
#: build/lib/core/models.py:1524 core/models.py:1524
|
||||
msgid "Document favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1523 core/models.py:1523
|
||||
#: build/lib/core/models.py:1525 core/models.py:1525
|
||||
msgid "Document favorites"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1529 core/models.py:1529
|
||||
#: build/lib/core/models.py:1531 core/models.py:1531
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1551 core/models.py:1551
|
||||
#: build/lib/core/models.py:1553 core/models.py:1553
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1552 core/models.py:1552
|
||||
#: build/lib/core/models.py:1554 core/models.py:1554
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1558 core/models.py:1558
|
||||
#: build/lib/core/models.py:1560 core/models.py:1560
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1564 core/models.py:1564
|
||||
#: build/lib/core/models.py:1566 core/models.py:1566
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1570 core/models.py:1570
|
||||
#: build/lib/core/models.py:1572 core/models.py:1572
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1721 core/models.py:1721
|
||||
#: build/lib/core/models.py:1723 core/models.py:1723
|
||||
msgid "Document ask for access"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1722 core/models.py:1722
|
||||
#: build/lib/core/models.py:1724 core/models.py:1724
|
||||
msgid "Document ask for accesses"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1728 core/models.py:1728
|
||||
#: build/lib/core/models.py:1730 core/models.py:1730
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1785 core/models.py:1785
|
||||
#: build/lib/core/models.py:1787 core/models.py:1787
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1789 core/models.py:1789
|
||||
#: build/lib/core/models.py:1791 core/models.py:1791
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1795 core/models.py:1795
|
||||
#: build/lib/core/models.py:1797 core/models.py:1797
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1837 core/models.py:1837
|
||||
#: build/lib/core/models.py:1839 core/models.py:1839
|
||||
msgid "Thread"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1838 core/models.py:1838
|
||||
#: build/lib/core/models.py:1840 core/models.py:1840
|
||||
msgid "Threads"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1841 build/lib/core/models.py:1893
|
||||
#: core/models.py:1841 core/models.py:1893
|
||||
#: build/lib/core/models.py:1843 build/lib/core/models.py:1895
|
||||
#: core/models.py:1843 core/models.py:1895
|
||||
msgid "Anonymous"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1888 core/models.py:1888
|
||||
#: build/lib/core/models.py:1890 core/models.py:1890
|
||||
msgid "Comment"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1889 core/models.py:1889
|
||||
#: build/lib/core/models.py:1891 core/models.py:1891
|
||||
msgid "Comments"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1938 core/models.py:1938
|
||||
#: build/lib/core/models.py:1940 core/models.py:1940
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1942 core/models.py:1942
|
||||
#: build/lib/core/models.py:1944 core/models.py:1944
|
||||
msgid "Reaction"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1943 core/models.py:1943
|
||||
#: build/lib/core/models.py:1945 core/models.py:1945
|
||||
msgid "Reactions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1953 core/models.py:1953
|
||||
#: build/lib/core/models.py:1955 core/models.py:1955
|
||||
msgid "email address"
|
||||
msgstr "e-postadress"
|
||||
|
||||
#: build/lib/core/models.py:1972 core/models.py:1972
|
||||
#: build/lib/core/models.py:1974 core/models.py:1974
|
||||
msgid "Document invitation"
|
||||
msgstr "Bjud in dokument"
|
||||
|
||||
#: build/lib/core/models.py:1973 core/models.py:1973
|
||||
#: build/lib/core/models.py:1975 core/models.py:1975
|
||||
msgid "Document invitations"
|
||||
msgstr "Inbjudningar dokument"
|
||||
|
||||
#: build/lib/core/models.py:1993 core/models.py:1993
|
||||
#: build/lib/core/models.py:1995 core/models.py:1995
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Denna e-postadress är redan associerad med en registrerad användare."
|
||||
|
||||
#: build/lib/impress/settings.py:808 impress/settings.py:808
|
||||
#: build/lib/impress/settings.py:837 impress/settings.py:837
|
||||
msgid "Docs AI"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-04-02 09:37+0000\n"
|
||||
"PO-Revision-Date: 2026-04-08 13:28\n"
|
||||
"POT-Creation-Date: 2026-04-30 12:37+0000\n"
|
||||
"PO-Revision-Date: 2026-04-30 13:05\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Turkish\n"
|
||||
"Language: tr_TR\n"
|
||||
@@ -17,28 +17,28 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:30 core/admin.py:30
|
||||
#: build/lib/core/admin.py:33 core/admin.py:33
|
||||
msgid "Personal info"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
|
||||
#: core/admin.py:161
|
||||
#: build/lib/core/admin.py:46 build/lib/core/admin.py:166 core/admin.py:46
|
||||
#: core/admin.py:166
|
||||
msgid "Permissions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:55 core/admin.py:55
|
||||
#: build/lib/core/admin.py:58 core/admin.py:58
|
||||
msgid "Important dates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:112 core/admin.py:112
|
||||
#: build/lib/core/admin.py:117 core/admin.py:117
|
||||
msgid "Import job created and queued."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:116 core/admin.py:116
|
||||
#: build/lib/core/admin.py:121 core/admin.py:121
|
||||
msgid "Process selected user reconciliations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:171 core/admin.py:171
|
||||
#: build/lib/core/admin.py:176 core/admin.py:176
|
||||
msgid "Tree structure"
|
||||
msgstr ""
|
||||
|
||||
@@ -62,24 +62,24 @@ msgstr ""
|
||||
msgid "Favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:544 core/api/serializers.py:544
|
||||
#: build/lib/core/api/serializers.py:507 core/api/serializers.py:507
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#: build/lib/core/api/serializers.py:511 core/api/serializers.py:511
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:584 core/api/serializers.py:584
|
||||
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
|
||||
msgid "This field is required."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
|
||||
#: build/lib/core/api/serializers.py:558 core/api/serializers.py:558
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1312 core/api/viewsets.py:1312
|
||||
#: build/lib/core/api/viewsets.py:1299 core/api/viewsets.py:1299
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr ""
|
||||
@@ -375,151 +375,151 @@ msgstr ""
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:940 build/lib/core/models.py:1345
|
||||
#: core/models.py:940 core/models.py:1345
|
||||
#: build/lib/core/models.py:940 build/lib/core/models.py:1347
|
||||
#: core/models.py:940 core/models.py:1347
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1346 core/models.py:1346
|
||||
#: build/lib/core/models.py:1348 core/models.py:1348
|
||||
msgid "Open"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1381 core/models.py:1381
|
||||
#: build/lib/core/models.py:1383 core/models.py:1383
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1385 core/models.py:1385
|
||||
#: build/lib/core/models.py:1387 core/models.py:1387
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1391 core/models.py:1391
|
||||
#: build/lib/core/models.py:1393 core/models.py:1393
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1492 core/models.py:1492
|
||||
#: build/lib/core/models.py:1494 core/models.py:1494
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1493 core/models.py:1493
|
||||
#: build/lib/core/models.py:1495 core/models.py:1495
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1499 core/models.py:1499
|
||||
#: build/lib/core/models.py:1501 core/models.py:1501
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1522 core/models.py:1522
|
||||
#: build/lib/core/models.py:1524 core/models.py:1524
|
||||
msgid "Document favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1523 core/models.py:1523
|
||||
#: build/lib/core/models.py:1525 core/models.py:1525
|
||||
msgid "Document favorites"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1529 core/models.py:1529
|
||||
#: build/lib/core/models.py:1531 core/models.py:1531
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1551 core/models.py:1551
|
||||
#: build/lib/core/models.py:1553 core/models.py:1553
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1552 core/models.py:1552
|
||||
#: build/lib/core/models.py:1554 core/models.py:1554
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1558 core/models.py:1558
|
||||
#: build/lib/core/models.py:1560 core/models.py:1560
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1564 core/models.py:1564
|
||||
#: build/lib/core/models.py:1566 core/models.py:1566
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1570 core/models.py:1570
|
||||
#: build/lib/core/models.py:1572 core/models.py:1572
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1721 core/models.py:1721
|
||||
#: build/lib/core/models.py:1723 core/models.py:1723
|
||||
msgid "Document ask for access"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1722 core/models.py:1722
|
||||
#: build/lib/core/models.py:1724 core/models.py:1724
|
||||
msgid "Document ask for accesses"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1728 core/models.py:1728
|
||||
#: build/lib/core/models.py:1730 core/models.py:1730
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1785 core/models.py:1785
|
||||
#: build/lib/core/models.py:1787 core/models.py:1787
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1789 core/models.py:1789
|
||||
#: build/lib/core/models.py:1791 core/models.py:1791
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1795 core/models.py:1795
|
||||
#: build/lib/core/models.py:1797 core/models.py:1797
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1837 core/models.py:1837
|
||||
#: build/lib/core/models.py:1839 core/models.py:1839
|
||||
msgid "Thread"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1838 core/models.py:1838
|
||||
#: build/lib/core/models.py:1840 core/models.py:1840
|
||||
msgid "Threads"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1841 build/lib/core/models.py:1893
|
||||
#: core/models.py:1841 core/models.py:1893
|
||||
#: build/lib/core/models.py:1843 build/lib/core/models.py:1895
|
||||
#: core/models.py:1843 core/models.py:1895
|
||||
msgid "Anonymous"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1888 core/models.py:1888
|
||||
#: build/lib/core/models.py:1890 core/models.py:1890
|
||||
msgid "Comment"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1889 core/models.py:1889
|
||||
#: build/lib/core/models.py:1891 core/models.py:1891
|
||||
msgid "Comments"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1938 core/models.py:1938
|
||||
#: build/lib/core/models.py:1940 core/models.py:1940
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1942 core/models.py:1942
|
||||
#: build/lib/core/models.py:1944 core/models.py:1944
|
||||
msgid "Reaction"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1943 core/models.py:1943
|
||||
#: build/lib/core/models.py:1945 core/models.py:1945
|
||||
msgid "Reactions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1953 core/models.py:1953
|
||||
#: build/lib/core/models.py:1955 core/models.py:1955
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1972 core/models.py:1972
|
||||
#: build/lib/core/models.py:1974 core/models.py:1974
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1973 core/models.py:1973
|
||||
#: build/lib/core/models.py:1975 core/models.py:1975
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:1993 core/models.py:1993
|
||||
#: build/lib/core/models.py:1995 core/models.py:1995
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/impress/settings.py:808 impress/settings.py:808
|
||||
#: build/lib/impress/settings.py:837 impress/settings.py:837
|
||||
msgid "Docs AI"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-04-02 09:37+0000\n"
|
||||
"PO-Revision-Date: 2026-04-08 13:28\n"
|
||||
"POT-Creation-Date: 2026-04-30 12:37+0000\n"
|
||||
"PO-Revision-Date: 2026-04-30 13:05\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Ukrainian\n"
|
||||
"Language: uk_UA\n"
|
||||
@@ -17,28 +17,28 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:30 core/admin.py:30
|
||||
#: build/lib/core/admin.py:33 core/admin.py:33
|
||||
msgid "Personal info"
|
||||
msgstr "Особисті дані"
|
||||
|
||||
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
|
||||
#: core/admin.py:161
|
||||
#: build/lib/core/admin.py:46 build/lib/core/admin.py:166 core/admin.py:46
|
||||
#: core/admin.py:166
|
||||
msgid "Permissions"
|
||||
msgstr "Дозволи"
|
||||
|
||||
#: build/lib/core/admin.py:55 core/admin.py:55
|
||||
#: build/lib/core/admin.py:58 core/admin.py:58
|
||||
msgid "Important dates"
|
||||
msgstr "Важливі дати"
|
||||
|
||||
#: build/lib/core/admin.py:112 core/admin.py:112
|
||||
#: build/lib/core/admin.py:117 core/admin.py:117
|
||||
msgid "Import job created and queued."
|
||||
msgstr "Завдання імпорту створено і поставлено в чергу."
|
||||
|
||||
#: build/lib/core/admin.py:116 core/admin.py:116
|
||||
#: build/lib/core/admin.py:121 core/admin.py:121
|
||||
msgid "Process selected user reconciliations"
|
||||
msgstr "Обробити обрані узгодження користувача"
|
||||
|
||||
#: build/lib/core/admin.py:171 core/admin.py:171
|
||||
#: build/lib/core/admin.py:176 core/admin.py:176
|
||||
msgid "Tree structure"
|
||||
msgstr "Ієрархічна структура"
|
||||
|
||||
@@ -62,24 +62,24 @@ msgstr "Приховано"
|
||||
msgid "Favorite"
|
||||
msgstr "Обране"
|
||||
|
||||
#: build/lib/core/api/serializers.py:544 core/api/serializers.py:544
|
||||
#: build/lib/core/api/serializers.py:507 core/api/serializers.py:507
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "Новий документ був створений від вашого імені!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#: build/lib/core/api/serializers.py:511 core/api/serializers.py:511
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr "Ви тепер є власником нового документа:"
|
||||
|
||||
#: build/lib/core/api/serializers.py:584 core/api/serializers.py:584
|
||||
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
|
||||
msgid "This field is required."
|
||||
msgstr "Це поле є обов’язковим."
|
||||
|
||||
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
|
||||
#: build/lib/core/api/serializers.py:558 core/api/serializers.py:558
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr "Доступ до посилання '%(link_reach)s' заборонено на основі конфігурації батьківського документа."
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1312 core/api/viewsets.py:1312
|
||||
#: build/lib/core/api/viewsets.py:1299 core/api/viewsets.py:1299
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "копія {title}"
|
||||
@@ -382,151 +382,151 @@ msgstr "Документ"
|
||||
msgid "Documents"
|
||||
msgstr "Документи"
|
||||
|
||||
#: build/lib/core/models.py:940 build/lib/core/models.py:1345
|
||||
#: core/models.py:940 core/models.py:1345
|
||||
#: build/lib/core/models.py:940 build/lib/core/models.py:1347
|
||||
#: core/models.py:940 core/models.py:1347
|
||||
msgid "Untitled Document"
|
||||
msgstr "Документ без назви"
|
||||
|
||||
#: build/lib/core/models.py:1346 core/models.py:1346
|
||||
#: build/lib/core/models.py:1348 core/models.py:1348
|
||||
msgid "Open"
|
||||
msgstr "Відкрити"
|
||||
|
||||
#: build/lib/core/models.py:1381 core/models.py:1381
|
||||
#: build/lib/core/models.py:1383 core/models.py:1383
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} ділиться з вами документом!"
|
||||
|
||||
#: build/lib/core/models.py:1385 core/models.py:1385
|
||||
#: build/lib/core/models.py:1387 core/models.py:1387
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} запрошує вас для роботи з документом із роллю \"{role}\":"
|
||||
|
||||
#: build/lib/core/models.py:1391 core/models.py:1391
|
||||
#: build/lib/core/models.py:1393 core/models.py:1393
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} ділиться з вами документом: {title}"
|
||||
|
||||
#: build/lib/core/models.py:1492 core/models.py:1492
|
||||
#: build/lib/core/models.py:1494 core/models.py:1494
|
||||
msgid "Document/user link trace"
|
||||
msgstr "Трасування посилання Документ/користувач"
|
||||
|
||||
#: build/lib/core/models.py:1493 core/models.py:1493
|
||||
#: build/lib/core/models.py:1495 core/models.py:1495
|
||||
msgid "Document/user link traces"
|
||||
msgstr "Трасування посилань Документ/користувач"
|
||||
|
||||
#: build/lib/core/models.py:1499 core/models.py:1499
|
||||
#: build/lib/core/models.py:1501 core/models.py:1501
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "Відстеження вже існуючих посилань для цього документа/користувача."
|
||||
|
||||
#: build/lib/core/models.py:1522 core/models.py:1522
|
||||
#: build/lib/core/models.py:1524 core/models.py:1524
|
||||
msgid "Document favorite"
|
||||
msgstr "Обраний документ"
|
||||
|
||||
#: build/lib/core/models.py:1523 core/models.py:1523
|
||||
#: build/lib/core/models.py:1525 core/models.py:1525
|
||||
msgid "Document favorites"
|
||||
msgstr "Обрані документи"
|
||||
|
||||
#: build/lib/core/models.py:1529 core/models.py:1529
|
||||
#: build/lib/core/models.py:1531 core/models.py:1531
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "Цей документ вже вказаний як обраний для одного користувача."
|
||||
|
||||
#: build/lib/core/models.py:1551 core/models.py:1551
|
||||
#: build/lib/core/models.py:1553 core/models.py:1553
|
||||
msgid "Document/user relation"
|
||||
msgstr "Відносини документ/користувач"
|
||||
|
||||
#: build/lib/core/models.py:1552 core/models.py:1552
|
||||
#: build/lib/core/models.py:1554 core/models.py:1554
|
||||
msgid "Document/user relations"
|
||||
msgstr "Відносини документ/користувач"
|
||||
|
||||
#: build/lib/core/models.py:1558 core/models.py:1558
|
||||
#: build/lib/core/models.py:1560 core/models.py:1560
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Цей користувач вже має доступ до цього документу."
|
||||
|
||||
#: build/lib/core/models.py:1564 core/models.py:1564
|
||||
#: build/lib/core/models.py:1566 core/models.py:1566
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Ця команда вже має доступ до цього документа."
|
||||
|
||||
#: build/lib/core/models.py:1570 core/models.py:1570
|
||||
#: build/lib/core/models.py:1572 core/models.py:1572
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Вкажіть користувача або команду, а не обох."
|
||||
|
||||
#: build/lib/core/models.py:1721 core/models.py:1721
|
||||
#: build/lib/core/models.py:1723 core/models.py:1723
|
||||
msgid "Document ask for access"
|
||||
msgstr "Запит доступу до документа"
|
||||
|
||||
#: build/lib/core/models.py:1722 core/models.py:1722
|
||||
#: build/lib/core/models.py:1724 core/models.py:1724
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "Запит доступу для документа"
|
||||
|
||||
#: build/lib/core/models.py:1728 core/models.py:1728
|
||||
#: build/lib/core/models.py:1730 core/models.py:1730
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr "Цей користувач вже попросив доступ до цього документа."
|
||||
|
||||
#: build/lib/core/models.py:1785 core/models.py:1785
|
||||
#: build/lib/core/models.py:1787 core/models.py:1787
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr "{name} хоче отримати доступ до документа!"
|
||||
|
||||
#: build/lib/core/models.py:1789 core/models.py:1789
|
||||
#: build/lib/core/models.py:1791 core/models.py:1791
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr "{name} бажає отримати доступ до наступного документа:"
|
||||
|
||||
#: build/lib/core/models.py:1795 core/models.py:1795
|
||||
#: build/lib/core/models.py:1797 core/models.py:1797
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr "{name} запитує доступ до документа: {title}"
|
||||
|
||||
#: build/lib/core/models.py:1837 core/models.py:1837
|
||||
#: build/lib/core/models.py:1839 core/models.py:1839
|
||||
msgid "Thread"
|
||||
msgstr "Обговорення"
|
||||
|
||||
#: build/lib/core/models.py:1838 core/models.py:1838
|
||||
#: build/lib/core/models.py:1840 core/models.py:1840
|
||||
msgid "Threads"
|
||||
msgstr "Обговорення"
|
||||
|
||||
#: build/lib/core/models.py:1841 build/lib/core/models.py:1893
|
||||
#: core/models.py:1841 core/models.py:1893
|
||||
#: build/lib/core/models.py:1843 build/lib/core/models.py:1895
|
||||
#: core/models.py:1843 core/models.py:1895
|
||||
msgid "Anonymous"
|
||||
msgstr "Анонім"
|
||||
|
||||
#: build/lib/core/models.py:1888 core/models.py:1888
|
||||
#: build/lib/core/models.py:1890 core/models.py:1890
|
||||
msgid "Comment"
|
||||
msgstr "Коментар"
|
||||
|
||||
#: build/lib/core/models.py:1889 core/models.py:1889
|
||||
#: build/lib/core/models.py:1891 core/models.py:1891
|
||||
msgid "Comments"
|
||||
msgstr "Коментарі"
|
||||
|
||||
#: build/lib/core/models.py:1938 core/models.py:1938
|
||||
#: build/lib/core/models.py:1940 core/models.py:1940
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr "Цим емодзі вже відреагували на цей коментар."
|
||||
|
||||
#: build/lib/core/models.py:1942 core/models.py:1942
|
||||
#: build/lib/core/models.py:1944 core/models.py:1944
|
||||
msgid "Reaction"
|
||||
msgstr "Реакція"
|
||||
|
||||
#: build/lib/core/models.py:1943 core/models.py:1943
|
||||
#: build/lib/core/models.py:1945 core/models.py:1945
|
||||
msgid "Reactions"
|
||||
msgstr "Реакції"
|
||||
|
||||
#: build/lib/core/models.py:1953 core/models.py:1953
|
||||
#: build/lib/core/models.py:1955 core/models.py:1955
|
||||
msgid "email address"
|
||||
msgstr "електронна адреса"
|
||||
|
||||
#: build/lib/core/models.py:1972 core/models.py:1972
|
||||
#: build/lib/core/models.py:1974 core/models.py:1974
|
||||
msgid "Document invitation"
|
||||
msgstr "Запрошення до редагування документа"
|
||||
|
||||
#: build/lib/core/models.py:1973 core/models.py:1973
|
||||
#: build/lib/core/models.py:1975 core/models.py:1975
|
||||
msgid "Document invitations"
|
||||
msgstr "Запрошення до редагування документів"
|
||||
|
||||
#: build/lib/core/models.py:1993 core/models.py:1993
|
||||
#: build/lib/core/models.py:1995 core/models.py:1995
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "Ця електронна пошта вже пов'язана з зареєстрованим користувачем."
|
||||
|
||||
#: build/lib/impress/settings.py:808 impress/settings.py:808
|
||||
#: build/lib/impress/settings.py:837 impress/settings.py:837
|
||||
msgid "Docs AI"
|
||||
msgstr "Docs ШІ"
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-04-02 09:37+0000\n"
|
||||
"PO-Revision-Date: 2026-04-08 13:28\n"
|
||||
"POT-Creation-Date: 2026-04-30 12:37+0000\n"
|
||||
"PO-Revision-Date: 2026-04-30 13:05\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Chinese Simplified\n"
|
||||
"Language: zh_CN\n"
|
||||
@@ -17,28 +17,28 @@ msgstr ""
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:30 core/admin.py:30
|
||||
#: build/lib/core/admin.py:33 core/admin.py:33
|
||||
msgid "Personal info"
|
||||
msgstr "個人資訊"
|
||||
|
||||
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
|
||||
#: core/admin.py:161
|
||||
#: build/lib/core/admin.py:46 build/lib/core/admin.py:166 core/admin.py:46
|
||||
#: core/admin.py:166
|
||||
msgid "Permissions"
|
||||
msgstr "權限"
|
||||
|
||||
#: build/lib/core/admin.py:55 core/admin.py:55
|
||||
#: build/lib/core/admin.py:58 core/admin.py:58
|
||||
msgid "Important dates"
|
||||
msgstr "重要日期"
|
||||
|
||||
#: build/lib/core/admin.py:112 core/admin.py:112
|
||||
#: build/lib/core/admin.py:117 core/admin.py:117
|
||||
msgid "Import job created and queued."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:116 core/admin.py:116
|
||||
#: build/lib/core/admin.py:121 core/admin.py:121
|
||||
msgid "Process selected user reconciliations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:171 core/admin.py:171
|
||||
#: build/lib/core/admin.py:176 core/admin.py:176
|
||||
msgid "Tree structure"
|
||||
msgstr "樹狀結構"
|
||||
|
||||
@@ -62,24 +62,24 @@ msgstr "已隱藏"
|
||||
msgid "Favorite"
|
||||
msgstr "我的最愛"
|
||||
|
||||
#: build/lib/core/api/serializers.py:544 core/api/serializers.py:544
|
||||
#: build/lib/core/api/serializers.py:507 core/api/serializers.py:507
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "已代表您建立新文件!"
|
||||
|
||||
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
|
||||
#: build/lib/core/api/serializers.py:511 core/api/serializers.py:511
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr "您已獲得新文件的所有權:"
|
||||
|
||||
#: build/lib/core/api/serializers.py:584 core/api/serializers.py:584
|
||||
#: build/lib/core/api/serializers.py:547 core/api/serializers.py:547
|
||||
msgid "This field is required."
|
||||
msgstr "此欄位為必填。"
|
||||
|
||||
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
|
||||
#: build/lib/core/api/serializers.py:558 core/api/serializers.py:558
|
||||
#, python-format
|
||||
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
msgstr "根據父文件設定,不允許連結範圍「%(link_reach)s」。"
|
||||
|
||||
#: build/lib/core/api/viewsets.py:1312 core/api/viewsets.py:1312
|
||||
#: build/lib/core/api/viewsets.py:1299 core/api/viewsets.py:1299
|
||||
#, python-brace-format
|
||||
msgid "copy of {title}"
|
||||
msgstr "{title} 的副本"
|
||||
@@ -375,151 +375,151 @@ msgstr "文件"
|
||||
msgid "Documents"
|
||||
msgstr "文件"
|
||||
|
||||
#: build/lib/core/models.py:940 build/lib/core/models.py:1345
|
||||
#: core/models.py:940 core/models.py:1345
|
||||
#: build/lib/core/models.py:940 build/lib/core/models.py:1347
|
||||
#: core/models.py:940 core/models.py:1347
|
||||
msgid "Untitled Document"
|
||||
msgstr "未命名文件"
|
||||
|
||||
#: build/lib/core/models.py:1346 core/models.py:1346
|
||||
#: build/lib/core/models.py:1348 core/models.py:1348
|
||||
msgid "Open"
|
||||
msgstr "開啟"
|
||||
|
||||
#: build/lib/core/models.py:1381 core/models.py:1381
|
||||
#: build/lib/core/models.py:1383 core/models.py:1383
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} 與您分享了一份文件!"
|
||||
|
||||
#: build/lib/core/models.py:1385 core/models.py:1385
|
||||
#: build/lib/core/models.py:1387 core/models.py:1387
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} 邀請您以「{role}」角色參與以下文件:"
|
||||
|
||||
#: build/lib/core/models.py:1391 core/models.py:1391
|
||||
#: build/lib/core/models.py:1393 core/models.py:1393
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} 與您分享了一份文件:{title}"
|
||||
|
||||
#: build/lib/core/models.py:1492 core/models.py:1492
|
||||
#: build/lib/core/models.py:1494 core/models.py:1494
|
||||
msgid "Document/user link trace"
|
||||
msgstr "文件/使用者連結追蹤"
|
||||
|
||||
#: build/lib/core/models.py:1493 core/models.py:1493
|
||||
#: build/lib/core/models.py:1495 core/models.py:1495
|
||||
msgid "Document/user link traces"
|
||||
msgstr "文件/使用者連結追蹤"
|
||||
|
||||
#: build/lib/core/models.py:1499 core/models.py:1499
|
||||
#: build/lib/core/models.py:1501 core/models.py:1501
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr "此文件/使用者已存在連結追蹤。"
|
||||
|
||||
#: build/lib/core/models.py:1522 core/models.py:1522
|
||||
#: build/lib/core/models.py:1524 core/models.py:1524
|
||||
msgid "Document favorite"
|
||||
msgstr "文件收藏"
|
||||
|
||||
#: build/lib/core/models.py:1523 core/models.py:1523
|
||||
#: build/lib/core/models.py:1525 core/models.py:1525
|
||||
msgid "Document favorites"
|
||||
msgstr "文件收藏"
|
||||
|
||||
#: build/lib/core/models.py:1529 core/models.py:1529
|
||||
#: build/lib/core/models.py:1531 core/models.py:1531
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr "此使用者已將此文件加入收藏。"
|
||||
|
||||
#: build/lib/core/models.py:1551 core/models.py:1551
|
||||
#: build/lib/core/models.py:1553 core/models.py:1553
|
||||
msgid "Document/user relation"
|
||||
msgstr "文件/使用者關聯"
|
||||
|
||||
#: build/lib/core/models.py:1552 core/models.py:1552
|
||||
#: build/lib/core/models.py:1554 core/models.py:1554
|
||||
msgid "Document/user relations"
|
||||
msgstr "文件/使用者關聯"
|
||||
|
||||
#: build/lib/core/models.py:1558 core/models.py:1558
|
||||
#: build/lib/core/models.py:1560 core/models.py:1560
|
||||
msgid "This user is already in this document."
|
||||
msgstr "此使用者已在此文件中。"
|
||||
|
||||
#: build/lib/core/models.py:1564 core/models.py:1564
|
||||
#: build/lib/core/models.py:1566 core/models.py:1566
|
||||
msgid "This team is already in this document."
|
||||
msgstr "此團隊已在此文件中。"
|
||||
|
||||
#: build/lib/core/models.py:1570 core/models.py:1570
|
||||
#: build/lib/core/models.py:1572 core/models.py:1572
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "必須設定使用者或團隊其中之一,不能同時設定兩者。"
|
||||
|
||||
#: build/lib/core/models.py:1721 core/models.py:1721
|
||||
#: build/lib/core/models.py:1723 core/models.py:1723
|
||||
msgid "Document ask for access"
|
||||
msgstr "要求文件存取權"
|
||||
|
||||
#: build/lib/core/models.py:1722 core/models.py:1722
|
||||
#: build/lib/core/models.py:1724 core/models.py:1724
|
||||
msgid "Document ask for accesses"
|
||||
msgstr "要求文件存取權"
|
||||
|
||||
#: build/lib/core/models.py:1728 core/models.py:1728
|
||||
#: build/lib/core/models.py:1730 core/models.py:1730
|
||||
msgid "This user has already asked for access to this document."
|
||||
msgstr "此使用者已要求過存取此文件的權限。"
|
||||
|
||||
#: build/lib/core/models.py:1785 core/models.py:1785
|
||||
#: build/lib/core/models.py:1787 core/models.py:1787
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to a document!"
|
||||
msgstr "{name} 想要存取文件!"
|
||||
|
||||
#: build/lib/core/models.py:1789 core/models.py:1789
|
||||
#: build/lib/core/models.py:1791 core/models.py:1791
|
||||
#, python-brace-format
|
||||
msgid "{name} would like access to the following document:"
|
||||
msgstr "{name} 想要存取以下文件:"
|
||||
|
||||
#: build/lib/core/models.py:1795 core/models.py:1795
|
||||
#: build/lib/core/models.py:1797 core/models.py:1797
|
||||
#, python-brace-format
|
||||
msgid "{name} is asking for access to the document: {title}"
|
||||
msgstr "{name} 正要求存取文件:{title}"
|
||||
|
||||
#: build/lib/core/models.py:1837 core/models.py:1837
|
||||
#: build/lib/core/models.py:1839 core/models.py:1839
|
||||
msgid "Thread"
|
||||
msgstr "對話串"
|
||||
|
||||
#: build/lib/core/models.py:1838 core/models.py:1838
|
||||
#: build/lib/core/models.py:1840 core/models.py:1840
|
||||
msgid "Threads"
|
||||
msgstr "對話串"
|
||||
|
||||
#: build/lib/core/models.py:1841 build/lib/core/models.py:1893
|
||||
#: core/models.py:1841 core/models.py:1893
|
||||
#: build/lib/core/models.py:1843 build/lib/core/models.py:1895
|
||||
#: core/models.py:1843 core/models.py:1895
|
||||
msgid "Anonymous"
|
||||
msgstr "匿名"
|
||||
|
||||
#: build/lib/core/models.py:1888 core/models.py:1888
|
||||
#: build/lib/core/models.py:1890 core/models.py:1890
|
||||
msgid "Comment"
|
||||
msgstr "評論"
|
||||
|
||||
#: build/lib/core/models.py:1889 core/models.py:1889
|
||||
#: build/lib/core/models.py:1891 core/models.py:1891
|
||||
msgid "Comments"
|
||||
msgstr "評論"
|
||||
|
||||
#: build/lib/core/models.py:1938 core/models.py:1938
|
||||
#: build/lib/core/models.py:1940 core/models.py:1940
|
||||
msgid "This emoji has already been reacted to this comment."
|
||||
msgstr "此評論已標記過此表情符號。"
|
||||
|
||||
#: build/lib/core/models.py:1942 core/models.py:1942
|
||||
#: build/lib/core/models.py:1944 core/models.py:1944
|
||||
msgid "Reaction"
|
||||
msgstr "回應"
|
||||
|
||||
#: build/lib/core/models.py:1943 core/models.py:1943
|
||||
#: build/lib/core/models.py:1945 core/models.py:1945
|
||||
msgid "Reactions"
|
||||
msgstr "回應"
|
||||
|
||||
#: build/lib/core/models.py:1953 core/models.py:1953
|
||||
#: build/lib/core/models.py:1955 core/models.py:1955
|
||||
msgid "email address"
|
||||
msgstr "電子郵件地址"
|
||||
|
||||
#: build/lib/core/models.py:1972 core/models.py:1972
|
||||
#: build/lib/core/models.py:1974 core/models.py:1974
|
||||
msgid "Document invitation"
|
||||
msgstr "文件邀請"
|
||||
|
||||
#: build/lib/core/models.py:1973 core/models.py:1973
|
||||
#: build/lib/core/models.py:1975 core/models.py:1975
|
||||
msgid "Document invitations"
|
||||
msgstr "文件邀請"
|
||||
|
||||
#: build/lib/core/models.py:1993 core/models.py:1993
|
||||
#: build/lib/core/models.py:1995 core/models.py:1995
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr "此電子郵件地址已與已註冊使用者關聯。"
|
||||
|
||||
#: build/lib/impress/settings.py:808 impress/settings.py:808
|
||||
#: build/lib/impress/settings.py:837 impress/settings.py:837
|
||||
msgid "Docs AI"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "impress"
|
||||
version = "4.8.6"
|
||||
version = "5.0.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -26,7 +26,7 @@ readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"beautifulsoup4==4.14.3",
|
||||
"boto3==1.42.59",
|
||||
"boto3==1.42.93",
|
||||
"Brotli==1.2.0",
|
||||
"celery[redis]==5.5.3",
|
||||
"django-configurations==2.5.1",
|
||||
@@ -34,37 +34,39 @@ dependencies = [
|
||||
"django-countries==8.2.0",
|
||||
"django-csp==4.0",
|
||||
"django-filter==25.2",
|
||||
"django-lasuite[all]==0.0.24",
|
||||
"django-lasuite[all]==0.0.26",
|
||||
"django-parler==2.3",
|
||||
"django-redis==6.0.0",
|
||||
"django-storages[s3]==1.14.6",
|
||||
"django-timezone-field>=5.1",
|
||||
"django<6.0.0",
|
||||
"django-treebeard<5.0.0",
|
||||
"djangorestframework==3.16.1",
|
||||
"djangorestframework==3.17.1",
|
||||
"django-waffle==5.0.0",
|
||||
"drf_spectacular==0.29.0",
|
||||
"dockerflow==2026.1.26",
|
||||
"dockerflow==2026.3.4",
|
||||
"easy_thumbnails==2.10.1",
|
||||
"emoji==2.15.0",
|
||||
"factory_boy==3.3.3",
|
||||
"gunicorn==25.1.0",
|
||||
"gunicorn==25.3.0",
|
||||
"jsonschema==4.26.0",
|
||||
"langfuse==3.11.2",
|
||||
"lxml==6.1.0",
|
||||
"markdown==3.10.2",
|
||||
"mistralai==1.12.4",
|
||||
"mozilla-django-oidc==5.0.2",
|
||||
"nested-multipart-parser==1.6.0",
|
||||
"openai==2.24.0",
|
||||
"openai==2.32.0",
|
||||
"psycopg[binary,pool]==3.3.3",
|
||||
"pycrdt==0.12.47",
|
||||
"pydantic==2.12.5",
|
||||
"pycrdt==0.12.50",
|
||||
"pydantic==2.13.3",
|
||||
"pydantic-ai-slim[openai,logfire,web]==1.58.0",
|
||||
"PyJWT==2.12.0",
|
||||
"PyJWT==2.12.1",
|
||||
"python-magic==0.4.27",
|
||||
"redis<6.0.0",
|
||||
"requests==2.33.0",
|
||||
"sentry-sdk==2.53.0",
|
||||
"uvicorn==0.41.0",
|
||||
"requests==2.33.1",
|
||||
"sentry-sdk==2.58.0",
|
||||
"uvicorn==0.45.0",
|
||||
"whitenoise==6.12.0",
|
||||
]
|
||||
|
||||
@@ -78,21 +80,21 @@ dependencies = [
|
||||
dev = [
|
||||
"django-extensions==4.1",
|
||||
"django-test-migrations==1.5.0",
|
||||
"drf-spectacular-sidecar==2026.3.1",
|
||||
"drf-spectacular-sidecar==2026.4.14",
|
||||
"freezegun==1.5.5",
|
||||
"ipdb==0.13.13",
|
||||
"ipython==9.10.0",
|
||||
"pyfakefs==6.1.3",
|
||||
"ipython==9.12.0",
|
||||
"pyfakefs==6.2.0",
|
||||
"pylint-django==2.7.0",
|
||||
"pylint<4.0.0",
|
||||
"pytest-cov==7.0.0",
|
||||
"pytest-cov==7.1.0",
|
||||
"pytest-django==4.12.0",
|
||||
"pytest==9.0.2",
|
||||
"pytest==9.0.3",
|
||||
"pytest-icdiff==0.9",
|
||||
"pytest-xdist==3.8.0",
|
||||
"responses==0.26.0",
|
||||
"ruff==0.15.4",
|
||||
"types-requests==2.32.4.20260107",
|
||||
"ruff==0.15.11",
|
||||
"types-requests==2.33.0.20260408",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
|
||||
@@ -60,7 +60,7 @@ COPY --from=impress-builder /home/frontend/apps/impress/out /app
|
||||
FROM ${FRONTEND_IMAGE} AS frontend-source
|
||||
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:alpine3.22 AS frontend-production
|
||||
FROM nginxinc/nginx-unprivileged:alpine3.23 AS frontend-production
|
||||
|
||||
# Upgrade system packages to install security updates
|
||||
USER root
|
||||
|
||||
@@ -66,6 +66,8 @@ if (process.env.IS_INSTANCE !== 'true') {
|
||||
name: 'Albert AI',
|
||||
color: '#8bc6ff',
|
||||
},
|
||||
AI_FEATURE_ENABLED: true,
|
||||
AI_FEATURE_BLOCKNOTE_ENABLED: true,
|
||||
});
|
||||
|
||||
await mockAIResponse(page);
|
||||
@@ -131,6 +133,8 @@ if (process.env.IS_INSTANCE !== 'true') {
|
||||
name: 'Albert AI',
|
||||
color: '#8bc6ff',
|
||||
},
|
||||
AI_FEATURE_ENABLED: true,
|
||||
AI_FEATURE_BLOCKNOTE_ENABLED: true,
|
||||
});
|
||||
|
||||
await mockAIResponse(page);
|
||||
@@ -166,6 +170,11 @@ if (process.env.IS_INSTANCE !== 'true') {
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await overrideConfig(page, {
|
||||
AI_FEATURE_ENABLED: true,
|
||||
AI_FEATURE_LEGACY_ENABLED: true,
|
||||
});
|
||||
|
||||
await page.route(/.*\/ai-translate\//, async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method().includes('POST')) {
|
||||
@@ -229,6 +238,11 @@ if (process.env.IS_INSTANCE !== 'true') {
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await overrideConfig(page, {
|
||||
AI_FEATURE_ENABLED: true,
|
||||
AI_FEATURE_LEGACY_ENABLED: true,
|
||||
});
|
||||
|
||||
await mockedDocument(page, {
|
||||
accesses: [
|
||||
{
|
||||
@@ -303,6 +317,11 @@ if (process.env.IS_INSTANCE !== 'true') {
|
||||
});
|
||||
|
||||
test(`it checks ai_proxy ability`, async ({ page, browserName }) => {
|
||||
await overrideConfig(page, {
|
||||
AI_FEATURE_ENABLED: true,
|
||||
AI_FEATURE_LEGACY_ENABLED: true,
|
||||
});
|
||||
|
||||
await mockedDocument(page, {
|
||||
accesses: [
|
||||
{
|
||||
|
||||
@@ -65,19 +65,6 @@ test.describe('Doc Editor', () => {
|
||||
toolbar.locator('button[data-test="createLink"]'),
|
||||
).toBeVisible();
|
||||
|
||||
/**
|
||||
* Because of how Posthog is loaded and how auth session are
|
||||
* saved, this assertion is not reliable on test instances
|
||||
* We will dedicate a testcase to check the AI features
|
||||
* on test instances with a specific setup
|
||||
*/
|
||||
if (process.env.IS_INSTANCE !== 'true') {
|
||||
// eslint-disable-next-line playwright/no-conditional-expect
|
||||
await expect(
|
||||
toolbar.getByRole('button', { name: 'Ask AI' }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
await expect(
|
||||
toolbar.locator('button[data-test="comment-toolbar-button"]'),
|
||||
).toBeVisible();
|
||||
@@ -109,7 +96,6 @@ test.describe('Doc Editor', () => {
|
||||
|
||||
await image.click();
|
||||
|
||||
await expect(toolbar.getByRole('button', { name: 'Ask AI' })).toBeHidden();
|
||||
await expect(
|
||||
toolbar.locator('button[data-test="comment-toolbar-button"]'),
|
||||
).toBeHidden();
|
||||
@@ -702,25 +688,23 @@ test.describe('Doc Editor', () => {
|
||||
test('it checks interlink feature', async ({ page, browserName }) => {
|
||||
const [randomDoc] = await createDoc(page, 'doc-interlink', browserName, 1);
|
||||
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
const { name: docChild1 } = await createRootSubPage(
|
||||
page,
|
||||
browserName,
|
||||
'doc-interlink-child-1',
|
||||
);
|
||||
|
||||
await verifyDocName(page, docChild1);
|
||||
|
||||
const { name: docChild2 } = await createRootSubPage(
|
||||
page,
|
||||
browserName,
|
||||
'doc-interlink-child-2',
|
||||
);
|
||||
|
||||
await verifyDocName(page, docChild2);
|
||||
|
||||
const treeRow = await getTreeRow(page, docChild2);
|
||||
|
||||
// To let the time for the emoji-picker to load
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await treeRow.locator('.--docs--doc-icon').click();
|
||||
await page.getByRole('button', { name: '😀' }).first().click();
|
||||
|
||||
|
||||
@@ -104,6 +104,9 @@ test.describe('Doc Header', () => {
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
|
||||
await writeInEditor({ page, text: 'Hello Content' });
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
await updateShareLink(page, 'Public', 'Editing');
|
||||
|
||||
@@ -116,7 +119,9 @@ test.describe('Doc Header', () => {
|
||||
docTitle,
|
||||
});
|
||||
|
||||
// Wait for other page to sync
|
||||
await expect(otherPage.getByText('Hello Content')).toBeVisible();
|
||||
|
||||
// Wait for other page to broadcast sync
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
@@ -124,9 +129,8 @@ test.describe('Doc Header', () => {
|
||||
await expect(elTitle).toBeVisible();
|
||||
await elTitle.fill('Hello World');
|
||||
await elTitle.blur();
|
||||
await verifyDocName(page, 'Hello World');
|
||||
|
||||
// Wait for other page to sync
|
||||
// Wait for other page to broadcast sync
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Check other user page
|
||||
@@ -144,6 +148,36 @@ test.describe('Doc Header', () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
test('it pastes plain text in the title without keeping formatting', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await createDoc(page, 'doc-title-paste', browserName, 1);
|
||||
|
||||
const docTitle = page.getByRole('textbox', { name: 'Document title' });
|
||||
await docTitle.click();
|
||||
await page.keyboard.press('Control+a');
|
||||
|
||||
await page.evaluate(() => {
|
||||
const el = document.querySelector('[aria-label="Document title"]');
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dt = new DataTransfer();
|
||||
dt.setData('text/plain', 'Pasted plain text');
|
||||
dt.setData('text/html', '<b><em>Pasted plain text</em></b>');
|
||||
el.dispatchEvent(
|
||||
new ClipboardEvent('paste', { clipboardData: dt, bubbles: true }),
|
||||
);
|
||||
});
|
||||
|
||||
await docTitle.blur();
|
||||
await expect(docTitle).toHaveText('Pasted plain text');
|
||||
// Ensure formatting tags from text/html were not inserted.
|
||||
await expect(docTitle.locator('b, em, strong, i')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('it updates the title doc adding a leading emoji', async ({
|
||||
page,
|
||||
browserName,
|
||||
@@ -522,7 +556,6 @@ test.describe('Doc Header', () => {
|
||||
name: 'Share',
|
||||
exact: true,
|
||||
});
|
||||
await expect(shareButton).toBeVisible();
|
||||
|
||||
await shareButton.click();
|
||||
await page.getByRole('button', { name: 'Copy link' }).click();
|
||||
@@ -533,10 +566,8 @@ test.describe('Doc Header', () => {
|
||||
);
|
||||
const clipboardContent = await handle.jsonValue();
|
||||
|
||||
const origin = await page.evaluate(() => window.location.origin);
|
||||
expect(clipboardContent.trim()).toMatch(
|
||||
`${origin}/docs/mocked-document-id/`,
|
||||
);
|
||||
const url = page.url();
|
||||
expect(clipboardContent.trim()).toMatch(url);
|
||||
});
|
||||
|
||||
test('it pins a document', async ({ page, browserName }) => {
|
||||
|
||||
@@ -31,6 +31,8 @@ test.describe('Inherited share accesses', () => {
|
||||
.getByRole('link')
|
||||
.click();
|
||||
|
||||
await page.getByRole('button', { name: 'close' }).first().click();
|
||||
|
||||
await verifyDocName(page, parentTitle);
|
||||
});
|
||||
|
||||
|
||||
@@ -185,23 +185,23 @@ test.describe('Doc Version', () => {
|
||||
|
||||
await page.getByLabel('Restore', { exact: true }).click();
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
const mainEditor = page.getByLabel('Document editor');
|
||||
|
||||
await expect(editor.getByText('Hello')).toBeVisible();
|
||||
await expect(editor.getByText('World')).toBeHidden();
|
||||
await expect(mainEditor.getByText('Hello')).toBeVisible();
|
||||
await expect(mainEditor.getByText('World')).toBeHidden();
|
||||
|
||||
// The old comment is not restored
|
||||
await expect(editor.getByText('Hello')).toHaveCSS(
|
||||
await expect(mainEditor.getByText('Hello')).toHaveCSS(
|
||||
'background-color',
|
||||
'rgba(0, 0, 0, 0)',
|
||||
);
|
||||
|
||||
// We can add a new comment
|
||||
await editor.getByText('Hello').selectText();
|
||||
await mainEditor.getByText('Hello').selectText();
|
||||
await page.getByRole('button', { name: 'Add comment' }).click();
|
||||
|
||||
await thread.getByRole('paragraph').first().fill('This is a comment');
|
||||
await thread.locator('[data-test="save"]').click();
|
||||
await expect(editor.getByText('Hello')).toHaveClass('bn-thread-mark');
|
||||
await expect(mainEditor.getByText('Hello')).toHaveClass('bn-thread-mark');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -153,7 +153,8 @@ test.describe('Help feature', () => {
|
||||
theme_customization: {
|
||||
onboarding: {
|
||||
enabled: true,
|
||||
learn_more_url: 'https://example.com/learn-more',
|
||||
learn_more_url: 'http://localhost:3000/learn-more',
|
||||
ready_template_url: 'http://localhost:3000/ready-template',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -184,18 +185,19 @@ test.describe('Help feature', () => {
|
||||
'0',
|
||||
);
|
||||
|
||||
await page.getByTestId('onboarding-step-3').click();
|
||||
await expect(page.getByTestId('onboarding-step-3')).toHaveAttribute(
|
||||
'tabindex',
|
||||
'0',
|
||||
);
|
||||
const step3 = page.getByTestId('onboarding-step-3');
|
||||
await step3.click();
|
||||
await expect(step3).toHaveAttribute('tabindex', '0');
|
||||
await expect(
|
||||
step3.getByRole('link', { name: 'ready-made template' }),
|
||||
).toHaveAttribute('href', 'http://localhost:3000/ready-template');
|
||||
|
||||
const learnMoreLink = page.getByRole('link', {
|
||||
name: 'Learn more docs features',
|
||||
});
|
||||
await expect(learnMoreLink).toHaveAttribute(
|
||||
'href',
|
||||
'https://example.com/learn-more',
|
||||
'http://localhost:3000/learn-more',
|
||||
);
|
||||
await learnMoreLink.click();
|
||||
|
||||
@@ -241,6 +243,16 @@ test.describe('Help feature', () => {
|
||||
await expect(
|
||||
modal.getByRole('button', { name: /Suivant/i }),
|
||||
).toBeVisible();
|
||||
await modal
|
||||
.getByText(/Tirez parti de la bibliothèque de contenu/)
|
||||
.first()
|
||||
.click();
|
||||
await expect(
|
||||
modal.getByText(/Commencez à partir de/).first(),
|
||||
).toBeVisible();
|
||||
await expect(modal.getByRole('link')).toHaveText(
|
||||
"modèles prêts à l'emploi",
|
||||
);
|
||||
});
|
||||
|
||||
test('Modal is displayed automatically on first connection', async ({
|
||||
|
||||
@@ -131,7 +131,7 @@ test.describe('Language', () => {
|
||||
await waitForLanguageSwitch(page, TestLanguage.French);
|
||||
|
||||
// Check for French 404 response
|
||||
await check404Response('Pas trouvé.');
|
||||
await check404Response('Non trouvé.');
|
||||
});
|
||||
|
||||
test('it check translations of the slash menu when changing language', async ({
|
||||
|
||||
@@ -13,8 +13,8 @@ export const CONFIG = {
|
||||
name: 'Docs AI',
|
||||
color: '#8bc6ff',
|
||||
},
|
||||
AI_FEATURE_ENABLED: true,
|
||||
AI_FEATURE_BLOCKNOTE_ENABLED: true,
|
||||
AI_FEATURE_ENABLED: false,
|
||||
AI_FEATURE_BLOCKNOTE_ENABLED: false,
|
||||
AI_FEATURE_LEGACY_ENABLED: true,
|
||||
API_USERS_SEARCH_QUERY_MIN_LENGTH: 3,
|
||||
CRISP_WEBSITE_ID: null,
|
||||
@@ -131,6 +131,13 @@ export const createDoc = async (
|
||||
await openHeaderMenu(page);
|
||||
}
|
||||
|
||||
const responsePromiseCreateDoc = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/api/v1.0/documents/') &&
|
||||
response.status() === 201 &&
|
||||
response.request().method() === 'POST',
|
||||
);
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'New doc',
|
||||
@@ -142,34 +149,46 @@ export const createDoc = async (
|
||||
waitUntil: 'networkidle',
|
||||
});
|
||||
|
||||
const responseCreateDoc = await responsePromiseCreateDoc;
|
||||
expect(responseCreateDoc.ok()).toBeTruthy();
|
||||
const { id: docId } = (await responseCreateDoc.json()) as { id: string };
|
||||
|
||||
const responsePromiseUpdateDoc = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes(`/api/v1.0/documents/${docId}`) &&
|
||||
response.status() === 200 &&
|
||||
response.request().method() === 'PATCH',
|
||||
);
|
||||
|
||||
const input = page.getByLabel('Document title');
|
||||
await expect(input).toBeVisible();
|
||||
await expect(input).toHaveText('');
|
||||
await expect(input).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(input).toHaveText('', {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await input.fill(randomDocs[i]);
|
||||
await input.blur();
|
||||
void input.blur();
|
||||
|
||||
const responseUpdateDoc = await responsePromiseUpdateDoc;
|
||||
expect(responseUpdateDoc.ok()).toBeTruthy();
|
||||
}
|
||||
|
||||
return randomDocs;
|
||||
};
|
||||
|
||||
export const verifyDocName = async (page: Page, docName: string) => {
|
||||
await expect(
|
||||
page.getByLabel('It is the card information about the document.'),
|
||||
).toBeVisible({
|
||||
const card = page.getByLabel(
|
||||
'It is the card information about the document.',
|
||||
);
|
||||
await expect(card).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
/*replace toHaveText with toContainText to handle cases where emojis or other characters might be added*/
|
||||
try {
|
||||
await expect(
|
||||
page.getByRole('textbox', { name: 'Document title' }),
|
||||
).toContainText(docName, {
|
||||
timeout: 3000,
|
||||
});
|
||||
} catch {
|
||||
await expect(page.getByRole('heading', { name: docName })).toBeVisible();
|
||||
}
|
||||
await expect(card).toHaveText(new RegExp(docName), {
|
||||
timeout: 10000,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGridRow = async (page: Page, title: string) => {
|
||||
@@ -231,11 +250,9 @@ export const updateDocTitle = async (page: Page, title: string) => {
|
||||
const input = page.getByRole('textbox', { name: 'Document title' });
|
||||
await expect(input).toHaveText('');
|
||||
await expect(input).toBeVisible();
|
||||
await input.click();
|
||||
await input.fill(title, {
|
||||
force: true,
|
||||
});
|
||||
await input.click();
|
||||
await input.blur();
|
||||
await verifyDocName(page, title);
|
||||
};
|
||||
@@ -250,22 +267,18 @@ export const waitForResponseCreateDoc = (page: Page) => {
|
||||
};
|
||||
|
||||
export const mockedDocument = async (page: Page, data: object) => {
|
||||
await page.route(/\**\/documents\/\**/, async (route) => {
|
||||
// document/[ID]/ or document/[ID]/tree/ routes
|
||||
let uuid: string | undefined;
|
||||
await page.route(/.*\/documents\/[^/]+\/(?:$|tree\/.*)/, async (route) => {
|
||||
const request = route.request();
|
||||
if (
|
||||
request.method().includes('GET') &&
|
||||
!request.url().includes('page=') &&
|
||||
!request.url().includes('versions') &&
|
||||
!request.url().includes('accesses') &&
|
||||
!request.url().includes('invitations')
|
||||
) {
|
||||
if (request.method().includes('GET') && !request.url().includes('page=')) {
|
||||
uuid = request.url().match(/\/documents\/([^/]+)\//)?.[1];
|
||||
const { abilities, ...doc } = data as unknown as {
|
||||
abilities?: Record<string, unknown>;
|
||||
};
|
||||
await route.fulfill({
|
||||
json: {
|
||||
id: 'mocked-document-id',
|
||||
content: '',
|
||||
id: uuid,
|
||||
title: 'Mocked document',
|
||||
path: '000000',
|
||||
abilities: {
|
||||
@@ -299,6 +312,19 @@ export const mockedDocument = async (page: Page, data: object) => {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await page.route(/.*\/documents\/[^/]+\/content\/$/, async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method().includes('GET')) {
|
||||
await route.fulfill({
|
||||
body: '',
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
return uuid;
|
||||
};
|
||||
|
||||
export const mockedListDocs = async (page: Page, data: object[] = []) => {
|
||||
|
||||
@@ -27,25 +27,16 @@ export const overrideDocContent = async ({
|
||||
browserName: BrowserName;
|
||||
}) => {
|
||||
// Override content prop with assets/base-content-test-pdf.txt
|
||||
await page.route(/\**\/documents\/\**/, async (route) => {
|
||||
await page.route(/.*\/documents\/[^/]+\/content\/$/, async (route) => {
|
||||
const request = route.request();
|
||||
if (
|
||||
request.method().includes('GET') &&
|
||||
!request.url().includes('page=') &&
|
||||
!request.url().includes('versions') &&
|
||||
!request.url().includes('accesses') &&
|
||||
!request.url().includes('invitations')
|
||||
) {
|
||||
if (request.method() === 'GET') {
|
||||
const response = await route.fetch();
|
||||
const json = await response.json();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
json.content = fs.readFileSync(
|
||||
path.join(__dirname, 'assets/base-content-test-pdf.txt'),
|
||||
'utf-8',
|
||||
);
|
||||
void route.fulfill({
|
||||
response,
|
||||
body: JSON.stringify(json),
|
||||
body: fs.readFileSync(
|
||||
path.join(__dirname, 'assets/base-content-test-pdf.txt'),
|
||||
'utf-8',
|
||||
),
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-e2e",
|
||||
"version": "4.8.6",
|
||||
"version": "5.0.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.58.2",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@types/node": "*",
|
||||
"@types/pdf-parse": "1.1.5",
|
||||
"eslint-plugin-docs": "*",
|
||||
@@ -24,7 +24,7 @@
|
||||
"dependencies": {
|
||||
"@types/pngjs": "6.0.5",
|
||||
"convert-stream": "1.0.2",
|
||||
"dotenv": "17.3.1",
|
||||
"dotenv": "17.4.2",
|
||||
"pdf-parse": "2.4.5",
|
||||
"pixelmatch": "7.1.0",
|
||||
"pngjs": "7.0.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"target": "es2020",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
@@ -6,6 +6,7 @@ const buildId = crypto.randomBytes(256).toString('hex').slice(0, 8);
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
allowedDevOrigins: ['docs.127.0.0.1.nip.io'],
|
||||
output: 'export',
|
||||
trailingSlash: true,
|
||||
images: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-impress",
|
||||
"version": "4.8.6",
|
||||
"version": "5.0.0",
|
||||
"repository": "https://github.com/suitenumerique/docs",
|
||||
"author": "DINUM",
|
||||
"license": "MIT",
|
||||
@@ -23,60 +23,60 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ag-media/react-pdf-table": "2.0.3",
|
||||
"@ai-sdk/openai": "3.0.47",
|
||||
"@blocknote/code-block": "0.47.3",
|
||||
"@blocknote/core": "0.47.3",
|
||||
"@blocknote/mantine": "0.47.3",
|
||||
"@blocknote/react": "0.47.3",
|
||||
"@blocknote/xl-ai": "0.47.3",
|
||||
"@blocknote/xl-docx-exporter": "0.47.3",
|
||||
"@blocknote/xl-multi-column": "0.47.3",
|
||||
"@blocknote/xl-odt-exporter": "0.47.3",
|
||||
"@blocknote/xl-pdf-exporter": "0.47.3",
|
||||
"@ai-sdk/openai": "3.0.53",
|
||||
"@blocknote/code-block": "0.49.0",
|
||||
"@blocknote/core": "0.49.0",
|
||||
"@blocknote/mantine": "0.49.0",
|
||||
"@blocknote/react": "0.49.0",
|
||||
"@blocknote/xl-ai": "0.49.0",
|
||||
"@blocknote/xl-docx-exporter": "0.49.0",
|
||||
"@blocknote/xl-multi-column": "0.49.0",
|
||||
"@blocknote/xl-odt-exporter": "0.49.0",
|
||||
"@blocknote/xl-pdf-exporter": "0.49.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.38",
|
||||
"@fontsource-variable/material-symbols-outlined": "5.2.42",
|
||||
"@fontsource/material-icons": "5.2.7",
|
||||
"@gouvfr-lasuite/cunningham-react": "4.2.0",
|
||||
"@gouvfr-lasuite/cunningham-react": "4.3.0",
|
||||
"@gouvfr-lasuite/integration": "1.0.3",
|
||||
"@gouvfr-lasuite/ui-kit": "0.19.10",
|
||||
"@gouvfr-lasuite/ui-kit": "0.20.1",
|
||||
"@hocuspocus/provider": "3.4.4",
|
||||
"@mantine/core": "8.3.18",
|
||||
"@mantine/hooks": "8.3.18",
|
||||
"@react-aria/live-announcer": "3.4.4",
|
||||
"@mantine/core": "9.0.2",
|
||||
"@mantine/hooks": "9.0.2",
|
||||
"@react-aria/live-announcer": "3.5.0",
|
||||
"@react-pdf/renderer": "4.3.1",
|
||||
"@sentry/nextjs": "10.45.0",
|
||||
"@tanstack/react-query": "5.95.0",
|
||||
"@sentry/nextjs": "10.49.0",
|
||||
"@tanstack/react-query": "5.99.2",
|
||||
"@tiptap/extensions": "*",
|
||||
"ai": "6.0.134",
|
||||
"ai": "6.0.168",
|
||||
"canvg": "4.0.3",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
"crisp-sdk-web": "1.0.27",
|
||||
"crisp-sdk-web": "1.1.1",
|
||||
"emoji-datasource-apple": "16.0.0",
|
||||
"emoji-mart": "5.6.0",
|
||||
"emoji-regex": "10.6.0",
|
||||
"i18next": "25.10.4",
|
||||
"i18next": "26.0.6",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"idb": "8.0.3",
|
||||
"lodash": "4.18.1",
|
||||
"luxon": "3.7.2",
|
||||
"next": "16.2.3",
|
||||
"posthog-js": "1.363.1",
|
||||
"next": "16.2.4",
|
||||
"posthog-js": "1.369.4",
|
||||
"react": "*",
|
||||
"react-aria-components": "1.16.0",
|
||||
"react-aria-components": "1.17.0",
|
||||
"react-dom": "*",
|
||||
"react-dropzone": "15.0.0",
|
||||
"react-i18next": "16.6.1",
|
||||
"react-i18next": "17.0.4",
|
||||
"react-intersection-observer": "10.0.3",
|
||||
"react-resizable-panels": "3.0.6",
|
||||
"react-select": "5.10.2",
|
||||
"styled-components": "6.3.12",
|
||||
"use-debounce": "10.1.0",
|
||||
"uuid": "13.0.0",
|
||||
"styled-components": "6.4.0",
|
||||
"use-debounce": "10.1.1",
|
||||
"uuid": "14.0.0",
|
||||
"y-protocols": "1.0.7",
|
||||
"yjs": "*",
|
||||
"zod": "4.3.6",
|
||||
@@ -84,7 +84,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@svgr/webpack": "8.1.0",
|
||||
"@tanstack/react-query-devtools": "5.95.0",
|
||||
"@tanstack/react-query-devtools": "5.99.2",
|
||||
"@testing-library/dom": "10.4.1",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
"@testing-library/react": "16.3.2",
|
||||
@@ -96,18 +96,18 @@
|
||||
"@types/react-dom": "*",
|
||||
"@vitejs/plugin-react": "6.0.1",
|
||||
"cross-env": "10.1.0",
|
||||
"dotenv": "17.3.1",
|
||||
"dotenv": "17.4.2",
|
||||
"eslint-plugin-docs": "*",
|
||||
"fetch-mock": "9.11.0",
|
||||
"jsdom": "29.0.1",
|
||||
"jsdom": "29.0.2",
|
||||
"node-fetch": "2.7.0",
|
||||
"prettier": "3.8.1",
|
||||
"prettier": "3.8.3",
|
||||
"stylelint": "16.26.1",
|
||||
"stylelint-config-standard": "39.0.1",
|
||||
"stylelint-prettier": "5.0.3",
|
||||
"typescript": "*",
|
||||
"vitest": "4.1.0",
|
||||
"webpack": "5.105.4",
|
||||
"vitest": "4.1.4",
|
||||
"webpack": "5.106.2",
|
||||
"workbox-webpack-plugin": "7.1.0"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22"
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Ref, forwardRef } from 'react';
|
||||
import { ComponentPropsWithRef, Ref, forwardRef } from 'react';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, BoxType } from './Box';
|
||||
import { Box, BoxProps } from './Box';
|
||||
|
||||
export type BoxButtonType = Omit<BoxType, 'ref'> & {
|
||||
disabled?: boolean;
|
||||
ref?: Ref<HTMLButtonElement>;
|
||||
};
|
||||
export type BoxButtonType = BoxProps &
|
||||
Omit<ComponentPropsWithRef<'button'>, keyof BoxProps | 'ref'> & {
|
||||
disabled?: boolean;
|
||||
ref?: Ref<HTMLButtonElement>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Styleless button that extends the Box component.
|
||||
@@ -59,7 +60,7 @@ const BoxButton = forwardRef<HTMLButtonElement, BoxButtonType>(
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
props.onClick?.(event as unknown as React.MouseEvent<HTMLDivElement>);
|
||||
props.onClick?.(event);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -34,9 +34,7 @@ export const TextStyled = styled(Box)<TextProps>`
|
||||
|
||||
const Text = forwardRef<HTMLElement, ComponentPropsWithRef<typeof TextStyled>>(
|
||||
(props, ref) => {
|
||||
return (
|
||||
<TextStyled ref={ref as React.Ref<HTMLDivElement>} as="span" {...props} />
|
||||
);
|
||||
return <TextStyled ref={ref} as="span" {...props} />;
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
Button,
|
||||
ButtonProps,
|
||||
Modal,
|
||||
ModalProps,
|
||||
ModalDefaultVariantProps,
|
||||
ModalSize,
|
||||
} from '@gouvfr-lasuite/cunningham-react';
|
||||
import { ReactNode, useEffect } from 'react';
|
||||
@@ -20,7 +20,7 @@ export type AlertModalProps = {
|
||||
title: string;
|
||||
cancelLabel?: string;
|
||||
confirmLabel?: string;
|
||||
} & Partial<ModalProps>;
|
||||
} & Partial<ModalDefaultVariantProps>;
|
||||
|
||||
export const AlertModal = ({
|
||||
cancelLabel,
|
||||
|
||||
@@ -49,7 +49,7 @@ export const SideModal = ({
|
||||
return (
|
||||
<>
|
||||
<SideModalStyle width={width} side={side} $css={$css} />
|
||||
<Modal {...modalProps} size={ModalSize.FULL}>
|
||||
<Modal {...modalProps} size={ModalSize.FULL} variant="default">
|
||||
{children}
|
||||
</Modal>
|
||||
</>
|
||||
|
||||
@@ -28,6 +28,7 @@ interface ThemeCustomization {
|
||||
onboarding?: {
|
||||
enabled: true;
|
||||
learn_more_url?: string;
|
||||
ready_template_url?: string;
|
||||
};
|
||||
translations?: Resource;
|
||||
waffle?: WaffleType;
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
|
||||
.c__modal__title {
|
||||
padding: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.c__modal__footer {
|
||||
|
||||
@@ -361,7 +361,7 @@
|
||||
--c--globals--font--weights--medium: 500;
|
||||
--c--globals--font--weights--bold: 600;
|
||||
--c--globals--font--weights--extrabold: 800;
|
||||
--c--globals--font--weights--black: 900;
|
||||
--c--globals--font--weights--black: 800;
|
||||
--c--globals--font--families--base:
|
||||
inter variable, roboto flex variable, sans-serif;
|
||||
--c--globals--font--families--accent:
|
||||
@@ -849,6 +849,18 @@
|
||||
--c--components--forms-checkbox--font-size: var(
|
||||
--c--globals--font--sizes--sm
|
||||
);
|
||||
--c--components--forms-input--border-radius: 4px;
|
||||
--c--components--forms-input--border-radius--hover: 4px;
|
||||
--c--components--forms-input--border-radius--focus: 4px;
|
||||
--c--components--forms-select--border-radius: 4px;
|
||||
--c--components--forms-select--border-radius--hover: 4px;
|
||||
--c--components--forms-select--border-radius--focus: 4px;
|
||||
--c--components--forms-textarea--border-radius: 4px;
|
||||
--c--components--forms-textarea--border-radius--hover: 4px;
|
||||
--c--components--forms-textarea--border-radius--focus: 4px;
|
||||
--c--components--forms-datepicker--border-radius: 4px;
|
||||
--c--components--forms-datepicker--border-radius--hover: 4px;
|
||||
--c--components--forms-datepicker--border-radius--focus: 4px;
|
||||
--c--components--badge--font-size: var(--c--globals--font--sizes--xs);
|
||||
--c--components--badge--border-radius: 12px;
|
||||
--c--components--badge--padding-inline: var(--c--globals--spacings--xs);
|
||||
@@ -1731,7 +1743,6 @@
|
||||
--c--globals--font--sizes--xs-alt: 3rem;
|
||||
--c--globals--font--weights--thin: 100;
|
||||
--c--globals--font--weights--extrabold: 800;
|
||||
--c--globals--font--weights--black: 900;
|
||||
--c--globals--font--families--accent:
|
||||
marianne, inter variable, roboto flex variable, sans-serif;
|
||||
--c--globals--font--families--base:
|
||||
@@ -2539,6 +2550,18 @@
|
||||
--c--components--forms-checkbox--font-size: var(
|
||||
--c--globals--font--sizes--sm
|
||||
);
|
||||
--c--components--forms-input--border-radius: 4px;
|
||||
--c--components--forms-input--border-radius--hover: 4px;
|
||||
--c--components--forms-input--border-radius--focus: 4px;
|
||||
--c--components--forms-select--border-radius: 4px;
|
||||
--c--components--forms-select--border-radius--hover: 4px;
|
||||
--c--components--forms-select--border-radius--focus: 4px;
|
||||
--c--components--forms-textarea--border-radius: 4px;
|
||||
--c--components--forms-textarea--border-radius--hover: 4px;
|
||||
--c--components--forms-textarea--border-radius--focus: 4px;
|
||||
--c--components--forms-datepicker--border-radius: 4px;
|
||||
--c--components--forms-datepicker--border-radius--hover: 4px;
|
||||
--c--components--forms-datepicker--border-radius--focus: 4px;
|
||||
--c--components--badge--font-size: var(--c--globals--font--sizes--xs);
|
||||
--c--components--badge--border-radius: 12px;
|
||||
--c--components--badge--padding-inline: var(--c--globals--spacings--xs);
|
||||
|
||||
@@ -372,7 +372,7 @@ export const tokens = {
|
||||
medium: 500,
|
||||
bold: 600,
|
||||
extrabold: 800,
|
||||
black: 900,
|
||||
black: 800,
|
||||
},
|
||||
families: {
|
||||
base: 'Inter Variable, Roboto Flex Variable, sans-serif',
|
||||
@@ -664,6 +664,26 @@ export const tokens = {
|
||||
'body--background-color-hover': '#F0F0F3',
|
||||
},
|
||||
'forms-checkbox': { 'font-size': '0.875rem' },
|
||||
'forms-input': {
|
||||
'border-radius': '4px',
|
||||
'border-radius--hover': '4px',
|
||||
'border-radius--focus': '4px',
|
||||
},
|
||||
'forms-select': {
|
||||
'border-radius': '4px',
|
||||
'border-radius--hover': '4px',
|
||||
'border-radius--focus': '4px',
|
||||
},
|
||||
'forms-textarea': {
|
||||
'border-radius': '4px',
|
||||
'border-radius--hover': '4px',
|
||||
'border-radius--focus': '4px',
|
||||
},
|
||||
'forms-datepicker': {
|
||||
'border-radius': '4px',
|
||||
'border-radius--hover': '4px',
|
||||
'border-radius--focus': '4px',
|
||||
},
|
||||
badge: {
|
||||
'font-size': '0.75rem',
|
||||
'border-radius': '12px',
|
||||
@@ -1334,7 +1354,7 @@ export const tokens = {
|
||||
'sm-alt': '3.5rem',
|
||||
'xs-alt': '3rem',
|
||||
},
|
||||
weights: { thin: 100, extrabold: 800, black: 900 },
|
||||
weights: { thin: 100, extrabold: 800 },
|
||||
families: {
|
||||
accent:
|
||||
'Marianne, Inter Variable, Roboto Flex Variable, sans-serif',
|
||||
@@ -1948,6 +1968,26 @@ export const tokens = {
|
||||
'body--background-color-hover': '#F0F0F3',
|
||||
},
|
||||
'forms-checkbox': { 'font-size': '0.875rem' },
|
||||
'forms-input': {
|
||||
'border-radius': '4px',
|
||||
'border-radius--hover': '4px',
|
||||
'border-radius--focus': '4px',
|
||||
},
|
||||
'forms-select': {
|
||||
'border-radius': '4px',
|
||||
'border-radius--hover': '4px',
|
||||
'border-radius--focus': '4px',
|
||||
},
|
||||
'forms-textarea': {
|
||||
'border-radius': '4px',
|
||||
'border-radius--hover': '4px',
|
||||
'border-radius--focus': '4px',
|
||||
},
|
||||
'forms-datepicker': {
|
||||
'border-radius': '4px',
|
||||
'border-radius--hover': '4px',
|
||||
'border-radius--focus': '4px',
|
||||
},
|
||||
badge: {
|
||||
'font-size': '0.75rem',
|
||||
'border-radius': '12px',
|
||||
|
||||
@@ -35,7 +35,7 @@ const initialState: ThemeStore = {
|
||||
colorsTokens: defaultTokens.globals.colors,
|
||||
componentTokens: defaultTokens.components,
|
||||
contextualTokens: defaultTokens.contextuals,
|
||||
currentTokens: tokens.themes[DEFAULT_THEME] as Partial<Tokens>,
|
||||
currentTokens: tokens.themes[DEFAULT_THEME],
|
||||
fontSizesTokens: defaultTokens.globals.font.sizes,
|
||||
setTheme: () => {},
|
||||
spacingsTokens: defaultTokens.globals.spacings,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React from 'react';
|
||||
import React, { ComponentPropsWithRef } from 'react';
|
||||
|
||||
import { Box, BoxType } from '@/components';
|
||||
import { Box, BoxProps } from '@/components';
|
||||
|
||||
type AvatarSvgProps = {
|
||||
initials: string;
|
||||
background: string;
|
||||
fontFamily?: string;
|
||||
} & BoxType;
|
||||
type AvatarSvgProps = BoxProps &
|
||||
Omit<ComponentPropsWithRef<'svg'>, keyof BoxProps> & {
|
||||
initials: string;
|
||||
background: string;
|
||||
fontFamily?: string;
|
||||
};
|
||||
|
||||
export const AvatarSvg: React.FC<AvatarSvgProps> = ({
|
||||
initials,
|
||||
|
||||
@@ -11,11 +11,15 @@ vi.mock('@/stores', () => ({
|
||||
useResponsiveStore: () => ({ isDesktop: false }),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/skeletons', () => ({
|
||||
useSkeletonStore: () => ({
|
||||
setIsSkeletonVisible: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
vi.mock('@/features/skeletons', async () => {
|
||||
const actual = await vi.importActual<any>('../../../skeletons');
|
||||
return {
|
||||
...actual,
|
||||
useSkeletonStore: () => ({
|
||||
setIsSkeletonVisible: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../doc-management', async () => {
|
||||
const actual = await vi.importActual<any>('../../doc-management');
|
||||
|
||||
@@ -15,14 +15,13 @@ import { useCreateBlockNote } from '@blocknote/react';
|
||||
import { HocuspocusProvider } from '@hocuspocus/provider';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
import type { Awareness } from 'y-protocols/awareness';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { Box, TextErrors } from '@/components';
|
||||
import { useConfig } from '@/core';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { Doc, useProviderStore } from '@/docs/doc-management';
|
||||
import { Doc } from '@/docs/doc-management';
|
||||
import { avatarUrlFromName, useAuth } from '@/features/auth';
|
||||
import { useAnalytics } from '@/libs/Analytics';
|
||||
|
||||
@@ -35,14 +34,14 @@ import {
|
||||
useUploadStatus,
|
||||
} from '../hook';
|
||||
import { useEditorStore } from '../stores';
|
||||
import { cssEditor } from '../styles';
|
||||
import { DocsEditorStyle } from '../styles';
|
||||
import { DocsBlockNoteEditor } from '../types';
|
||||
import { randomColor } from '../utils';
|
||||
|
||||
import BlockNoteAI from './AI';
|
||||
import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu';
|
||||
import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar';
|
||||
import { cssComments, useComments } from './comments/';
|
||||
import { DocsCommentsStyle, useComments } from './comments/';
|
||||
import {
|
||||
AccessibleImageBlock,
|
||||
CalloutBlock,
|
||||
@@ -88,13 +87,12 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
const { user } = useAuth();
|
||||
const { setEditor } = useEditorStore();
|
||||
const { themeTokens } = useCunninghamTheme();
|
||||
const { isSynced: isConnectedToCollabServer } = useProviderStore();
|
||||
const refEditorContainer = useRef<HTMLDivElement>(null);
|
||||
const canSeeComment = doc.abilities.comment;
|
||||
// Determine if comments should be visible in the UI
|
||||
const showComments = canSeeComment;
|
||||
|
||||
useSaveDoc(doc.id, provider.document, isConnectedToCollabServer);
|
||||
useSaveDoc(doc.id, provider.document);
|
||||
const { i18n, t } = useTranslation();
|
||||
const langLocalesBN =
|
||||
!i18n.resolvedLanguage || !(i18n.resolvedLanguage in localesBN)
|
||||
@@ -261,13 +259,12 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||
}, [setEditor, editor]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={refEditorContainer}
|
||||
$css={css`
|
||||
${cssEditor};
|
||||
${cssComments(showComments, currentUserAvatarUrl)}
|
||||
`}
|
||||
>
|
||||
<Box ref={refEditorContainer} $height="100%">
|
||||
<DocsEditorStyle />
|
||||
<DocsCommentsStyle
|
||||
canSeeComment={canSeeComment}
|
||||
currentUserAvatarUrl={currentUserAvatarUrl}
|
||||
/>
|
||||
{errorAttachment && (
|
||||
<Box $margin={{ bottom: 'big', top: 'none', horizontal: 'large' }}>
|
||||
<TextErrors
|
||||
@@ -351,12 +348,9 @@ export const BlockNoteReader = ({
|
||||
useHeadings(editor);
|
||||
|
||||
return (
|
||||
<Box
|
||||
$css={css`
|
||||
${cssEditor};
|
||||
${cssComments(false)}
|
||||
`}
|
||||
>
|
||||
<Box>
|
||||
<DocsEditorStyle />
|
||||
<DocsCommentsStyle canSeeComment={false} />
|
||||
<BlockNoteView
|
||||
className="--docs--main-editor"
|
||||
editor={editor}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import clsx from 'clsx';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { PropsWithChildren, useEffect, useState } from 'react';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, Loading } from '@/components';
|
||||
import { Box } from '@/components';
|
||||
import { DocHeader } from '@/docs/doc-header/';
|
||||
import {
|
||||
Doc,
|
||||
@@ -12,25 +13,29 @@ import {
|
||||
} from '@/docs/doc-management';
|
||||
import { TableContent } from '@/docs/doc-table-content/';
|
||||
import { useAuth } from '@/features/auth/';
|
||||
import { useSkeletonStore } from '@/features/skeletons';
|
||||
import { SkeletonEditorCore, useSkeletonStore } from '@/features/skeletons';
|
||||
import { useSkeletonFadeOut } from '@/features/skeletons/hooks/useFadeOut';
|
||||
import { useAnalytics } from '@/libs';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import { useCollaboration } from '../hook/useCollaboration';
|
||||
|
||||
import { BlockNoteEditor, BlockNoteReader } from './BlockNoteEditor';
|
||||
|
||||
const DOCS_EDITOR_CLASS = '--docs--doc-editor';
|
||||
|
||||
interface DocEditorContainerProps {
|
||||
docHeader: React.ReactNode;
|
||||
docEditor: React.ReactNode;
|
||||
isDeletedDoc: boolean;
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
export const DocEditorContainer = ({
|
||||
children,
|
||||
docHeader,
|
||||
docEditor,
|
||||
isDeletedDoc,
|
||||
readOnly,
|
||||
}: DocEditorContainerProps) => {
|
||||
}: PropsWithChildren<DocEditorContainerProps>) => {
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
|
||||
return (
|
||||
@@ -38,8 +43,8 @@ export const DocEditorContainer = ({
|
||||
<Box
|
||||
$maxWidth="868px"
|
||||
$width="100%"
|
||||
$height="100%"
|
||||
className="--docs--doc-editor"
|
||||
$flex="1"
|
||||
className={DOCS_EDITOR_CLASS}
|
||||
>
|
||||
<Box
|
||||
$padding={{ horizontal: isDesktop ? '54px' : 'base' }}
|
||||
@@ -65,7 +70,7 @@ export const DocEditorContainer = ({
|
||||
})}
|
||||
$height="100%"
|
||||
>
|
||||
{docEditor}
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -79,24 +84,21 @@ interface DocEditorProps {
|
||||
}
|
||||
|
||||
export const DocEditor = ({ doc }: DocEditorProps) => {
|
||||
useCollaboration(doc.id);
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const { provider, isReady } = useProviderStore();
|
||||
const { isEditable, isLoading } = useIsCollaborativeEditable(doc);
|
||||
const isDeletedDoc = !!doc.deleted_at;
|
||||
const readOnly =
|
||||
!doc.abilities.partial_update || !isEditable || isLoading || isDeletedDoc;
|
||||
const { setIsSkeletonVisible } = useSkeletonStore();
|
||||
const isProviderReady = isReady && provider;
|
||||
const { trackEvent } = useAnalytics();
|
||||
const [hasTracked, setHasTracked] = useState(false);
|
||||
const { authenticated } = useAuth();
|
||||
const isPublicDoc = getDocLinkReach(doc) === LinkReach.PUBLIC;
|
||||
const { setIsSkeletonVisible } = useSkeletonStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (isProviderReady) {
|
||||
setIsSkeletonVisible(false);
|
||||
}
|
||||
}, [isProviderReady, setIsSkeletonVisible]);
|
||||
setIsSkeletonVisible(false);
|
||||
}, [setIsSkeletonVisible, doc.id]);
|
||||
|
||||
/**
|
||||
* Track doc view event only once per doc change
|
||||
@@ -122,30 +124,57 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
|
||||
});
|
||||
}, [authenticated, hasTracked, isPublicDoc, trackEvent]);
|
||||
|
||||
if (!isProviderReady || provider?.configuration.name !== doc.id) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isDesktop && <TableContent />}
|
||||
{isDesktop && <TableContent selector={`.${DOCS_EDITOR_CLASS}`} />}
|
||||
<DocEditorContainer
|
||||
docHeader={<DocHeader doc={doc} />}
|
||||
docEditor={
|
||||
readOnly ? (
|
||||
<BlockNoteReader
|
||||
initialContent={provider.document.getXmlFragment(
|
||||
'document-store',
|
||||
)}
|
||||
docId={doc.id}
|
||||
/>
|
||||
) : (
|
||||
<BlockNoteEditor doc={doc} provider={provider} />
|
||||
)
|
||||
}
|
||||
isDeletedDoc={isDeletedDoc}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
>
|
||||
<DocCoreEditor doc={doc} readOnly={readOnly} />
|
||||
</DocEditorContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface DocCoreEditorProps {
|
||||
doc: Doc;
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
export const DocCoreEditor = ({ doc, readOnly }: DocCoreEditorProps) => {
|
||||
useCollaboration(doc.id);
|
||||
const { provider, isReady } = useProviderStore();
|
||||
const isProviderReady = isReady && provider;
|
||||
const showContent = !!(
|
||||
isProviderReady && provider?.configuration.name === doc.id
|
||||
);
|
||||
const { skeletonVisible, isFadingOut } = useSkeletonFadeOut(showContent);
|
||||
|
||||
if (
|
||||
skeletonVisible ||
|
||||
!isProviderReady ||
|
||||
provider?.configuration.name !== doc.id
|
||||
) {
|
||||
return (
|
||||
<SkeletonEditorCore
|
||||
isFadingOut={isFadingOut}
|
||||
$css={css`
|
||||
padding-top: 0px;
|
||||
`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
<BlockNoteReader
|
||||
initialContent={provider.document.getXmlFragment('document-store')}
|
||||
docId={doc.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <BlockNoteEditor doc={doc} provider={provider} />;
|
||||
};
|
||||
|
||||
@@ -67,7 +67,7 @@ export class DocsThreadStore extends ThreadStore {
|
||||
continue;
|
||||
}
|
||||
|
||||
const state = states.get(clientId) as
|
||||
const state:
|
||||
| {
|
||||
[DocsThreadStore.COMMENTS_PING]?: {
|
||||
at: number;
|
||||
@@ -76,7 +76,7 @@ export class DocsThreadStore extends ThreadStore {
|
||||
threadId: string;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
| undefined = states.get(clientId);
|
||||
|
||||
const ping = state?.commentsPing;
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { css } from 'styled-components';
|
||||
import { createGlobalStyle, css } from 'styled-components';
|
||||
|
||||
export const cssComments = (
|
||||
canSeeComment: boolean,
|
||||
currentUserAvatarUrl?: string,
|
||||
) => css`
|
||||
& .--docs--main-editor,
|
||||
& .--docs--main-editor .ProseMirror {
|
||||
export const DocsCommentsStyle = createGlobalStyle<{
|
||||
canSeeComment: boolean;
|
||||
currentUserAvatarUrl?: string;
|
||||
}>`
|
||||
.--docs--main-editor.bn-root,
|
||||
.--docs--main-editor.bn-root .ProseMirror {
|
||||
// Comments marks in the editor
|
||||
.bn-editor {
|
||||
// Resets blocknote comments styles
|
||||
@@ -14,30 +14,31 @@ export const cssComments = (
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
${canSeeComment &&
|
||||
css`
|
||||
.bn-thread-mark:not([data-orphan='true']) {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--c--contextuals--background--palette--yellow--tertiary) 40%,
|
||||
transparent
|
||||
);
|
||||
border-bottom: 2px solid
|
||||
var(--c--contextuals--background--palette--yellow--secondary);
|
||||
|
||||
mix-blend-mode: multiply;
|
||||
|
||||
transition:
|
||||
background-color var(--c--globals--transitions--duration),
|
||||
border-bottom-color var(--c--globals--transitions--duration);
|
||||
|
||||
&:has(.bn-thread-mark-selected) {
|
||||
background-color: var(
|
||||
--c--contextuals--background--palette--yellow--tertiary
|
||||
${({ canSeeComment }) =>
|
||||
canSeeComment &&
|
||||
css`
|
||||
.bn-thread-mark:not([data-orphan='true']) {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--c--contextuals--background--palette--yellow--tertiary) 40%,
|
||||
transparent
|
||||
);
|
||||
border-bottom: 2px solid
|
||||
var(--c--contextuals--background--palette--yellow--secondary);
|
||||
|
||||
mix-blend-mode: multiply;
|
||||
|
||||
transition:
|
||||
background-color var(--c--globals--transitions--duration),
|
||||
border-bottom-color var(--c--globals--transitions--duration);
|
||||
|
||||
&:has(.bn-thread-mark-selected) {
|
||||
background-color: var(
|
||||
--c--contextuals--background--palette--yellow--tertiary
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
`}
|
||||
`}
|
||||
|
||||
[data-show-selection] {
|
||||
color: HighlightText;
|
||||
@@ -82,6 +83,8 @@ export const cssComments = (
|
||||
|
||||
.bn-thread-comment {
|
||||
padding: 8px;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0px;
|
||||
|
||||
& .bn-editor {
|
||||
padding-left: var(--c--globals--spacings--lg);
|
||||
@@ -105,10 +108,14 @@ export const cssComments = (
|
||||
|
||||
// Top bar (Name / Date / Actions) when actions displayed
|
||||
&:has(.bn-comment-actions) {
|
||||
& > .mantine-Group-root {
|
||||
max-width: 70%;
|
||||
& > .mantine-Group-root:first-child {
|
||||
right: 0.3rem !important;
|
||||
top: 0.3rem !important;
|
||||
background: linear-gradient(
|
||||
to left,
|
||||
#fff 90%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.bn-menu-dropdown {
|
||||
@@ -124,7 +131,6 @@ export const cssComments = (
|
||||
|
||||
// Date
|
||||
span.mantine-focus-auto {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.bn-comment-actions {
|
||||
@@ -150,7 +156,8 @@ export const cssComments = (
|
||||
}
|
||||
|
||||
// Actions button edit comment
|
||||
.bn-container + .bn-comment-actions-wrapper {
|
||||
.bn-root + .bn-comment-actions-wrapper {
|
||||
margin-top: var(--c--globals--spacings--2xs);
|
||||
.bn-comment-actions {
|
||||
flex-direction: row-reverse;
|
||||
background: none;
|
||||
@@ -201,9 +208,8 @@ export const cssComments = (
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex: 0 0 26px;
|
||||
background-image: ${currentUserAvatarUrl
|
||||
? `url("${currentUserAvatarUrl}")`
|
||||
: 'none'};
|
||||
background-image: ${({ currentUserAvatarUrl }) =>
|
||||
currentUserAvatarUrl ? `url("${currentUserAvatarUrl}")` : 'none'};
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
|
||||
@@ -18,7 +18,7 @@ export const LinkSelected = ({
|
||||
isEditable,
|
||||
onUpdateTitle,
|
||||
}: LinkSelectedProps) => {
|
||||
const { data: doc } = useDoc({ id: docId, withoutContent: true });
|
||||
const { data: doc } = useDoc({ id: docId });
|
||||
|
||||
/**
|
||||
* Update the content title if the referenced doc title changes
|
||||
@@ -41,7 +41,7 @@ export const LinkSelected = ({
|
||||
const router = useRouter();
|
||||
const href = `/docs/${docId}/`;
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
// If ctrl or command is pressed, it opens a new tab. If shift is pressed, it opens a new window
|
||||
@@ -53,7 +53,7 @@ export const LinkSelected = ({
|
||||
};
|
||||
|
||||
// This triggers on middle-mouse click
|
||||
const handleAuxClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const handleAuxClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (e.button !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,13 @@ const withMultiColumnNoDropHandler = <
|
||||
});
|
||||
};
|
||||
|
||||
let modulesXL = undefined;
|
||||
type ModulesXL =
|
||||
| (Omit<typeof XLMultiColumn, 'withMultiColumn'> & {
|
||||
withMultiColumn: typeof withMultiColumnNoDropHandler;
|
||||
})
|
||||
| undefined;
|
||||
|
||||
let modulesXL: ModulesXL = undefined;
|
||||
if (process.env.NEXT_PUBLIC_PUBLISH_AS_MIT === 'false') {
|
||||
modulesXL = {
|
||||
...XLMultiColumn,
|
||||
@@ -47,10 +53,4 @@ if (process.env.NEXT_PUBLIC_PUBLISH_AS_MIT === 'false') {
|
||||
};
|
||||
}
|
||||
|
||||
type ModulesXL =
|
||||
| (Omit<typeof XLMultiColumn, 'withMultiColumn'> & {
|
||||
withMultiColumn: typeof withMultiColumnNoDropHandler;
|
||||
})
|
||||
| undefined;
|
||||
|
||||
export default modulesXL as ModulesXL;
|
||||
export default modulesXL;
|
||||
|
||||
@@ -43,7 +43,7 @@ describe('useSaveDoc', () => {
|
||||
|
||||
const addEventListenerSpy = vi.spyOn(window, 'addEventListener');
|
||||
|
||||
renderHook(() => useSaveDoc(docId, yDoc, true), {
|
||||
renderHook(() => useSaveDoc(docId, yDoc), {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
@@ -65,17 +65,16 @@ describe('useSaveDoc', () => {
|
||||
it('should save when there are local changes', async () => {
|
||||
vi.useFakeTimers();
|
||||
const yDoc = new Y.Doc();
|
||||
const docId = 'test-doc-id';
|
||||
const docId = self.crypto.randomUUID();
|
||||
|
||||
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
|
||||
fetchMock.patch(`http://test.jest/api/v1.0/documents/${docId}/content/`, {
|
||||
body: JSON.stringify({
|
||||
id: 'test-doc-id',
|
||||
id: docId,
|
||||
content: 'test-content',
|
||||
title: 'test-title',
|
||||
}),
|
||||
});
|
||||
|
||||
renderHook(() => useSaveDoc(docId, yDoc, true), {
|
||||
renderHook(() => useSaveDoc(docId, yDoc), {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
@@ -94,7 +93,7 @@ describe('useSaveDoc', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.lastCall()?.[0]).toBe(
|
||||
'http://test.jest/api/v1.0/documents/test-doc-id/',
|
||||
`http://test.jest/api/v1.0/documents/${docId}/content/`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -104,15 +103,17 @@ describe('useSaveDoc', () => {
|
||||
const yDoc = new Y.Doc();
|
||||
const docId = 'test-doc-id';
|
||||
|
||||
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
|
||||
body: JSON.stringify({
|
||||
id: 'test-doc-id',
|
||||
content: 'test-content',
|
||||
title: 'test-title',
|
||||
}),
|
||||
});
|
||||
fetchMock.patch(
|
||||
'http://test.jest/api/v1.0/documents/test-doc-id/content/',
|
||||
{
|
||||
body: JSON.stringify({
|
||||
id: 'test-doc-id',
|
||||
content: 'test-content',
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
renderHook(() => useSaveDoc(docId, yDoc, true), {
|
||||
renderHook(() => useSaveDoc(docId, yDoc), {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
@@ -132,7 +133,7 @@ describe('useSaveDoc', () => {
|
||||
const docId = 'test-doc-id';
|
||||
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
|
||||
|
||||
const { unmount } = renderHook(() => useSaveDoc(docId, yDoc, true), {
|
||||
const { unmount } = renderHook(() => useSaveDoc(docId, yDoc), {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useCollaborationUrl } from '@/core/config';
|
||||
import { KEY_DOC } from '@/docs/doc-management/api/useDoc';
|
||||
import {
|
||||
KEY_DOC_CONTENT,
|
||||
useDocContent,
|
||||
} from '@/docs/doc-management/api/useDocContent';
|
||||
import { useProviderStore } from '@/docs/doc-management/stores/useProviderStore';
|
||||
import { useIsOffline } from '@/features/service-worker/hooks/useOffline';
|
||||
import { useBroadcastStore } from '@/stores/useBroadcastStore';
|
||||
|
||||
export const useCollaboration = (room: string) => {
|
||||
const collaborationUrl = useCollaborationUrl(room);
|
||||
const { addTask } = useBroadcastStore();
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
setBroadcastProvider,
|
||||
cleanupBroadcast,
|
||||
provider: broadcastProvider,
|
||||
} = useBroadcastStore();
|
||||
const {
|
||||
provider,
|
||||
createProvider,
|
||||
destroyProvider,
|
||||
setReady,
|
||||
isReady,
|
||||
hasLostConnection,
|
||||
resetLostConnection,
|
||||
} = useProviderStore();
|
||||
const isOffline = useIsOffline((state) => state.isOffline);
|
||||
const { data: docContent } = useDocContent(
|
||||
{ id: room },
|
||||
{
|
||||
staleTime: 30000, // 30 seconds - We keep the data fresh as it is a highly collaborative page
|
||||
queryKey: [KEY_DOC_CONTENT, { id: room }],
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* When offline, the WebSocket never connects so the provider would stay
|
||||
* in a non-ready state for a long time. Immediately mark it as ready so
|
||||
* the editor can render with the cached content.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (isOffline && provider && !isReady) {
|
||||
setReady(true);
|
||||
}
|
||||
}, [isOffline, isReady, provider, setReady]);
|
||||
|
||||
/**
|
||||
* When the provider detects a lost connection, we invalidate the document query to trigger a refetch.
|
||||
* Because it can be because the user has access to the document that are modified
|
||||
* (e.g., permissions changed, document deleted, user removed)
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (hasLostConnection && room) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_DOC, { id: room }],
|
||||
});
|
||||
resetLostConnection();
|
||||
}
|
||||
}, [hasLostConnection, room, queryClient, resetLostConnection]);
|
||||
|
||||
/**
|
||||
* We add a broadcast task to reset the query cache
|
||||
* when the document visibility changes.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!room || broadcastProvider?.document?.guid !== room) {
|
||||
return;
|
||||
}
|
||||
|
||||
addTask(`${KEY_DOC}-${room}`, () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_DOC, { id: room }],
|
||||
});
|
||||
});
|
||||
}, [addTask, room, queryClient, broadcastProvider?.document?.guid]);
|
||||
|
||||
/**
|
||||
* Set the provider when the collaboration URL and the document content are available.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!room || !collaborationUrl || provider || docContent === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newProvider = createProvider(collaborationUrl, room, docContent);
|
||||
setBroadcastProvider(newProvider);
|
||||
}, [
|
||||
provider,
|
||||
collaborationUrl,
|
||||
createProvider,
|
||||
docContent,
|
||||
room,
|
||||
setBroadcastProvider,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Destroy the provider when the component is unmounted
|
||||
*/
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (room) {
|
||||
cleanupBroadcast();
|
||||
destroyProvider();
|
||||
}
|
||||
};
|
||||
}, [destroyProvider, room, cleanupBroadcast]);
|
||||
};
|
||||
@@ -1,24 +1,36 @@
|
||||
import { useRouter } from 'next/router';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { useUpdateDoc } from '@/docs/doc-management/';
|
||||
import { useDocContentUpdate } from '@/docs/doc-management/api/useDocContentUpdate';
|
||||
import { useProviderStore } from '@/docs/doc-management/stores/useProviderStore';
|
||||
import { KEY_LIST_DOC_VERSIONS } from '@/docs/doc-versioning/api/useDocVersions';
|
||||
import { useIsOffline } from '@/features/service-worker';
|
||||
import { toBase64 } from '@/utils/string';
|
||||
import { isFirefox } from '@/utils/userAgent';
|
||||
|
||||
const SAVE_INTERVAL = 60000;
|
||||
|
||||
export const useSaveDoc = (
|
||||
docId: string,
|
||||
yDoc: Y.Doc,
|
||||
isConnectedToCollabServer: boolean,
|
||||
) => {
|
||||
const { mutate: updateDoc } = useUpdateDoc({
|
||||
export const useSaveDoc = (docId: string, yDoc: Y.Doc) => {
|
||||
/**
|
||||
* isSynced is more reliable than isConnected in this cases
|
||||
* because it indicates that the content is fully synchronised
|
||||
* with the yjs server
|
||||
*/
|
||||
const { isSynced: isConnectedToCollabServer } = useProviderStore();
|
||||
|
||||
const { isOffline } = useIsOffline();
|
||||
const isSavingRef = useRef(false);
|
||||
const { mutate: updateDocContent } = useDocContentUpdate({
|
||||
listInvalidQueries: [KEY_LIST_DOC_VERSIONS],
|
||||
isOptimistic: isOffline, // Enable optimistic updates when offline, to update the cache immediately
|
||||
onSuccess: () => {
|
||||
isSavingRef.current = false;
|
||||
setIsLocalChange(false);
|
||||
},
|
||||
onError: () => {
|
||||
isSavingRef.current = false;
|
||||
},
|
||||
});
|
||||
const [isLocalChange, setIsLocalChange] = useState<boolean>(false);
|
||||
|
||||
@@ -64,18 +76,19 @@ export const useSaveDoc = (
|
||||
}, [yDoc]);
|
||||
|
||||
const saveDoc = useCallback(() => {
|
||||
if (!isLocalChange) {
|
||||
if (!isLocalChange || isSavingRef.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
updateDoc({
|
||||
isSavingRef.current = true;
|
||||
updateDocContent({
|
||||
id: docId,
|
||||
content: toBase64(Y.encodeStateAsUpdate(yDoc)),
|
||||
websocket: isConnectedToCollabServer,
|
||||
});
|
||||
|
||||
return true;
|
||||
}, [isLocalChange, updateDoc, docId, yDoc, isConnectedToCollabServer]);
|
||||
}, [isLocalChange, updateDocContent, docId, yDoc, isConnectedToCollabServer]);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
|
||||
@@ -1,266 +1,306 @@
|
||||
import { css } from 'styled-components';
|
||||
import { createGlobalStyle } from 'styled-components';
|
||||
|
||||
export const cssEditor = css`
|
||||
.mantine-Menu-itemLabel,
|
||||
.mantine-Button-label {
|
||||
font-family: var(--c--components--button--font-family);
|
||||
}
|
||||
|
||||
&,
|
||||
& > .bn-container,
|
||||
& .ProseMirror {
|
||||
export const DocsEditorStyle = createGlobalStyle`
|
||||
.bn-root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token Mantime
|
||||
*/
|
||||
& > .bn-container {
|
||||
.bn-editor {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.mantine-Menu-itemLabel,
|
||||
.mantine-Button-label {
|
||||
font-family: var(--c--components--button--font-family);
|
||||
}
|
||||
|
||||
/**
|
||||
* Token Mantine
|
||||
*/
|
||||
--bn-colors-editor-text: var(
|
||||
--c--contextuals--content--semantic--neutral--primary
|
||||
);
|
||||
--bn-colors-side-menu: var(
|
||||
--c--contextuals--content--semantic--neutral--tertiary
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure long placeholder text is truncated with ellipsis
|
||||
*/
|
||||
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
|
||||
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
}
|
||||
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
|
||||
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child) {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure images with unsafe URLs are not interactive
|
||||
*/
|
||||
img.bn-visual-media[src*='-unsafe'] {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collaboration cursor styles
|
||||
*/
|
||||
.collaboration-cursor-custom__base {
|
||||
position: relative;
|
||||
}
|
||||
.collaboration-cursor-custom__caret {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 2px;
|
||||
bottom: 4%;
|
||||
left: -1px;
|
||||
}
|
||||
.collaboration-cursor-custom__label {
|
||||
color: #0d0d0d;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
position: absolute;
|
||||
top: -17px;
|
||||
left: 0px;
|
||||
padding: 0px 6px;
|
||||
border-radius: 0px;
|
||||
white-space: nowrap;
|
||||
transition: clip-path 0.3s ease-in-out;
|
||||
border-radius: 4px 4px 4px 0;
|
||||
box-shadow: inset -2px 2px 6px #ffffff00;
|
||||
clip-path: polygon(0 85%, 4% 85%, 4% 100%, 0% 100%);
|
||||
}
|
||||
.collaboration-cursor-custom__base[data-active]
|
||||
.collaboration-cursor-custom__label {
|
||||
pointer-events: none;
|
||||
box-shadow: inset -2px 2px 6px #ffffff88;
|
||||
clip-path: polygon(0 0, 100% 0%, 100% 100%, 0% 100%);
|
||||
}
|
||||
|
||||
/**
|
||||
* Side menu
|
||||
*/
|
||||
.bn-side-menu[data-block-type='heading'][data-level='1'] {
|
||||
height: 54px;
|
||||
}
|
||||
.bn-side-menu[data-block-type='heading'][data-level='2'] {
|
||||
height: 43px;
|
||||
}
|
||||
.bn-side-menu[data-block-type='heading'][data-level='3'] {
|
||||
height: 35px;
|
||||
}
|
||||
.bn-side-menu[data-block-type='divider'] {
|
||||
height: 38px;
|
||||
}
|
||||
.bn-side-menu .mantine-UnstyledButton-root svg {
|
||||
color: var(
|
||||
--c--contextuals--content--semantic--neutral--tertiary
|
||||
) !important;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callout, Paragraph and Heading blocks
|
||||
*/
|
||||
.bn-block {
|
||||
border-radius: var(--c--globals--spacings--3xs);
|
||||
}
|
||||
.bn-block-outer {
|
||||
border-radius: var(--c--globals--spacings--3xs);
|
||||
}
|
||||
.bn-block > .bn-block-content[data-background-color] {
|
||||
padding: var(--c--globals--spacings--3xs) var(--c--globals--spacings--3xs);
|
||||
border-radius: var(--c--globals--spacings--3xs);
|
||||
}
|
||||
.bn-block-content[data-content-type='checkListItem'][data-checked='true']
|
||||
.bn-inline-content {
|
||||
text-decoration: none;
|
||||
}
|
||||
.bn-default-styles h1 {
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
.bn-default-styles h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.bn-default-styles h3 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
a {
|
||||
color: var(--c--globals--colors--gray-600);
|
||||
cursor: pointer;
|
||||
}
|
||||
.bn-block-group
|
||||
.bn-block-group
|
||||
.bn-block-outer:not([data-prev-depth-changed]):before {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.bn-toolbar {
|
||||
max-width: 95vw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quotes
|
||||
*/
|
||||
blockquote {
|
||||
border-left: 4px solid var(--c--globals--colors--gray-300);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI
|
||||
/**
|
||||
* Ensure long placeholder text is truncated with ellipsis
|
||||
*/
|
||||
ins,
|
||||
[data-type='modification'] {
|
||||
background: var(--c--globals--colors--brand-100);
|
||||
border-bottom: 2px solid var(--c--globals--colors--brand-300);
|
||||
color: var(--c--globals--colors--brand-700);
|
||||
}
|
||||
|
||||
/**
|
||||
* Divider
|
||||
*/
|
||||
[data-content-type='divider'] hr {
|
||||
background: #d3d2cf;
|
||||
margin: 1rem 0;
|
||||
width: 100%;
|
||||
border: 1px solid #d3d2cf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checklist items
|
||||
*/
|
||||
.bn-block-content[data-content-type='checkListItem'] > div > input {
|
||||
appearance: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid
|
||||
var(--c--contextuals--content--semantic--neutral--tertiary);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
align-self: center;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.bn-block-content[data-content-type='checkListItem'] > div > input:checked {
|
||||
background-color: var(--c--contextuals--content--semantic--brand--tertiary);
|
||||
border-color: var(--c--contextuals--content--semantic--brand--tertiary);
|
||||
}
|
||||
.bn-block-content[data-content-type='checkListItem']
|
||||
> div
|
||||
> input:checked::after {
|
||||
content: 'check';
|
||||
font-family: 'Material Symbols Outlined Variable', sans-serif;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: var(--c--contextuals--content--semantic--overlay--primary);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure consistent spacing between headings and paragraphs
|
||||
*/
|
||||
& .bn-block-outer:not(:first-child) {
|
||||
&:has(h1) {
|
||||
margin-top: 32px;
|
||||
}
|
||||
&:has(h2) {
|
||||
margin-top: 24px;
|
||||
}
|
||||
&:has(h3) {
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
& .bn-inline-content code {
|
||||
background-color: gainsboro;
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@media screen and (width <= 768px) {
|
||||
& .bn-editor {
|
||||
padding-right: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (width <= 560px) {
|
||||
.--docs--doc-readonly & .bn-editor {
|
||||
padding-left: 10px;
|
||||
}
|
||||
& .bn-editor {
|
||||
padding-right: 10px;
|
||||
}
|
||||
.bn-side-menu[data-block-type='heading'][data-level='1'] {
|
||||
height: 46px;
|
||||
}
|
||||
.bn-side-menu[data-block-type='heading'][data-level='2'] {
|
||||
height: 40px;
|
||||
}
|
||||
.bn-side-menu[data-block-type='heading'][data-level='3'] {
|
||||
height: 40px;
|
||||
}
|
||||
& .bn-editor h1 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
& .bn-editor h2 {
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
& .bn-editor h3 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
|
||||
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before {
|
||||
font-size: 14px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
}
|
||||
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
|
||||
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child) {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure images with unsafe URLs are not interactive
|
||||
*/
|
||||
img.bn-visual-media[src*='-unsafe'] {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collaboration cursor styles
|
||||
*/
|
||||
.collaboration-cursor-custom__base {
|
||||
position: relative;
|
||||
}
|
||||
.collaboration-cursor-custom__caret {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 2px;
|
||||
bottom: 4%;
|
||||
left: -1px;
|
||||
}
|
||||
.collaboration-cursor-custom__label {
|
||||
color: #0d0d0d;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
position: absolute;
|
||||
top: -17px;
|
||||
left: 0px;
|
||||
padding: 0px 6px;
|
||||
border-radius: 0px;
|
||||
white-space: nowrap;
|
||||
transition: clip-path 0.3s ease-in-out;
|
||||
border-radius: 4px 4px 4px 0;
|
||||
box-shadow: inset -2px 2px 6px #ffffff00;
|
||||
clip-path: polygon(0 85%, 4% 85%, 4% 100%, 0% 100%);
|
||||
}
|
||||
.collaboration-cursor-custom__base[data-active]
|
||||
.collaboration-cursor-custom__label {
|
||||
pointer-events: none;
|
||||
box-shadow: inset -2px 2px 6px #ffffff88;
|
||||
clip-path: polygon(0 0, 100% 0%, 100% 100%, 0% 100%);
|
||||
}
|
||||
|
||||
/**
|
||||
* Side menu
|
||||
*/
|
||||
.bn-side-menu .mantine-UnstyledButton-root svg {
|
||||
color: var(
|
||||
--c--contextuals--content--semantic--neutral--tertiary
|
||||
) !important;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callout, Paragraph and Heading blocks
|
||||
*/
|
||||
.bn-block {
|
||||
border-radius: var(--c--globals--spacings--3xs);
|
||||
}
|
||||
.bn-block-outer {
|
||||
border-radius: var(--c--globals--spacings--3xs);
|
||||
}
|
||||
.bn-block > .bn-block-content[data-background-color] {
|
||||
padding: var(--c--globals--spacings--3xs) var(--c--globals--spacings--3xs);
|
||||
border-radius: var(--c--globals--spacings--3xs);
|
||||
}
|
||||
.bn-block-content[data-content-type='checkListItem'][data-checked='true']
|
||||
.bn-inline-content {
|
||||
text-decoration: none;
|
||||
}
|
||||
a {
|
||||
color: var(--c--globals--colors--gray-600);
|
||||
cursor: pointer;
|
||||
}
|
||||
.bn-block-group
|
||||
.bn-block-group
|
||||
.bn-block-outer:not([data-prev-depth-changed]):before {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.bn-toolbar {
|
||||
max-width: 95vw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quotes
|
||||
*/
|
||||
blockquote {
|
||||
border-left: 4px solid var(--c--globals--colors--gray-300);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI
|
||||
*/
|
||||
ins,
|
||||
[data-type='modification'] {
|
||||
background: var(--c--globals--colors--brand-100);
|
||||
border-bottom: 2px solid var(--c--globals--colors--brand-300);
|
||||
color: var(--c--globals--colors--brand-700);
|
||||
}
|
||||
|
||||
/**
|
||||
* Divider
|
||||
*/
|
||||
[data-content-type='divider'] hr {
|
||||
background: #d3d2cf;
|
||||
margin: 1rem 0;
|
||||
width: 100%;
|
||||
border: 1px solid #d3d2cf;
|
||||
}
|
||||
.bn-side-menu[data-block-type='divider'] {
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checklist items
|
||||
*/
|
||||
.bn-block-content[data-content-type='checkListItem'] > div > input {
|
||||
appearance: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid
|
||||
var(--c--contextuals--content--semantic--neutral--tertiary);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
align-self: center;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.bn-block-content[data-content-type='checkListItem'] > div > input:checked {
|
||||
background-color: var(--c--contextuals--content--semantic--brand--tertiary);
|
||||
border-color: var(--c--contextuals--content--semantic--brand--tertiary);
|
||||
}
|
||||
.bn-block-content[data-content-type='checkListItem']
|
||||
> div
|
||||
> input:checked::after {
|
||||
content: 'check';
|
||||
font-family: 'Material Symbols Outlined Variable', sans-serif;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: var(--c--contextuals--content--semantic--overlay--primary);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/**
|
||||
* Headings
|
||||
* Ensure consistent spacing between headings and paragraphs
|
||||
*/
|
||||
[data-content-type='heading'] {
|
||||
--level: 1.875rem;
|
||||
&[data-level='2'] {
|
||||
--level: 1.5rem;
|
||||
}
|
||||
&[data-level='3'] {
|
||||
--level: 1.25rem;
|
||||
}
|
||||
&[data-level='4'] {
|
||||
--level: 1.125rem;
|
||||
}
|
||||
&[data-level='5'] {
|
||||
--level: 1rem;
|
||||
}
|
||||
&[data-level='6'] {
|
||||
--level: 0.875rem;
|
||||
}
|
||||
}
|
||||
.bn-side-menu[data-block-type='heading'][data-level='1'] {
|
||||
height: 54px;
|
||||
}
|
||||
.bn-side-menu[data-block-type='heading'][data-level='2'] {
|
||||
height: 43px;
|
||||
}
|
||||
.bn-side-menu[data-block-type='heading'][data-level='3'] {
|
||||
height: 35px;
|
||||
}
|
||||
& .bn-default-styles h1 {
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
& .bn-default-styles h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
& .bn-default-styles h3 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
& .bn-default-styles h4 {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
& .bn-default-styles h5 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
& .bn-default-styles h6 {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
& .bn-block-outer:not(:first-child) {
|
||||
&:has(h1) {
|
||||
margin-top: 32px;
|
||||
}
|
||||
&:has(h2) {
|
||||
margin-top: 24px;
|
||||
}
|
||||
&:has(h3) {
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
& .bn-inline-content code {
|
||||
background-color: gainsboro;
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@media screen and (width <= 768px) {
|
||||
& .bn-editor {
|
||||
padding-right: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (width <= 560px) {
|
||||
.--docs--doc-readonly & .bn-editor {
|
||||
padding-left: 10px;
|
||||
}
|
||||
& .bn-editor {
|
||||
padding-right: 10px;
|
||||
}
|
||||
.bn-side-menu[data-block-type='heading'][data-level='1'] {
|
||||
height: 46px;
|
||||
}
|
||||
.bn-side-menu[data-block-type='heading'][data-level='2'] {
|
||||
height: 40px;
|
||||
}
|
||||
.bn-side-menu[data-block-type='heading'][data-level='3'] {
|
||||
height: 40px;
|
||||
}
|
||||
[data-content-type='heading'] {
|
||||
--level: 1.6rem;
|
||||
&[data-level='2'] {
|
||||
--level: 1.35rem;
|
||||
}
|
||||
&[data-level='3'] {
|
||||
--level: 1.2rem;
|
||||
}
|
||||
&[data-level='4'] {
|
||||
--level: 1.125rem;
|
||||
}
|
||||
&[data-level='5'] {
|
||||
--level: 1rem;
|
||||
}
|
||||
&[data-level='6'] {
|
||||
--level: 0.875rem;
|
||||
}
|
||||
}
|
||||
& .bn-editor h1 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
|
||||
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
useToastProvider,
|
||||
} from '@gouvfr-lasuite/cunningham-react';
|
||||
import { DocumentProps, pdf } from '@react-pdf/renderer';
|
||||
import jsonemoji from 'emoji-datasource-apple' assert { type: 'json' };
|
||||
import jsonemoji from 'emoji-datasource-apple' with { type: 'json' };
|
||||
import i18next from 'i18next';
|
||||
import JSZip from 'jszip';
|
||||
import { cloneElement, isValidElement, useState } from 'react';
|
||||
|
||||
@@ -16,6 +16,4 @@ if (process.env.NEXT_PUBLIC_PUBLISH_AS_MIT === 'false') {
|
||||
};
|
||||
}
|
||||
|
||||
type ModulesExport = typeof modulesExport;
|
||||
|
||||
export default modulesExport as ModulesExport;
|
||||
export default modulesExport;
|
||||
|
||||
@@ -84,7 +84,7 @@ const PRINT_ONLY_CONTENT_CSS = `
|
||||
|
||||
/* Ensure BlockNote content flows properly */
|
||||
.bn-editor,
|
||||
.bn-container,
|
||||
.bn-root,
|
||||
.--docs--main-editor,
|
||||
.bn-block-outer {
|
||||
height: auto !important;
|
||||
|
||||
@@ -68,7 +68,7 @@ export const BoutonShare = ({
|
||||
/>
|
||||
}
|
||||
onClick={(e) => {
|
||||
addLastFocus(e.currentTarget as HTMLElement);
|
||||
addLastFocus(e.currentTarget);
|
||||
open();
|
||||
}}
|
||||
size="medium"
|
||||
@@ -85,7 +85,7 @@ export const BoutonShare = ({
|
||||
color="brand"
|
||||
variant="tertiary"
|
||||
onClick={(e) => {
|
||||
addLastFocus(e.currentTarget as HTMLElement);
|
||||
addLastFocus(e.currentTarget);
|
||||
open();
|
||||
}}
|
||||
size="medium"
|
||||
|
||||
@@ -153,6 +153,40 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const insertPlainText = (plainText: string, target: HTMLElement) => {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
if (!target.contains(range.commonAncestorContainer)) {
|
||||
target.focus();
|
||||
range.selectNodeContents(target);
|
||||
range.collapse(false);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
range.deleteContents();
|
||||
range.insertNode(document.createTextNode(plainText));
|
||||
};
|
||||
|
||||
const handlePaste = (event: React.ClipboardEvent<HTMLSpanElement>) => {
|
||||
event.preventDefault();
|
||||
insertPlainText(
|
||||
event.clipboardData.getData('text/plain'),
|
||||
event.currentTarget,
|
||||
);
|
||||
};
|
||||
|
||||
const handleDrop = (event: React.DragEvent<HTMLSpanElement>) => {
|
||||
event.preventDefault();
|
||||
insertPlainText(
|
||||
event.dataTransfer.getData('text/plain'),
|
||||
event.currentTarget,
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setTitleDisplay(isTopRoot ? doc.title : titleWithoutEmoji);
|
||||
}, [doc.title, isTopRoot, titleWithoutEmoji]);
|
||||
@@ -181,6 +215,8 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
|
||||
onBlurCapture={(event) =>
|
||||
handleTitleSubmit(event.target.textContent || '')
|
||||
}
|
||||
onPasteCapture={handlePaste}
|
||||
onDropCapture={handleDrop}
|
||||
$padding={{ right: 'big' }}
|
||||
$css={css`
|
||||
&[contenteditable='true']:empty:not(:focus):before {
|
||||
|
||||
@@ -244,7 +244,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
<Icon iconName="download" $color="inherit" aria-hidden={true} />
|
||||
}
|
||||
onClick={(e) => {
|
||||
addLastFocus(e.currentTarget as HTMLElement);
|
||||
addLastFocus(e.currentTarget);
|
||||
setIsModalExportOpen(true);
|
||||
}}
|
||||
size={isSmallMobile ? 'small' : 'medium'}
|
||||
|
||||
@@ -6,15 +6,10 @@ import { Doc } from '../types';
|
||||
|
||||
export type DocParams = {
|
||||
id: string;
|
||||
withoutContent?: boolean;
|
||||
};
|
||||
|
||||
export const getDoc = async ({
|
||||
id,
|
||||
withoutContent,
|
||||
}: DocParams): Promise<Doc> => {
|
||||
const params = withoutContent ? '?without_content=true' : '';
|
||||
const response = await fetchAPI(`documents/${id}/${params}`);
|
||||
export const getDoc = async ({ id }: DocParams): Promise<Doc> => {
|
||||
const response = await fetchAPI(`documents/${id}/`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError('Failed to get the doc', await errorCauses(response));
|
||||
@@ -24,7 +19,6 @@ export const getDoc = async ({
|
||||
};
|
||||
|
||||
export const KEY_DOC = 'doc';
|
||||
export const KEY_DOC_VISIBILITY = 'doc-visibility';
|
||||
|
||||
export function useDoc(
|
||||
param: DocParams,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
|
||||
import { validate as uuidValidate } from 'uuid';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
|
||||
export type DocContentParams = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export const getDocContent = async ({
|
||||
id,
|
||||
}: DocContentParams): Promise<string> => {
|
||||
if (!uuidValidate(id)) {
|
||||
throw new Error(`Invalid doc id in getDocContent: ${id}`);
|
||||
}
|
||||
|
||||
const response = await fetchAPI(`documents/${id}/content/`, {
|
||||
headers: {
|
||||
accept: 'text/plain,application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError('Failed to get the doc', await errorCauses(response));
|
||||
}
|
||||
|
||||
return response.text();
|
||||
};
|
||||
|
||||
export const KEY_DOC_CONTENT = 'doc-content';
|
||||
|
||||
export function useDocContent(
|
||||
param: DocContentParams,
|
||||
queryConfig?: UseQueryOptions<string, APIError, string>,
|
||||
) {
|
||||
return useQuery<string, APIError, string>({
|
||||
queryKey: queryConfig?.queryKey ?? [KEY_DOC_CONTENT, param],
|
||||
queryFn: () => getDocContent(param),
|
||||
...queryConfig,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
UseMutationOptions,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query';
|
||||
import { validate as uuidValidate } from 'uuid';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
|
||||
import { Doc } from '../types';
|
||||
|
||||
import { KEY_CAN_EDIT } from './useDocCanEdit';
|
||||
import { KEY_DOC_CONTENT } from './useDocContent';
|
||||
|
||||
export interface UpdateDocContentParams {
|
||||
id: Doc['id'];
|
||||
content: string; // Base64 encoded content
|
||||
websocket?: boolean;
|
||||
}
|
||||
|
||||
export const updateDocContent = async ({
|
||||
id,
|
||||
content,
|
||||
websocket,
|
||||
}: UpdateDocContentParams): Promise<void> => {
|
||||
if (!uuidValidate(id)) {
|
||||
throw new Error(`Invalid doc id in updateDocContent: ${id}`);
|
||||
}
|
||||
|
||||
const response = await fetchAPI(`documents/${id}/content/`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
content,
|
||||
websocket,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
'Failed to update the doc content',
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
type UseDocContentUpdate = UseMutationOptions<
|
||||
void,
|
||||
APIError,
|
||||
UpdateDocContentParams
|
||||
> & {
|
||||
isOptimistic?: boolean;
|
||||
listInvalidQueries?: string[];
|
||||
};
|
||||
|
||||
export function useDocContentUpdate(queryConfig?: UseDocContentUpdate) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, APIError, UpdateDocContentParams>({
|
||||
mutationFn: updateDocContent,
|
||||
...queryConfig,
|
||||
onMutate: (variables) => {
|
||||
/**
|
||||
* If optimistic, we update the content cache immediately with the new content
|
||||
* It is useful when we are in offline mode because the onSuccess is not always triggered.
|
||||
*/
|
||||
if (queryConfig?.isOptimistic) {
|
||||
const previousContent = queryClient.getQueryData([
|
||||
KEY_DOC_CONTENT,
|
||||
{ id: variables.id },
|
||||
]);
|
||||
|
||||
queryClient.setQueryData(
|
||||
[KEY_DOC_CONTENT, { id: variables.id }],
|
||||
variables.content,
|
||||
);
|
||||
|
||||
return { previousContent };
|
||||
}
|
||||
},
|
||||
onSuccess: (data, variables, onMutateResult, context) => {
|
||||
if (!queryConfig?.isOptimistic) {
|
||||
/**
|
||||
* If not optimistic, we need to update the content cache with the new content returned
|
||||
* from the server
|
||||
*/
|
||||
queryClient.setQueryData(
|
||||
[KEY_DOC_CONTENT, { id: variables.id }],
|
||||
variables.content,
|
||||
);
|
||||
}
|
||||
|
||||
queryConfig?.listInvalidQueries?.forEach((queryKey) => {
|
||||
void queryClient.resetQueries({
|
||||
queryKey: [queryKey],
|
||||
});
|
||||
});
|
||||
|
||||
if (queryConfig?.onSuccess) {
|
||||
void queryConfig.onSuccess(data, variables, onMutateResult, context);
|
||||
}
|
||||
},
|
||||
onError: (error, variables, onMutateResult, context) => {
|
||||
if (
|
||||
queryConfig?.isOptimistic &&
|
||||
(onMutateResult as { previousContent: unknown })?.previousContent
|
||||
) {
|
||||
const previousContent = (onMutateResult as { previousContent: unknown })
|
||||
.previousContent;
|
||||
|
||||
queryClient.setQueryData(
|
||||
[KEY_DOC_CONTENT, { id: variables.id }],
|
||||
previousContent,
|
||||
);
|
||||
}
|
||||
|
||||
// If error it means the user is probably not allowed to edit the doc
|
||||
// so we invalidate the canEdit query to update the UI accordingly
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_CAN_EDIT],
|
||||
});
|
||||
|
||||
if (queryConfig?.onError) {
|
||||
queryConfig.onError(error, variables, onMutateResult, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user