Compare commits

..

71 Commits

Author SHA1 Message Date
Manuel Raynaud
fa3beca494 💚(docker) ignore .venv with compilemessages command
The compilemessages management command was also compiling messages for
all the libraries present in the .venv folder. We have to ignore it, on
arm64 this management command is taking too much time otherwise.
2026-05-07 14:21:38 +00:00
Anthony LC
d340c8f1f1 🧐(frontend) dispatch the app version to posthog
We add the app version in Posthog events to be
able to track which versions are being used and
identify potential issues related to specific
versions.
2026-05-07 14:20:05 +02:00
Anthony LC
67773ef2d9 🩺(project) reload app if front and back unsync
We observe some cases where the frontend and
backend versions can get out of sync, which can
cause issues.
To mitigate this, we want to implement a mechanism
that detects when the frontend and backend
versions are mismatched and triggers a
reload of the application to ensure they are in sync.
2026-05-07 14:20:05 +02:00
Manuel Raynaud
1268bbe5ea 🔧(actions) migrate from pip to uv
Migreate usage of pip to uv in github actions. How python is setup is
also changed.
2026-05-07 13:09:43 +02:00
Manuel Raynaud
8fc13d75dc ♻️(backend) migrate from setuptool to uv_build as build backend
We already migrate from pip to uv to manage our dependencies. We can also
migrate the build backend from setuptool to uv_build.
In the pyproject file, the readme property has been removed, because
uv_build try to read it, but the readme is at the root of the project
and not copied into the Dockerfile instructions. This readme can be used
when the package is published on pypi but it is not the case for Docs.
2026-05-07 13:09:43 +02:00
Manuel Raynaud
aea6fbef9b 🏗️(core) migrate from pip to uv
We want to remove the usage of pip in order to use uv as python package
manager.
2026-05-07 13:09:42 +02:00
Manuel Raynaud
a47c35195d 🐛(backend) replace document creation table locks with retry strategy
We have situation where the number of locks in the database can increase
dangerously creating deadlock situation. To remove this situation we
decided to change the strategy to manage document creation concurrency.
We decided to use a retry strategy, trying to create the document
multiple times while a usable path is found. To avoid having an
inifinite loop, we use a max_attempts counter configurable using the
setting TREEBEARD_PATH_COMPUTE_RETRY_MAX_ATTEMPTS
2026-05-07 11:52:48 +02:00
Manuel Raynaud
8f67b37d70 ♻️(backend) split core/utils.py module
We need to split the core/utils.py in multiple submodule created in
core/utils/*.py. We need to do this to avoir circular import between
this module and the models module.
2026-05-07 11:45:57 +02:00
Manuel Raynaud
0b20d9f435 🐛(backend) manage race condition between GET and PATCH content
When a PATCH and a GET on the content endpoint are made at the same time
for different users a race condition can happen and the metadata
returned
by the S3 head_object can be outdated when the object is fetched leading
to an error raised because the Content-Length header does not match the
size of the response body. To avoid this, we no longer used head_object
followed bu get_object, we have to manage
everything in one call with the get_object. The get_object also accepts
as parameters an etag or last-modified header and will return a 304 if
the content has not changed, so we can use this to not return the entire
body if this one has not changed.
2026-05-07 09:43:20 +00:00
Anthony LC
a166716a2f ️(frontend) close websocket connection when user change tab
When a user change to another tab, after a delay of "inactivity"
we disconnect the user from the collaboration server.
When the user come back we reconnect to the server
again. It will reduce the connection to the collaboration
server and reduce outburst during reconnection during
a ingress ngnix restart.
2026-05-06 16:19:40 +02:00
Manuel Raynaud
6fe0221596 (backend) new settings COLLABORATION_WS_INACTIVITY_TIMEOUT
We want to configure the timeout, in second, a user is consider as
inactive. After this inactivity period we want to close the websocket
connection
2026-05-06 16:19:40 +02:00
Anthony LC
bd662d72bf 🐛(frontend) fix loading comments transaction
When we load the comments we have to notify the
subscribers of the DocsThreadStore. This generates
a Yjs transaction that is currently treated as a
user-initiated content change that will trigger
a patch request when the doc will try to save.
We now update the transaction origin when we notify
the subscribers so that we can reliably identify
and ignore those transactions in the useSaveDoc
hook.
2026-05-06 15:55:47 +02:00
Anthony LC
3701fe5a45 🐛(frontend) interlinking are exported correctly in print mode
Interlinking are now exported correctly in print
mode. The interlinking was not a link in print
mode, but now it is.
2026-05-06 15:12:24 +02:00
Anthony LC
0f527a789a 🔒️(frontend) sanitize color during collaboration
To improve security we sanitize the color used
for collaboration presence to ensure it's a valid
hex color. If the color is not valid, we generate
a random color instead. This prevents potential
issues with invalid color values being used in the UI.
2026-05-05 15:21:16 +02:00
renovate[bot]
85128c7b11 ⬆️(dependencies) update axios to v1.15.2 [SECURITY] 2026-05-05 12:25:22 +00:00
Anthony LC
5f700ed6c4 💬(frontend) add missing link in onboarding description
We added a missing link in the onboarding step
description to direct users to ready-made templates f
or common use cases. This enhancement aims to improve
the user experience by providing easy access to
resources that can help users get started quickly
and customize their workflow efficiently.
2026-05-05 13:23:19 +02:00
Anthony LC
b0d9ed15c0 ️(frontend) add skeleton on content loading
Content is longer to load than other parts of the
editor because of the connection with websocket
to the collaboration server. To improve the user
experience, we add a skeleton on the content part
of the editor while the others parts are displayed.
2026-05-05 12:12:11 +02:00
Anthony LC
d41e44dcd5 🐛(frontend) fix uikit dnd tree
The last version of UIKit has a bug that causes
the dnd tree to break. It is due to some
pointers event that are not properly handled.
We remove the pointer event in waiting for the
fix to be released.
2026-05-05 10:56:59 +02:00
Anthony LC
07e7b3feb6 🏷️(mail) adapt to mjml v5
We upgraded to mjml v5, which has some breaking changes.
By default the ubuntu font was loaded, with google
fonts, that is not GDPR compliant.
We switched to Inter, and uses fonts.bunny.net to
load the font, which is GDPR compliant.
2026-05-05 10:56:59 +02:00
Anthony LC
aa71cfdfc0 ️(frontend) remove listener on hover with react-dropzone
React-dropzone was rerendering components on hover,
which was unnecessary. This commit removes the
rerendering by adapting its settings.
2026-05-05 10:53:39 +02:00
Anthony LC
7afa17a181 💄(frontend) adapt css to Blocknote v0.49
We updated Blocknote to v0.49, which includes
some breaking changes in the CSS. This commit adapts
our custom styles to the new version of Blocknote.
2026-05-05 10:53:39 +02:00
Anthony LC
af2b381097 🐛(frontend) fix scroll table of content
The scroll of the table of content was calculated
on mount of the component, so when the editor height change,
the scroll of the table of content was not updated.
We added a observer to observe the height of the
editor and update the scroll of the table of
content when the height change.
2026-05-05 10:53:39 +02:00
Anthony LC
5015d42677 🏷️(frontend) adapt types to i18next v26
We updated i18next to v26, which includes some
breaking changes. This commit adapts our types
to the new version, ensuring compatibility and
proper type checking throughout our codebase.
2026-05-05 10:53:39 +02:00
Anthony LC
738ff90fc7 🏷️(frontend) adapt types to upgrade Cunningham and ui-kit
We upgraded Cunningham and ui-kit dependencies, which
introduced some breaking changes. This commit adapts
our code to these changes, ensuring compatibility
with the new versions of these libraries.
2026-05-05 10:53:38 +02:00
Anthony LC
0e8094c733 🏷️(frontend) adapt types to typescript v6
We updated typescript to v6, which includes some
breaking changes. This commit adapts our code to
be compatible with the new version of typescript.
2026-05-05 10:53:38 +02:00
renovate[bot]
9231730bf0 ⬆️(dependencies) update js dependencies 2026-05-05 10:50:49 +02:00
Cyril
21100b986d 🐛(frontend) sanitize pasted and dropped content in document title
Prevent rich-text formatting when pasting or dropping into document title.
2026-05-04 16:14:39 +02:00
Manuel Raynaud
eaddbd83d7 🔖(major) release 5.0.0
Added

- (backend) create a dedicated endpoint to update document content
- ️(backend) stream s3 file content with a dedicated endpoint
- (backend) allow to use new ai feature using mistral sdk

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

- 🚸(frontend) redirect on current url tab after 401 #2197
- 🐛(frontend) abort check media status unmount #2194
- (backend) order pinned documents by last updated at #2028
- 🐛(frontend) fix app shallow reload #2231
- 🐛(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
2026-05-04 11:59:35 +02:00
github-actions[bot]
22c587fdd0 🌐(i18n) update translated strings
update translated strings
2026-04-30 15:28:44 +02:00
Manuel Raynaud
9568d12f68 ♻️(backend) improve legacy AI translate prompt
The return of the legacy AI translate prompt is sometimes surrounded
with a html code block. We improve the prompt to not return this
surrounding code block.
2026-04-30 11:23:11 +02:00
Manuel Raynaud
33a9e99d54 (backend) manage langfuse with the mistral sdk
Langfuse was not working with the legacy client using the mistral sdk.
We want to add the support of langfuse for it.
2026-04-30 09:32:34 +02:00
Manuel Raynaud
6cfc8990b9 ♻️(backend) use mistral sdk with legacy ai feature
We also want to use the mistral sdk with the legacy AI feature when this
one is configured with the settings. In order to separate bot feature,
they now live in their own module.
2026-04-30 09:32:34 +02:00
Manuel Raynaud
8c84dbf39a ♻️(frontend) all dev origins when using the tilt environment
When developping using the tilt environment, we need to allow the domain
docs.127.0.0.1.nip.io in the next configuration.

Also the the customization is removed as it is not maintained.
2026-04-30 09:32:34 +02:00
Manuel Raynaud
b6efac3983 (backend) allow to use new ai feature using mistral sdk
We give the possibility, for the new ai feature, to choose between using
the OpenAI or Mistral sdk. For instances having access to the mistral
infrastructure, using it is mor appropriated than using the openai
compatible chat model.
2026-04-30 09:32:34 +02:00
Anthony LC
fa9d56d79b 🔒️(js) fix security warning
Critical:
- protobufjs CVE

High:
- lodash CVE
- picomatch CVE
- Vite CVE

Moderate:
- postcss CVE
- uuid CVE
- dompurify CVE
- follow-redirects CVE
2026-04-29 15:04:58 +02:00
Manuel Raynaud
4fe508bba1 ⬆️(docker) upgrade nginx image to last version
We want to upgrade the frontend image using the last nginx version
available in order to remove some fixed in version 1.29.7
2026-04-29 09:42:45 +02:00
Manuel Raynaud
487d0b12ca (backend) fix flaky search descendants test
One test about the search descendants test was flaky. It is because the
link_reach and link_role were used to test the
ancestors_link_(reach|role). The properties ancestors_link_reach and
ancestors_link_role should be used instead.
2026-04-29 09:18:05 +02:00
Anthony LC
9f1d4543e7 (e2e) adapt e2e test to fit translation upgrade
We upgraded djangorestframework, which includes a
change in the translation system. This change caused
the e2e test for the app impress language
to fail.
To fix this, we updated the test to work
with the new translation system.
2026-04-29 09:16:12 +02:00
renovate[bot]
c90280fb4d ⬆️(dependencies) update python dependencies 2026-04-29 09:16:12 +02:00
Cyril
a2860e8fe6 ️(frontend) fix sidebar resize handle for screen readers
Expose the handle as a slider so arrow keys work with NVDA
2026-04-29 07:12:10 +02:00
Mohamed El Amine BOUKERFA
cfd1fd00da 🐛(backend) Forbid restoring a non-deleted document
Catch RuntimeError raised by Document.restore() and translate it into a
DRF ValidationError so callers get a 400 instead of a 500, when trying
to restore a non-deleted document.
    
Signed-off-by: Mohamed El Amine BOUKERFA <boukerfa.ma@gmail.com>
2026-04-28 14:53:30 +00:00
Mohamed El Amine BOUKERFA
ed663f2e1e 🐛(backend) Prevent moving document to its own descendant or self
When attempting to move a document to itself or to any of its
descendants, the server would crash with a 500 Internal Server
Error.
    
Signed-off-by: Mohamed El Amine BOUKERFA <boukerfa.ma@gmail.com>
2026-04-28 14:13:42 +00:00
Mohamed El Amine BOUKERFA
99764b8e3e 🐛(backend) strip whitespace from media URLs in CORS proxy
When exporting a document to PDF, having whitespace before or after
the media URL causes the image to not be downloaded via the CORS proxy,
resulting in missing images in the exported PDF.
    
Signed-off-by: Mohamed El Amine BOUKERFA <boukerfa.ma@gmail.com>
2026-04-28 13:47:16 +00:00
Mohamed El Amine BOUKERFA
37091ca804 🐛(backend) enforce emoji validation for reactions
Validate emojis in ReactionSerializer (previously accepted
any string), preventing multiple emojis or text uploads in
a single reaction
    
Signed-off-by: Mohamed El Amine BOUKERFA <boukerfa.ma@gmail.com>
2026-04-28 13:10:04 +00:00
Erin
394fbc5537 (backend) make forward auth request uri header configurable
In deployment, Traefik is used, not nginx, as an ingress. Traefik
uses `X-Forwarded-Ur`i instead of `X-Original-Url`. This adds a setting
which lets users adapt Docs to their ingress proxy of choice
The settings name is MEDIA_AUTH_ORIGINAL_URL_HEADER

Signed-off-by: Erin Shepherd <erin.shepherd@e43.eu>
2026-04-28 08:57:19 +00:00
Anthony LC
7df5aba991 (e2e) fix uuid not formatted correctly in mocked document
We added a guard on the uuid format in our frontend
requests, this guard broke some of our e2e tests
because the mocked document id was not a valid uuid.
2026-04-28 09:08:30 +02:00
renovate[bot]
c464715158 ⬆️(dependencies) update uuid to v14 [SECURITY] 2026-04-27 21:21:59 +00:00
Manuel Raynaud
5e31eb0caa ♻️(backend) use additional http extra methods for content action
We used one drf extra action with both PATCH and GET https methods and
then split in two private methods and call them based on the http method
of the request. DRF allow to do this by using a mapping annotation
allowing us to have directly twi viewset actions used
django-rest-framework.org/api-guide/viewsets/#marking-extra-actions-for-routing
2026-04-27 15:07:35 +02:00
Manuel Raynaud
a00c51247d 🔧(helm) set logger to debug level for feature environment
The feature environment are here for demo and debug purpose. For this we
want to have more logs and set them to the debug level.
2026-04-27 15:07:35 +02:00
Anthony LC
100817b0e6 🥅(sw) improve requests fallback
We improve overall SW requests fallback.
If the plugin fails we try to refetch the request
without the plugin modifications, meaning the
status code will be more in correlation with the
actual server response and not the plugin error.

We improved as well the cache fallback, if
the cache failed because a store was missing,
we delete the DB to be sure to have a DB in
correlation with the current app version.
2026-04-27 15:07:34 +02:00
Anthony LC
ff2c61a3dc ✈️(SW) add offline support for content
We have added offline support for content.
When the content update fails, we save the new
content in the cache, and we will sync it later
with the SyncManager.
2026-04-27 15:07:34 +02:00
Anthony LC
4d250a7342 ️(SW) cache content and metadata for API requests
We cache the content of API responses in the service
worker, so that we can serve them when the user
is offline.
We also cache the ETag and Last-Modified headers,
so that we can make conditional requests to the
server and avoid downloading the content again if
it hasn't changed.
2026-04-27 15:07:34 +02:00
Manuel Raynaud
6f2cd8a829 ️(backend) implement etag and last_modified headers to fetch content
We want to give to the js client the ability to use some headers to
avoid fetching a content it already have. For this, the content endpoint
will return an ETag and Last-Modified headers corresponding to the file
content ETag and its last modification. For future fetch, the client can
use the If-None-Match or If-Modified-Since request headers, if one of
these headers are satisfied, the endpoint will return a 304 response. If
not it will still return a 200
2026-04-27 15:07:33 +02:00
Anthony LC
b6c6fc8217 👔(frontend) integrate dedicated content endpoint
To improve the performance of loading document content,
we have implemented a dedicated endpoint for
fetching document content. This allows us to load
the document metadata and content separately.
We updated the different components to utilize
this new endpoint, ensuring that the document content is
fetched and updated correctly.
2026-04-27 15:07:33 +02:00
Anthony LC
68f1600c2b 🔥(clients) remove without_content query string
We now have a dedicated API to fetch only the doc
content, so we can remove the without_content
query string from the doc fetching API.
2026-04-27 15:07:33 +02:00
Manuel Raynaud
1c2bafb0f7 📝(backend) add breaking changes document in UPGRADE.md file
We need to list the breaking changes made for the future version 5.0.0
2026-04-27 15:07:31 +02:00
Manuel Raynaud
6b3d19715b ️(backend) stream s3 file content with a dedicated endpoint
We created a dedicated endpoint to retrieve a document content. The
content of the s3 file is stream when this endpoint is fetch.
2026-04-27 15:06:59 +02:00
Manuel Raynaud
51d4746435 🔥(backend) remove content in document responses
The content was always loaded in the document reponse. We remove this
behavior in order to not make an http call to the s3 storage. To get the
document content it is needed now to use the new endpoint dedicated to
retrive the document content.
2026-04-27 15:06:57 +02:00
Manuel Raynaud
d7a186a98b (backend) create a dedicated endpoint to update document content
We want a dedicated endpoint to update a document content. Previously,
updating the content was made on the update action shared with all other
document's properties. When the title is updated, the response contains
the content, so a call to the s3 storage is made and we don't want this.
Isolating the content update will allow us in the next commit to remove
the content from the Document serializer.
2026-04-27 15:06:34 +02:00
Manuel Raynaud
207f21447d ♻️(backend) rename documents content endpoint in formatted-content
The endpoint /api/v1.0/documents/{document_id}/content/ has been renamed
in /api/v1.0/documents/{document_id}/formatted-content/. formatted-content
seems more accurante and the content endpoint will be used for another
purpose more appropriated.
2026-04-27 15:06:33 +02:00
Manuel Raynaud
3433d6de9a 📄(upgrade) specify docspec upgrade version
The version o docspec must be upgraded to version >= 3.0.0
2026-04-27 14:52:27 +02:00
Manuel Raynaud
5e22bc4736 🔥(backend) remove deprecated descendants endpoint
We can remove the deprecated and unused descendants endpoint. We will
release a new major version now.
2026-04-27 14:52:27 +02:00
Stephan Meijer
2d2e326cb6 ⬆️(backend) upgrade docspec to v3.0.0 and adapt converter API
Summary

- Bump docspec Docker image from `2.6.3` to `3.0.0` and adapt
`DocSpecConverter` to the new API (raw body upload with explicit
`Content-Type`/`Accept` headers instead of multipart form)

Important

**The Docker image (`ghcr.io/docspecio/api:3.0.0`) must be updated
alongside the code changes.** The new request format is incompatible
with v2.x — deploying only the code without updating the image (or vice
versa) will break document conversion.
2026-04-27 11:41:43 +00:00
Manuel Raynaud
ef9376368f 🔧(docker) run django app with uvicorn in dev environment
The django application is running in ASGI in production, to have the
same behavior we run the development container with uvicorn too with
options more appropriated for a development evironment.
2026-04-27 08:49:55 +02:00
renovate[bot]
e747e038f8 ⬆️(dependencies) update lxml to v6.1.0 [SECURITY] 2026-04-23 16:25:45 +02:00
Anthony LC
aed8ae7181 🐛(frontend) remove horizontal line when no elements
When no elements are present in the doc share
modals, a horizontal line is still displayed.
This PR removes this line when there are no elements
to display.
2026-04-21 11:39:07 +02:00
Anthony LC
e39b03c272 🐛(frontend) fix app shallow reload
The app was doing a shallow reload when user
was coming from another tab and the user data
was staled. We stop to block the app during the
loading state, depend the response the app
will manage correctly its states.
2026-04-21 11:39:07 +02:00
Anthony LC
3cc9655574 🐛(frontend) fix position interlinking when lost focus
When switching between a interlinking search to a
interlinking link, we could lose the position of
the interlinking. The interlinking was added at
the beginning of the document or where the cursor was.
We refactorize the interlinking to be only one type
of inline content, by doing so we do not lose the position
of the interlinking because we don't remove the interlinking search
to add the interlinking link, we just update the
interlinking search to be a interlinking link.
2026-04-21 10:15:35 +02:00
Anthony LC
c20e71e21d 💄(frontend) update interlinking ux/ui
Update interlinking to fit the new design.
The notable changes is that we cannot create
a subdoc from the search dropdown.
2026-04-21 10:15:34 +02:00
Anthony LC
b3dd8f2e39 🐛(frontend) fix interlinking modal clipping
Depend the parent block, the modal search may be
clipped by the parent block. We now use the portal
to render the modal search, which will not be
affected by the parent block's clipping.
2026-04-21 10:15:34 +02:00
Manuel Raynaud
203b3edcae 🐛(backend) load jwks url when OIDC_RS_PRIVATE_KEY_STR is set
When the resource server is enabled and the backend used is
JWTResourceServerBackend, then the API should expose a JWKS endpoint to
share the RSA public key to the OIDC provider. Everything is made in the
Django LaSuite library, but the URL is not included in the Docs URLs.
This commit adds it when the setting OIDC_RS_PRIVATE_KEY_STR is set.
2026-04-20 15:14:09 +00:00
213 changed files with 11606 additions and 7400 deletions

View File

@@ -96,21 +96,20 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Install Python
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.13.3"
cache: "pip"
- name: Upgrade pip and setuptools
run: pip install --upgrade pip setuptools
- name: Install development dependencies
run: pip install --user .[dev]
python-version-file: "src/backend/pyproject.toml"
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Install the project
run: uv sync --locked --all-extras
- name: Check code formatting with ruff
run: ~/.local/bin/ruff format . --diff
run: uv run ruff format . --diff
- name: Lint code with ruff
run: ~/.local/bin/ruff check .
run: uv run ruff check .
- name: Lint code with pylint
run: ~/.local/bin/pylint .
run: uv run pylint .
test-back:
runs-on: ubuntu-latest
@@ -192,14 +191,14 @@ jobs:
mc mb impress/impress-media-storage && \
mc version enable impress/impress-media-storage"
- name: Install Python
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.13.3"
cache: "pip"
- name: Install development dependencies
run: pip install --user .[dev]
python-version-file: "src/backend/pyproject.toml"
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Install the project
run: uv sync --locked --all-extras
- name: Install gettext (required to compile messages) and MIME support
run: |
@@ -208,7 +207,7 @@ jobs:
sudo wget https://raw.githubusercontent.com/suitenumerique/django-lasuite/refs/heads/main/assets/conf/mime.types -O /etc/mime.types
- name: Generate a MO file from strings extracted from the project
run: python manage.py compilemessages
run: uv run python manage.py compilemessages
- name: Run tests
run: ~/.local/bin/pytest -n 2
run: uv run pytest -n 2

View File

@@ -6,18 +6,71 @@ and this project adheres to
## [Unreleased]
### Added
- ⚡️(frontend) add skeleton on content loading #2254
- ⚡️(frontend) close websocket connection when user change tab #2264
### Changed
- 🏗️(core) migrate from pip to uv
### Fixed
- 🩺(project) reload app if front and back unsync #2276
- 🐛(frontend) fix patch and comments #2273
- 🐛(frontend) interlinking are exported correctly in print mode #2269
- 💬(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
- 🐛(backend) manage race condition between GET and PATCH content #2271
- 🐛(backend) replace document creation table locks with retry strategy #2274
### Security
- 🔒️(frontend) sanitize color during collaboration #2270
## [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
- 🚸(frontend) redirect on current url tab after 401 #2197
- 🐛(frontend) abort check media status unmount #2194
- ✨(backend) order pinned documents by last updated at #2028
- 🐛(frontend) fix app shallow reload #2231
- 🐛(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
@@ -56,7 +109,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
@@ -78,9 +130,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
@@ -1249,7 +1298,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

View File

@@ -1,24 +1,37 @@
# Django impress
# ---- base image to inherit from ----
FROM python:3.13.3-alpine AS base
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip
FROM python:3.13.13-alpine AS base
# Upgrade system packages to install security updates
RUN apk update && apk upgrade --no-cache
# We must do that to avoid having an outdated pip version with security issues
RUN python -m pip install --upgrade pip
# ---- Back-end builder image ----
FROM base AS back-builder
WORKDIR /builder
ENV UV_COMPILE_BYTECODE=1
ENV UV_LINK_MODE=copy
# Copy required python dependencies
COPY ./src/backend /builder
# Disable Python downloads, because we want to use the system interpreter
# across both images. If using a managed Python version, it needs to be
# copied from the build image into the final image;
ENV UV_PYTHON_DOWNLOADS=0
RUN mkdir /install && \
pip install --prefix=/install .
# install uv
COPY --from=ghcr.io/astral-sh/uv:0.11.10 /uv /uvx /bin/
WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=src/backend/uv.lock,target=uv.lock \
--mount=type=bind,source=src/backend/pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-dev
COPY src/backend /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev
# ---- mails ----
@@ -41,14 +54,13 @@ RUN apk add --no-cache \
pango \
rdfind
# Copy installed python dependencies
COPY --from=back-builder /install /usr/local
# Copy impress application (see .dockerignore)
COPY ./src/backend /app/
# Copy the application from the builder
COPY --from=back-builder /app /app
WORKDIR /app
ENV PATH="/app/.venv/bin:$PATH"
# collectstatic
RUN DJANGO_CONFIGURATION=Build \
python manage.py collectstatic --noinput
@@ -84,8 +96,12 @@ COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
# docker user (see entrypoint).
RUN chmod g=u /etc/passwd
# Copy installed python dependencies
COPY --from=back-builder /install /usr/local
# Copy the application from the builder
COPY --from=back-builder /app /app
WORKDIR /app
ENV PATH="/app/.venv/bin:$PATH"
# Link certifi certificate from a static path /cert/cacert.pem to avoid issues
# when python is upgraded and the path to the certificate changes.
@@ -95,14 +111,9 @@ RUN mkdir /cert && \
mv $path /cert/ && \
ln -s /cert/cacert.pem $path
# Copy impress application (see .dockerignore)
COPY ./src/backend /app/
WORKDIR /app
# Generate compiled translation messages
RUN DJANGO_CONFIGURATION=Build \
python manage.py compilemessages
python manage.py compilemessages --ignore=".venv/**/*"
# We wrap commands run in this container by the following entrypoint that
@@ -119,10 +130,9 @@ USER root:root
# Install psql
RUN apk add --no-cache postgresql-client
# Uninstall impress and re-install it in editable mode along with development
# dependencies
RUN pip uninstall -y impress
RUN pip install -e .[dev]
# Install development dependencies
RUN --mount=from=ghcr.io/astral-sh/uv:0.11.10,source=/uv,target=/bin/uv \
uv sync --all-extras --locked
# Restore the un-privileged user running the application
ARG DOCKER_USER
@@ -134,7 +144,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

View File

@@ -72,7 +72,7 @@ data/static:
# -- Project
create-env-local-files: ## create env.local files in env.d/development
create-env-local-files:
create-env-local-files:
@touch env.d/development/crowdin.local
@touch env.d/development/common.local
@touch env.d/development/postgresql.local
@@ -141,7 +141,7 @@ else
@echo "$(RESET)"
@echo "$(GREEN)Starting bootstrap process...$(RESET)"
endif
@echo ""
@echo ""
.PHONY: pre-beautiful-bootstrap
post-beautiful-bootstrap: ## Display a success message after bootstrap
@@ -235,7 +235,7 @@ run-backend: ## Start only the backend application and all needed services
.PHONY: run-backend
run: ## start the wsgi (production) and development server
run:
run:
@$(MAKE) run-backend
@$(COMPOSE) up --force-recreate -d frontend-development
.PHONY: run
@@ -322,7 +322,7 @@ superuser: ## Create an admin superuser with password "admin"
.PHONY: superuser
back-i18n-compile: ## compile the gettext files
@$(MANAGE) compilemessages --ignore="venv/**/*"
@$(MANAGE) compilemessages --ignore=".venv/**/*"
.PHONY: back-i18n-compile
back-i18n-generate: ## create the .pot files used for i18n

View File

@@ -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.

View File

@@ -1,203 +1,2 @@
#!/bin/sh
set -o errexit
CURRENT_DIR=$(pwd)
APPLICATION=${1:-app}
CLUSTERNAME=${2:-suite}
echo "0. Create ca"
# 0. Create ca
mkcert -install
cd /tmp
mkcert "127.0.0.1.nip.io" "*.127.0.0.1.nip.io"
cd $CURRENT_DIR
echo "1. Create registry container unless it already exists"
# 1. Create registry container unless it already exists
reg_name='kind-registry'
reg_port='5001'
if [ "$(docker inspect -f '{{.State.Running}}' "${reg_name}" 2>/dev/null || true)" != 'true' ]; then
docker run \
-d --restart=always -p "127.0.0.1:${reg_port}:5000" --network bridge --name "${reg_name}" \
registry:2
fi
echo "2. Create kind cluster with containerd registry config dir enabled"
# 2. Create kind cluster with containerd registry config dir enabled
# TODO: kind will eventually enable this by default and this patch will
# be unnecessary.
#
# See:
# https://github.com/kubernetes-sigs/kind/issues/2875
# https://github.com/containerd/containerd/blob/main/docs/cri/config.md#registry-configuration
# See: https://github.com/containerd/containerd/blob/main/docs/hosts.md
if ! kind get clusters | grep ${CLUSTERNAME}; then
cat <<EOF | kind create cluster --name ${CLUSTERNAME} --config=-
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
containerdConfigPatches:
- |-
[plugins."io.containerd.grpc.v1.cri".registry]
config_path = "/etc/containerd/certs.d"
nodes:
- role: control-plane
kubeadmConfigPatches:
- |
kind: InitConfiguration
nodeRegistration:
kubeletExtraArgs:
node-labels: "ingress-ready=true"
extraPortMappings:
- containerPort: 30000
hostPort: 80
protocol: TCP
- containerPort: 30001
hostPort: 443
protocol: TCP
EOF
fi
echo "3. Add the registry config to the nodes"
# 3. Add the registry config to the nodes
#
# This is necessary because localhost resolves to loopback addresses that are
# network-namespace local.
# In other words: localhost in the container is not localhost on the host.
#
# We want a consistent name that works from both ends, so we tell containerd to
# alias localhost:${reg_port} to the registry container when pulling images
REGISTRY_DIR="/etc/containerd/certs.d/localhost:${reg_port}"
for node in $(kind get nodes --name ${CLUSTERNAME}); do
docker exec "${node}" mkdir -p "${REGISTRY_DIR}"
cat <<EOF | docker exec -i "${node}" cp /dev/stdin "${REGISTRY_DIR}/hosts.toml"
[host."http://${reg_name}:5000"]
EOF
done
echo "4. Connect the registry to the cluster network if not already connected"
# 4. Connect the registry to the cluster network if not already connected
# This allows kind to bootstrap the network but ensures they're on the same network
if [ "$(docker inspect -f='{{json .NetworkSettings.Networks.kind}}' "${reg_name}")" = 'null' ]; then
docker network connect "kind" "${reg_name}"
fi
echo "5. Document the local registry"
# 5. Document the local registry
# https://github.com/kubernetes/enhancements/tree/master/keps/sig-cluster-lifecycle/generic/1755-communicating-a-local-registry
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: ConfigMap
metadata:
name: local-registry-hosting
namespace: kube-public
data:
localRegistryHosting.v1: |
host: "localhost:${reg_port}"
help: "https://kind.sigs.k8s.io/docs/user/local-registry/"
EOF
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: ConfigMap
metadata:
name: coredns
namespace: kube-system
data:
Corefile: |
.:53 {
errors
health {
lameduck 5s
}
ready
kubernetes cluster.local in-addr.arpa ip6.arpa {
pods insecure
fallthrough in-addr.arpa ip6.arpa
ttl 30
}
prometheus :9153
forward . /etc/resolv.conf {
max_concurrent 1000
}
rewrite stop {
name regex (.*).127.0.0.1.nip.io traefik.traefik.svc.cluster.local answer auto
}
cache 30
loop
reload
loadbalance
}
EOF
kubectl -n kube-system scale deployment coredns --replicas=1
kubectl -n kube-system rollout restart deployments/coredns
if ! kubectl get ns traefik; then
echo "6. Install Traefik ingress"
helm repo add traefik https://traefik.github.io/charts
helm repo update
kubectl create namespace traefik
kubectl -n traefik create secret tls local-selfsigned-tls --key /tmp/127.0.0.1.nip.io+1-key.pem --cert /tmp/127.0.0.1.nip.io+1.pem || echo ok
# Install the chart into the 'traefik' namespace
helm install traefik traefik/traefik \
--namespace traefik \
--values ./traefik-values.yaml
fi
if ! kubectl get ns ${APPLICATION}; then
echo "7. Setup namespace"
kubectl create ns ${APPLICATION}
kubectl config set-context --current --namespace=${APPLICATION}
kubectl -n ${APPLICATION} create secret generic mkcert --from-file=rootCA.pem="$(mkcert -CAROOT)/rootCA.pem" || echo ok
kubectl -n impress create secret tls impress-docs-tls --key /tmp/127.0.0.1.nip.io+1-key.pem --cert /tmp/127.0.0.1.nip.io+1.pem || echo ok
fi
if ! kubectl get configmap certifi -n ${APPLICATION}; then
echo "8. Inject our custom CA in a configmap for certifi"
curl https://raw.githubusercontent.com/certifi/python-certifi/refs/heads/master/certifi/cacert.pem -o /tmp/cacert.pem
cat "$(mkcert -CAROOT)/rootCA.pem" >>/tmp/cacert.pem
kubectl -n ${APPLICATION} create configmap certifi --from-file=cacert.pem=/tmp/cacert.pem
kubectl -n ${APPLICATION} create secret generic certifi --from-file=/tmp/cacert.pem
fi
echo "9. Check pod readiness across all namespaces..."
sleep_interval=10
echo "Initial wait time: $((sleep_interval * 2)) seconds…"
sleep $((sleep_interval * 2))
check_pods_ready() {
local max_attempts=60 # Maximum number of attempts (10 minutes with 10s intervals)
local attempt=1
while [ $attempt -le $max_attempts ]; do
echo "Attempt $attempt/$max_attempts - Checking pod status..."
not_ready_count=$( kubectl get po -A --no-headers | grep -v -E "Running|Completed"| wc -l | tr -d ' ')
if [ "$not_ready_count" -eq 0 ]; then
echo "✅ All pods are ready!"
return 0
else
echo "$not_ready_count pod(s) still not ready. Waiting $sleep_interval seconds…"
sleep $sleep_interval
((attempt++))
fi
done
echo "❌ Timeout: Some pods are still not ready after 10 minutes"
echo "Final pod status:"
kubectl get po -A
return 1
}
if check_pods_ready; then
echo "🎉 Cluster is fully ready!"
else
echo "⚠️ Some pods may need manual intervention"
exit 1
fi
curl https://raw.githubusercontent.com/numerique-gouv/tools/refs/heads/main/kind/create_cluster.sh | bash -s -- impress

View File

@@ -1,9 +0,0 @@
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: forwardauth
namespace: impress
spec:
forwardAuth:
address: https://docs.127.0.0.1.nip.io/api/v1.0/documents/media-auth/
trustForwardHeader: true

View File

@@ -1,96 +0,0 @@
# Configure Network Ports and EntryPoints
# EntryPoints are the network listeners for incoming traffic.
ports:
# Defines the HTTP entry point named 'web'
web:
port: 80
nodePort: 30000
# Instructs this entry point to redirect all traffic to the 'websecure' entry point
http:
redirections:
entryPoint:
to: websecure
scheme: https
permanent: true
# Defines the HTTPS entry point named 'websecure'
websecure:
port: 443
nodePort: 30001
# Enables the dashboard in Secure Mode
api:
dashboard: true
insecure: false
ingressRoute:
dashboard:
enabled: true
matchRule: Host(`traefik.127.0.0.1.nip.io`)
entryPoints:
- websecure
middlewares:
- name: dashboard-auth
# Creates a BasicAuth Middleware and Secret for the Dashboard Security
extraObjects:
- apiVersion: v1
kind: Secret
metadata:
name: dashboard-auth-secret
type: kubernetes.io/basic-auth
stringData:
username: admin
password: "P@ssw0rd" # Replace with an Actual Password
- apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: dashboard-auth
spec:
basicAuth:
secret: dashboard-auth-secret
# We will route with Gateway API instead.
ingressClass:
enabled: true
# Enable Gateway API Provider & Disables the KubernetesIngress provider
# Providers tell Traefik where to find routing configuration.
providers:
kubernetesIngress:
enabled: true
kubernetesGateway:
enabled: false
## Gateway Listeners
gateway:
listeners:
web: # HTTP listener that matches entryPoint `web`
port: 80
protocol: HTTP
namespacePolicy:
from: All
websecure: # HTTPS listener that matches entryPoint `websecure`
port: 443
protocol: HTTPS # TLS terminates inside Traefik
namespacePolicy:
from: All
mode: Terminate
certificateRefs:
- kind: Secret
name: local-selfsigned-tls # the Secret we created before the installation
group: ""
# Enable Observability
logs:
general:
level: INFO
# This enables access logs, outputting them to Traefik's standard output by default. The [Access Logs Documentation](https://doc.traefik.io/traefik/observability/access-logs/) covers formatting, filtering, and output options.
access:
enabled: true
# Enables Prometheus for Metrics
metrics:
prometheus:
enabled: true

View File

@@ -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
@@ -80,17 +80,18 @@ services:
volumes:
- ./src/backend:/app
- ./data/static:/data/static
- /app/.venv
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
@@ -108,6 +109,7 @@ services:
volumes:
- ./src/backend:/app
- ./data/static:/data/static
- /app/.venv
depends_on:
- app-dev
@@ -143,7 +145,7 @@ services:
frontend-development:
user: "${DOCKER_USER:-1000}"
build:
build:
context: .
dockerfile: ./src/frontend/Dockerfile
target: impress-dev
@@ -173,13 +175,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 +223,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 +241,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 +250,7 @@ services:
restart: true
docspec:
image: ghcr.io/docspecio/api:2.6.3
image: ghcr.io/docspecio/api:3.0.1
ports:
- "4000:4000"

View File

@@ -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 |
@@ -32,6 +34,7 @@ These are the environment variables you can set for the `impress-backend` contai
| CACHES_DEFAULT_KEY_PREFIX | The prefix used to every cache keys. | docs |
| COLLABORATION_API_URL | Collaboration api host | |
| COLLABORATION_SERVER_SECRET | Collaboration api secret | |
| COLLABORATION_WS_INACTIVITY_TIMEOUT | Timeout (in seconds) after which the user is considered inactive when there is no activity. The WebSocket is closed after this inactivity period. `None` means disabled. | None |
| COLLABORATION_WS_NOT_CONNECTED_READY_ONLY | Users not connected to the collaboration server cannot edit | false |
| COLLABORATION_WS_URL | Collaboration websocket url | |
| CONVERSION_API_CONTENT_FIELD | Conversion api content field | content |
@@ -91,6 +94,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 | {} |
@@ -131,6 +135,7 @@ These are the environment variables you can set for the `impress-backend` contai
| THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 |
| THEME_CUSTOMIZATION_FILE_PATH | Full path to the file customizing the theme. An example is provided in src/backend/impress/configuration/theme/default.json | BASE_DIR/impress/configuration/theme/default.json |
| TRASHBIN_CUTOFF_DAYS | Trashbin cutoff | 30 |
| TREEBEARD_PATH_COMPUTE_RETRY_MAX_ATTEMPTS | Number of attempts to create a document before failing. | 10 |
| USER_OIDC_ESSENTIAL_CLAIMS | Essential claims in OIDC token | [] |
| USER_ONBOARDING_DOCUMENTS | A list of documents IDs for which a read-only access will be created for new s | [] |
| USER_ONBOARDING_SANDBOX_DOCUMENT | ID of a template sandbox document that will be duplicated for new users | |

View File

@@ -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
@@ -86,6 +78,7 @@ COLLABORATION_SERVER_ORIGIN=http://localhost:3000
COLLABORATION_SERVER_SECRET=my-secret
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY=true
COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/
COLLABORATION_WS_INACTIVITY_TIMEOUT=15 # Seconds
DJANGO_SERVER_TO_SERVER_API_TOKENS=server-api-token
Y_PROVIDER_API_BASE_URL=http://y-provider-development:4444/api/

View File

@@ -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"},
}

View File

@@ -7,22 +7,23 @@ from base64 import b64decode
from os.path import splitext
from django.conf import settings
from django.db import connection, 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,
)
from core.utils.treebeard import create_tree_node_with_retry
class UserSerializer(serializers.ModelSerializer):
@@ -178,7 +179,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 +193,6 @@ class DocumentSerializer(ListDocumentSerializer):
"ancestors_link_role",
"computed_link_reach",
"computed_link_role",
"content",
"created_at",
"creator",
"deleted_at",
@@ -242,13 +241,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 +257,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 +290,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 +467,12 @@ 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(
document = create_tree_node_with_retry(
lambda: models.Document.add_root(
title=validated_data["title"],
creator=user,
)
)
if user:
# Associate the document with the pre-existing user
@@ -915,6 +870,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."""

View File

@@ -1,5 +1,6 @@
"""Util to generate S3 authorization headers for object storage access control"""
import datetime as dt
import time
from abc import ABC, abstractmethod
@@ -194,3 +195,36 @@ 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}"
def parse_http_conditional_headers(request):
"""Extract and normalize `If-None-Match` and `If-Modified-Since`.
The `W/` weak prefix is stripped from the ETag because reverse proxies
(e.g. nginx with gzip) rewrite strong ETags into weak ones, which would
otherwise break a strict equality check in production.
"""
if_none_match = request.META.get("HTTP_IF_NONE_MATCH")
if if_none_match and if_none_match.startswith("W/"):
if_none_match = if_none_match.removeprefix("W/")
if_modified_since_dt = None
if not (if_modified_since := request.META.get("HTTP_IF_MODIFIED_SINCE")):
return if_none_match, if_modified_since_dt
try:
if_modified_since_dt = dt.datetime.strptime(
if_modified_since, "%a, %d %b %Y %H:%M:%S %Z"
)
except ValueError:
if_modified_since_dt = None
else:
if not if_modified_since_dt.tzinfo:
if_modified_since_dt = if_modified_since_dt.replace(tzinfo=dt.timezone.utc)
return if_none_match, if_modified_since_dt

View File

@@ -3,6 +3,7 @@
# pylint: disable=too-many-lines
import base64
import datetime as dt
import ipaddress
import json
import logging
@@ -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,
@@ -64,11 +67,10 @@ from core.services.search_indexers import (
get_visited_document_ids_of,
)
from core.tasks.mail import send_ask_for_access_mail
from core.utils import (
extract_attachments,
filter_descendants,
users_sharing_documents_with,
)
from core.utils.paths import filter_descendants
from core.utils.treebeard import create_tree_node_with_retry
from core.utils.users import users_sharing_documents_with
from core.utils.yjs import extract_attachments
from ..enums import FeatureFlag, SearchType
from . import permissions, serializers, utils
@@ -705,18 +707,12 @@ 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(
obj = create_tree_node_with_retry(
lambda: models.Document.add_root(
creator=self.request.user,
**serializer.validated_data,
)
)
serializer.instance = obj
models.DocumentAccess.objects.create(
document=obj,
@@ -776,17 +772,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 +956,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 +990,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."},
@@ -1013,16 +1016,12 @@ class DocumentViewSet(
)
serializer.is_valid(raise_exception=True)
with transaction.atomic():
# "select_for_update" locks the table to ensure safe concurrent access
locked_parent = models.Document.objects.select_for_update().get(
pk=document.pk
)
child_document = locked_parent.add_child(
child_document = create_tree_node_with_retry(
lambda: document.add_child(
creator=request.user,
**serializer.validated_data,
)
)
# Set the created instance to the serializer
serializer.instance = child_document
@@ -1112,30 +1111,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 +1752,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 +1769,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 +1858,167 @@ 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.
The ETag and LastModified are retrieved in the S3 get_object operation to be consistent with
the content Body retrieved at the same time. These metadata are saved in cache for
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 get_object, we return a 304 response.
If the ETag is not present or not the same, we do the same check based on the LastModified
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_none_match, if_modified_since_dt = utils.parse_http_conditional_headers(
request
)
# First check if a cache is existing to return earlier a 304 without reaching s3
# if etag or last_modified have not changed.
cache_key = utils.get_content_metadata_cache_key(document.id)
if content_metadata := cache.get(cache_key):
if (if_none_match and if_none_match == content_metadata.get("etag")) or (
if_modified_since_dt
and dt.datetime.fromisoformat(content_metadata.get("last_modified"))
<= if_modified_since_dt
):
return drf_response.Response(status=status.HTTP_304_NOT_MODIFIED)
# Prepare get_object S3 operation. The get_object manages ETag and last_modified
# headers will raise a 304 client error if one of them matches the value existing in
# S3.
get_object_kwargs = {
"Bucket": default_storage.bucket_name,
"Key": document.file_key,
}
if if_none_match:
get_object_kwargs["IfNoneMatch"] = if_none_match
if if_modified_since_dt:
get_object_kwargs["IfModifiedSince"] = if_modified_since_dt
try:
s3_response = default_storage.connection.meta.client.get_object(
**get_object_kwargs
)
except ClientError as exc:
code = exc.response["Error"]["Code"]
match code:
case "304" | "PreconditionFailed" | "NotModified":
return drf_response.Response(status=status.HTTP_304_NOT_MODIFIED)
case "NoSuchKey" | "404":
return StreamingHttpResponse(
b"", content_type="text/plain", status=200
)
case _:
raise
last_modified = s3_response["LastModified"]
etag = s3_response["ETag"]
size = s3_response["ContentLength"]
# Refresh the metadata cache
cache.set(
cache_key,
{
"last_modified": last_modified.isoformat(),
"etag": etag,
},
settings.CONTENT_METADATA_CACHE_TIMEOUT,
)
def _stream(body):
yield from body.iter_chunks()
body.close()
response = StreamingHttpResponse(
streaming_content=_stream(s3_response["Body"]),
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 +2120,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 +2151,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 +2271,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 +2343,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).
@@ -2667,6 +2817,7 @@ class ConfigView(drf.views.APIView):
"API_USERS_SEARCH_QUERY_MIN_LENGTH",
"COLLABORATION_WS_URL",
"COLLABORATION_WS_NOT_CONNECTED_READY_ONLY",
"COLLABORATION_WS_INACTIVITY_TIMEOUT",
"CONVERSION_FILE_EXTENSIONS_ALLOWED",
"CONVERSION_FILE_MAX_SIZE",
"CONVERSION_UPLOAD_ENABLED",
@@ -2690,6 +2841,7 @@ class ConfigView(drf.views.APIView):
dict_settings[setting] = getattr(settings, setting)
dict_settings["theme_customization"] = self._load_theme_customization()
dict_settings["RELEASE_VERSION"] = settings.RELEASE
return drf.response.Response(dict_settings)

View File

@@ -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):

View File

@@ -9,7 +9,7 @@ from django.db import migrations, models
from botocore.exceptions import ClientError
import core.models
from core.utils import extract_attachments
from core.utils.yjs import extract_attachments
def populate_attachments_on_all_documents(apps, schema_editor):

View File

@@ -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 models, transaction
from django.db.models.functions import Left, Length
from django.template.loader import render_to_string
from django.utils import timezone
@@ -39,6 +39,7 @@ from core.choices import (
RoleChoices,
get_equivalent_link_definition,
)
from core.utils.treebeard import create_tree_node_with_retry
from core.validators import sub_validator
logger = getLogger(__name__)
@@ -265,8 +266,6 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
duplicate the sandbox document for the user
"""
if settings.USER_ONBOARDING_SANDBOX_DOCUMENT:
# transaction.atomic is used in a context manager to avoid a transaction if
# the settings USER_ONBOARDING_SANDBOX_DOCUMENT is unused
sandbox_id = settings.USER_ONBOARDING_SANDBOX_DOCUMENT
try:
template_document = Document.objects.get(id=sandbox_id)
@@ -276,20 +275,15 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
sandbox_id,
)
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;"
sandbox_document = create_tree_node_with_retry(
lambda: Document.add_root(
title=template_document.title,
content=template_document.content,
attachments=template_document.attachments,
duplicated_from=template_document,
creator=self,
)
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(
@@ -1308,7 +1302,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,

View File

@@ -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)

View 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)

View File

@@ -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,
)

View File

@@ -12,8 +12,11 @@ from django.utils.module_loading import import_string
import requests
from core import models, utils
from core import models
from core.enums import SearchType
from core.utils.dicts import get_value_by_pattern
from core.utils.paths import get_ancestor_to_descendants_map
from core.utils.yjs import base64_yjs_to_text
logger = logging.getLogger(__name__)
@@ -44,7 +47,7 @@ def get_batch_accesses_by_users_and_teams(paths):
Get accesses related to a list of document paths,
grouped by users and teams, including all ancestor paths.
"""
ancestor_map = utils.get_ancestor_to_descendants_map(
ancestor_map = get_ancestor_to_descendants_map(
paths, steplen=models.Document.steplen
)
ancestor_paths = list(ancestor_map.keys())
@@ -297,7 +300,7 @@ class FindDocumentIndexer(BaseDocumentIndexer):
>>> get_title({"id": 1})
""
"""
titles = utils.get_value_by_pattern(source, r"^title\.")
titles = get_value_by_pattern(source, r"^title\.")
for title in titles:
if title:
return title
@@ -318,7 +321,7 @@ class FindDocumentIndexer(BaseDocumentIndexer):
"""
doc_path = document.path
doc_content = document.content
text_content = utils.base64_yjs_to_text(doc_content) if doc_content else ""
text_content = base64_yjs_to_text(doc_content) if doc_content else ""
return {
"id": str(document.id),

View File

@@ -11,7 +11,7 @@ from django.dispatch import receiver
from core import models
from core.tasks.search import trigger_batch_document_indexer
from core.utils import get_users_sharing_documents_with_cache_key
from core.utils.users import get_users_sharing_documents_with_cache_key
@receiver(signals.post_save, sender=models.Document)

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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")

View File

@@ -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,
},
],
}

View File

@@ -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()
)

View File

@@ -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),

View File

@@ -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()

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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."]

View File

@@ -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"),

View File

@@ -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

View File

@@ -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):
"""

View File

@@ -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

View File

@@ -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
):

View File

@@ -26,6 +26,7 @@ pytestmark = pytest.mark.django_db
API_USERS_SEARCH_QUERY_MIN_LENGTH=6,
COLLABORATION_WS_URL="http://testcollab/",
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY=True,
COLLABORATION_WS_INACTIVITY_TIMEOUT=300,
CONVERSION_UPLOAD_ENABLED=False,
CRISP_WEBSITE_ID="123",
FRONTEND_CSS_URL="http://testcss/",
@@ -33,6 +34,7 @@ pytestmark = pytest.mark.django_db
FRONTEND_THEME="test-theme",
MEDIA_BASE_URL="http://testserver/",
POSTHOG_KEY={"id": "132456", "host": "https://eu.i.posthog-test.com"},
RELEASE="1.0.0",
SENTRY_DSN="https://sentry.test/123",
THEME_CUSTOMIZATION_FILE_PATH="",
)
@@ -55,6 +57,7 @@ def test_api_config(is_authenticated):
"API_USERS_SEARCH_QUERY_MIN_LENGTH": 6,
"COLLABORATION_WS_URL": "http://testcollab/",
"COLLABORATION_WS_NOT_CONNECTED_READY_ONLY": True,
"COLLABORATION_WS_INACTIVITY_TIMEOUT": 300,
"CONVERSION_FILE_EXTENSIONS_ALLOWED": [".docx", ".md"],
"CONVERSION_FILE_MAX_SIZE": 20971520,
"CONVERSION_UPLOAD_ENABLED": False,
@@ -75,6 +78,7 @@ def test_api_config(is_authenticated):
"LANGUAGE_CODE": "en-us",
"MEDIA_BASE_URL": "http://testserver/",
"POSTHOG_KEY": {"id": "132456", "host": "https://eu.i.posthog-test.com"},
"RELEASE_VERSION": "1.0.0",
"SENTRY_DSN": "https://sentry.test/123",
"TRASHBIN_CUTOFF_DAYS": 30,
"theme_customization": {},

View File

@@ -0,0 +1,52 @@
"""
Unit tests for the parse_http_conditional_headers utility function.
"""
import datetime as dt
import pytest
from rest_framework.test import APIRequestFactory
from core.api.utils import parse_http_conditional_headers
@pytest.fixture(name="prepare_request")
def fixture_prepare_request(request):
"""
Fixture returning a request with headers configured from the indirect parametrize parameters.
"""
return APIRequestFactory().get("/", headers=request.param)
@pytest.mark.parametrize(
"prepare_request, expected_if_none_match, expected_if_modified_since",
[
({}, None, None),
({"if-none-match": '"abc123"'}, '"abc123"', None),
({"if-none-match": 'W/"abc123"'}, '"abc123"', None),
(
{"if-modified-since": "Wed, 21 Oct 2015 07:28:00 GMT"},
None,
dt.datetime(2015, 10, 21, 7, 28, 0, tzinfo=dt.timezone.utc),
),
({"if-modified-since": "not-a-date"}, None, None),
(
{
"if-none-match": 'W/"deadbeef"',
"if-modified-since": "Wed, 21 Oct 2015 07:28:00 GMT",
},
'"deadbeef"',
dt.datetime(2015, 10, 21, 7, 28, 0, tzinfo=dt.timezone.utc),
),
],
indirect=["prepare_request"],
)
def test_api_utils_parse_http_conditional_headers(
prepare_request, expected_if_none_match, expected_if_modified_since
):
"""Test parse_http_conditional_headers utils."""
if_none_match, if_modified_since_dt = parse_http_conditional_headers(
prepare_request
)
assert if_none_match == expected_if_none_match
assert if_modified_since_dt == expected_if_modified_since

View File

@@ -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,

View File

@@ -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
):

View File

@@ -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,
)

View File

@@ -12,13 +12,14 @@ import pytest
import responses
from requests import HTTPError
from core import factories, models, utils
from core import factories, models
from core.services.search_indexers import (
BaseDocumentIndexer,
FindDocumentIndexer,
get_document_indexer,
get_visited_document_ids_of,
)
from core.utils.yjs import base64_yjs_to_text
pytestmark = pytest.mark.django_db
@@ -199,7 +200,7 @@ def test_services_search_indexers_serialize_document_returns_expected_json():
"depth": 1,
"path": document.path,
"numchild": 1,
"content": utils.base64_yjs_to_text(document.content),
"content": base64_yjs_to_text(document.content),
"created_at": document.created_at.isoformat(),
"updated_at": document.updated_at.isoformat(),
"reach": document.link_reach,

View File

@@ -8,7 +8,18 @@ from django.core.cache import cache
import pycrdt
import pytest
from core import factories, utils
from core import factories
from core.utils.dicts import get_value_by_pattern
from core.utils.paths import get_ancestor_to_descendants_map
from core.utils.users import (
get_users_sharing_documents_with_cache_key,
users_sharing_documents_with,
)
from core.utils.yjs import (
base64_yjs_to_text,
base64_yjs_to_xml,
extract_attachments,
)
pytestmark = pytest.mark.django_db
@@ -34,12 +45,12 @@ TEST_BASE64_STRING = (
def test_utils_base64_yjs_to_text():
"""Test extract text from saved yjs document"""
assert utils.base64_yjs_to_text(TEST_BASE64_STRING) == "Hello w or ld"
assert base64_yjs_to_text(TEST_BASE64_STRING) == "Hello w or ld"
def test_utils_base64_yjs_to_xml():
"""Test extract xml from saved yjs document"""
content = utils.base64_yjs_to_xml(TEST_BASE64_STRING)
content = base64_yjs_to_xml(TEST_BASE64_STRING)
assert (
'<heading textAlignment="left" level="1"><italic>Hello</italic></heading>'
in content
@@ -79,13 +90,13 @@ def test_utils_extract_attachments():
update = ydoc.get_update()
base64_string = base64.b64encode(update).decode("utf-8")
# image_key2 is missing the "/media/" part and shouldn't get extracted
assert utils.extract_attachments(base64_string) == [image_key1, image_key3]
assert extract_attachments(base64_string) == [image_key1, image_key3]
def test_utils_get_ancestor_to_descendants_map_single_path():
"""Test ancestor mapping of a single path."""
paths = ["000100020005"]
result = utils.get_ancestor_to_descendants_map(paths, steplen=4)
result = get_ancestor_to_descendants_map(paths, steplen=4)
assert result == {
"0001": {"000100020005"},
@@ -97,7 +108,7 @@ def test_utils_get_ancestor_to_descendants_map_single_path():
def test_utils_get_ancestor_to_descendants_map_multiple_paths():
"""Test ancestor mapping of multiple paths with shared prefixes."""
paths = ["000100020005", "00010003"]
result = utils.get_ancestor_to_descendants_map(paths, steplen=4)
result = get_ancestor_to_descendants_map(paths, steplen=4)
assert result == {
"0001": {"000100020005", "00010003"},
@@ -119,10 +130,10 @@ def test_utils_users_sharing_documents_with_cache_miss():
factories.UserDocumentAccessFactory(user=user2, document=doc1)
factories.UserDocumentAccessFactory(user=user3, document=doc2)
cache_key = utils.get_users_sharing_documents_with_cache_key(user1)
cache_key = get_users_sharing_documents_with_cache_key(user1)
cache.delete(cache_key)
result = utils.users_sharing_documents_with(user1)
result = users_sharing_documents_with(user1)
assert user2.id in result
@@ -139,12 +150,12 @@ def test_utils_users_sharing_documents_with_cache_hit():
factories.UserDocumentAccessFactory(user=user1, document=doc1)
factories.UserDocumentAccessFactory(user=user2, document=doc1)
cache_key = utils.get_users_sharing_documents_with_cache_key(user1)
cache_key = get_users_sharing_documents_with_cache_key(user1)
test_cached_data = {user2.id: "2025-02-10"}
cache.set(cache_key, test_cached_data, 86400)
result = utils.users_sharing_documents_with(user1)
result = users_sharing_documents_with(user1)
assert result == test_cached_data
@@ -156,7 +167,7 @@ def test_utils_users_sharing_documents_with_cache_invalidation_on_create():
doc1 = factories.DocumentFactory()
# Pre-populate cache
cache_key = utils.get_users_sharing_documents_with_cache_key(user1)
cache_key = get_users_sharing_documents_with_cache_key(user1)
cache.set(cache_key, {}, 86400)
# Verify cache exists
@@ -182,7 +193,7 @@ def test_utils_users_sharing_documents_with_cache_invalidation_on_delete():
doc_access = factories.UserDocumentAccessFactory(user=user1, document=doc1)
cache_key = utils.get_users_sharing_documents_with_cache_key(user1)
cache_key = get_users_sharing_documents_with_cache_key(user1)
cache.set(cache_key, {user2.id: "2025-02-10"}, 86400)
assert cache.get(cache_key) is not None
@@ -196,10 +207,10 @@ def test_utils_users_sharing_documents_with_empty_result():
"""Test when user is not sharing any documents."""
user1 = factories.UserFactory()
cache_key = utils.get_users_sharing_documents_with_cache_key(user1)
cache_key = get_users_sharing_documents_with_cache_key(user1)
cache.delete(cache_key)
result = utils.users_sharing_documents_with(user1)
result = users_sharing_documents_with(user1)
assert result == {}
@@ -210,7 +221,7 @@ def test_utils_users_sharing_documents_with_empty_result():
def test_utils_get_value_by_pattern_matching_key():
"""Test extracting value from a dictionary with a matching key pattern."""
data = {"title.extension": "Bonjour", "id": 1, "content": "test"}
result = utils.get_value_by_pattern(data, r"^title\.")
result = get_value_by_pattern(data, r"^title\.")
assert set(result) == {"Bonjour"}
@@ -218,7 +229,7 @@ def test_utils_get_value_by_pattern_matching_key():
def test_utils_get_value_by_pattern_multiple_matches():
"""Test that all matching keys are returned."""
data = {"title.extension_1": "Bonjour", "title.extension_2": "Hello", "id": 1}
result = utils.get_value_by_pattern(data, r"^title\.")
result = get_value_by_pattern(data, r"^title\.")
assert set(result) == {
"Bonjour",
@@ -229,7 +240,7 @@ def test_utils_get_value_by_pattern_multiple_matches():
def test_utils_get_value_by_pattern_multiple_extensions():
"""Test that all matching keys are returned."""
data = {"title.extension_1.extension_2": "Bonjour", "id": 1}
result = utils.get_value_by_pattern(data, r"^title\.")
result = get_value_by_pattern(data, r"^title\.")
assert set(result) == {"Bonjour"}
@@ -237,6 +248,6 @@ def test_utils_get_value_by_pattern_multiple_extensions():
def test_utils_get_value_by_pattern_no_match():
"""Test that empty list is returned when no key matches the pattern."""
data = {"name": "Test", "id": 1}
result = utils.get_value_by_pattern(data, r"^title\.")
result = get_value_by_pattern(data, r"^title\.")
assert result == []

View File

@@ -0,0 +1,89 @@
"""Tests for the create_tree_node_with_retry utils."""
from unittest import mock
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import IntegrityError
import pytest
from core.factories import UserFactory
from core.models import Document
from core.utils.treebeard import _is_tree_path_collision, create_tree_node_with_retry
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize(
"exc",
[
DjangoValidationError({"path": "not unique"}),
IntegrityError("impress_document_path_key"),
],
)
def test_utils_create_tree_node_with_retry_exceed_max_attempts(settings, exc):
"""Test exceeding the max attempts should reraise the exception."""
settings.TREEBEARD_PATH_COMPUTE_RETRY_MAX_ATTEMPTS = 2
create_fn = mock.MagicMock()
create_fn.side_effect = exc
with (
pytest.raises(exc.__class__),
mock.patch(
"core.utils.treebeard._is_tree_path_collision"
) as mock__is_tree_path_collision,
):
mock__is_tree_path_collision.side_effect = _is_tree_path_collision
create_tree_node_with_retry(create_fn)
mock__is_tree_path_collision.assert_called()
assert mock__is_tree_path_collision.call_count == 2
assert create_fn.call_count == 2
@pytest.mark.parametrize(
"exc",
[
DjangoValidationError({"foo": "bar"}),
IntegrityError("not handled"),
],
)
def test_utils_create_tree_node_with_retry_exceed_exception_not_handled(settings, exc):
"""Test with an exception not handled should return reraise it immediately."""
settings.TREEBEARD_PATH_COMPUTE_RETRY_MAX_ATTEMPTS = 2
create_fn = mock.MagicMock()
create_fn.side_effect = exc
with (
pytest.raises(exc.__class__),
mock.patch(
"core.utils.treebeard._is_tree_path_collision"
) as mock__is_tree_path_collision,
):
mock__is_tree_path_collision.side_effect = _is_tree_path_collision
create_tree_node_with_retry(create_fn)
mock__is_tree_path_collision.assert_called()
assert mock__is_tree_path_collision.call_count == 1
assert create_fn.call_count == 1
def test_utils_create_tree_node_with_retry_success():
"""Test executing successfully the create_fn callback."""
user = UserFactory()
document = create_tree_node_with_retry(
lambda: Document.add_root(
creator=user,
title="success",
)
)
assert isinstance(document, Document)
assert document.title == "success"
assert document.path is not None

View File

@@ -2,7 +2,7 @@
Unit tests for the filter_root_paths utility function.
"""
from core.utils import filter_descendants
from core.utils.paths import filter_descendants
def test_utils_filter_descendants_success():

View File

@@ -4,7 +4,8 @@ from django.utils import timezone
import pytest
from core import factories, utils
from core import factories
from core.utils.users import users_sharing_documents_with
pytestmark = pytest.mark.django_db
@@ -54,7 +55,7 @@ def test_utils_users_sharing_documents_with():
doc_3_pierre_2.created_at = yesterday
doc_3_pierre_2.save()
shared_map = utils.users_sharing_documents_with(user)
shared_map = users_sharing_documents_with(user)
assert shared_map == {
pierre_1.id: last_week,

View File

@@ -4,6 +4,7 @@ from django.conf import settings
from django.urls import include, path, re_path
from lasuite.oidc_login.urls import urlpatterns as oidc_urls
from lasuite.oidc_resource_server.urls import urlpatterns as oidc_resource_server_urls
from rest_framework.routers import DefaultRouter
from core.api import viewsets
@@ -117,3 +118,11 @@ if settings.OIDC_RESOURCE_SERVER_ENABLED:
),
)
)
if settings.OIDC_RS_PRIVATE_KEY_STR:
urlpatterns.append(
path(
f"api/{settings.API_VERSION}/",
include([*oidc_resource_server_urls]),
)
)

View File

@@ -1,170 +0,0 @@
"""Utils for the core app."""
import base64
import logging
import re
import time
from collections import defaultdict
from django.core.cache import cache
from django.db import models as db
from django.db.models import Subquery
import pycrdt
from bs4 import BeautifulSoup
from core import enums, models
logger = logging.getLogger(__name__)
def get_value_by_pattern(data, pattern):
"""
Get all values from keys matching a regex pattern in a dictionary.
Args:
data (dict): Source dictionary to search
pattern (str): Regex pattern to match against keys
Returns:
list: List of values for all matching keys, empty list if no matches
Example:
>>> get_value_by_pattern({"title.fr": "Bonjour", "id": 1}, r"^title\\.")
["Bonjour"]
>>> get_value_by_pattern({"title.fr": "Bonjour", "title.en": "Hello"}, r"^title\\.")
["Bonjour", "Hello"]
"""
regex = re.compile(pattern)
return [value for key, value in data.items() if regex.match(key)]
def get_ancestor_to_descendants_map(paths, steplen):
"""
Given a list of document paths, return a mapping of ancestor_path -> set of descendant_paths.
Each path is assumed to use materialized path format with fixed-length segments.
Args:
paths (list of str): List of full document paths.
steplen (int): Length of each path segment.
Returns:
dict[str, set[str]]: Mapping from ancestor path to its descendant paths (including itself).
"""
ancestor_map = defaultdict(set)
for path in paths:
for i in range(steplen, len(path) + 1, steplen):
ancestor = path[:i]
ancestor_map[ancestor].add(path)
return ancestor_map
def filter_descendants(paths, root_paths, skip_sorting=False):
"""
Filters paths to keep only those that are descendants of any path in root_paths.
A path is considered a descendant of a root path if it starts with the root path.
If `skip_sorting` is not set to True, the function will sort both lists before
processing because both `paths` and `root_paths` need to be in lexicographic order
before going through the algorithm.
Args:
paths (iterable of str): List of paths to be filtered.
root_paths (iterable of str): List of paths to check as potential prefixes.
skip_sorting (bool): If True, assumes both `paths` and `root_paths` are already sorted.
Returns:
list of str: A list of sorted paths that are descendants of any path in `root_paths`.
"""
results = []
i = 0
n = len(root_paths)
if not skip_sorting:
paths.sort()
root_paths.sort()
for path in paths:
# Try to find a matching prefix in the sorted accessible paths
while i < n:
if path.startswith(root_paths[i]):
results.append(path)
break
if root_paths[i] < path:
i += 1
else:
# If paths[i] > path, no need to keep searching
break
return results
def base64_yjs_to_xml(base64_string):
"""Extract xml from base64 yjs document."""
decoded_bytes = base64.b64decode(base64_string)
# uint8_array = bytearray(decoded_bytes)
doc = pycrdt.Doc()
doc.apply_update(decoded_bytes)
return str(doc.get("document-store", type=pycrdt.XmlFragment))
def base64_yjs_to_text(base64_string):
"""Extract text from base64 yjs document."""
blocknote_structure = base64_yjs_to_xml(base64_string)
soup = BeautifulSoup(blocknote_structure, "lxml-xml")
return soup.get_text(separator=" ", strip=True)
def extract_attachments(content):
"""Helper method to extract media paths from a document's content."""
if not content:
return []
xml_content = base64_yjs_to_xml(content)
return re.findall(enums.MEDIA_STORAGE_URL_EXTRACT, xml_content)
def get_users_sharing_documents_with_cache_key(user):
"""Generate a unique cache key for each user."""
return f"users_sharing_documents_with_{user.id}"
def users_sharing_documents_with(user):
"""
Returns a map of users sharing documents with the given user,
sorted by last shared date.
"""
start_time = time.time()
cache_key = get_users_sharing_documents_with_cache_key(user)
cached_result = cache.get(cache_key)
if cached_result is not None:
elapsed = time.time() - start_time
logger.info(
"users_sharing_documents_with cache hit for user %s (took %.3fs)",
user.id,
elapsed,
)
return cached_result
user_docs_qs = models.DocumentAccess.objects.filter(user=user).values_list(
"document_id", flat=True
)
shared_qs = (
models.DocumentAccess.objects.filter(document_id__in=Subquery(user_docs_qs))
.exclude(user=user)
.values("user")
.annotate(last_shared=db.Max("created_at"))
)
result = {item["user"]: item["last_shared"] for item in shared_qs}
cache.set(cache_key, result, 86400) # Cache for 1 day
elapsed = time.time() - start_time
logger.info(
"users_sharing_documents_with cache miss for user %s (took %.3fs)",
user.id,
elapsed,
)
return result

View File

@@ -0,0 +1 @@
"""Core utilities package."""

View File

@@ -0,0 +1,24 @@
"""Dictionary utility functions."""
import re
def get_value_by_pattern(data, pattern):
"""
Get all values from keys matching a regex pattern in a dictionary.
Args:
data (dict): Source dictionary to search
pattern (str): Regex pattern to match against keys
Returns:
list: List of values for all matching keys, empty list if no matches
Example:
>>> get_value_by_pattern({"title.fr": "Bonjour", "id": 1}, r"^title\\.")
["Bonjour"]
>>> get_value_by_pattern({"title.fr": "Bonjour", "title.en": "Hello"}, r"^title\\.")
["Bonjour", "Hello"]
"""
regex = re.compile(pattern)
return [value for key, value in data.items() if regex.match(key)]

View File

@@ -0,0 +1,63 @@
"""Path and tree structure utilities."""
from collections import defaultdict
def get_ancestor_to_descendants_map(paths, steplen):
"""
Given a list of document paths, return a mapping of ancestor_path -> set of descendant_paths.
Each path is assumed to use materialized path format with fixed-length segments.
Args:
paths (list of str): List of full document paths.
steplen (int): Length of each path segment.
Returns:
dict[str, set[str]]: Mapping from ancestor path to its descendant paths (including itself).
"""
ancestor_map = defaultdict(set)
for path in paths:
for i in range(steplen, len(path) + 1, steplen):
ancestor = path[:i]
ancestor_map[ancestor].add(path)
return ancestor_map
def filter_descendants(paths, root_paths, skip_sorting=False):
"""
Filters paths to keep only those that are descendants of any path in root_paths.
A path is considered a descendant of a root path if it starts with the root path.
If `skip_sorting` is not set to True, the function will sort both lists before
processing because both `paths` and `root_paths` need to be in lexicographic order
before going through the algorithm.
Args:
paths (iterable of str): List of paths to be filtered.
root_paths (iterable of str): List of paths to check as potential prefixes.
skip_sorting (bool): If True, assumes both `paths` and `root_paths` are already sorted.
Returns:
list of str: A list of sorted paths that are descendants of any path in `root_paths`.
"""
results = []
i = 0
n = len(root_paths)
if not skip_sorting:
paths.sort()
root_paths.sort()
for path in paths:
# Try to find a matching prefix in the sorted accessible paths
while i < n:
if path.startswith(root_paths[i]):
results.append(path)
break
if root_paths[i] < path:
i += 1
else:
# If paths[i] > path, no need to keep searching
break
return results

View File

@@ -0,0 +1,62 @@
"""Treebeard path collision handling utilities."""
import logging
import time
from django.conf import settings
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import IntegrityError, transaction
logger = logging.getLogger(__name__)
def _is_tree_path_collision(exc):
"""Return True when `exc` is caused by a Document.path uniqueness conflict.
Treebeard computes the materialized path by reading the current siblings;
under concurrency two callers may compute the same value. Depending on
timing this surfaces either as:
- `django.core.exceptions.ValidationError` raised by `full_clean()` /
`validate_unique()` before the INSERT (BaseModel.save calls full_clean),
with this message `{'path': ['Document with this Path already exists.']}`
- or `IntegrityError` from the database unique index when the validate
step misses the conflict. With this message:
duplicate key value violates unique constraint "impress_document_path_key"
DETAIL: Key (path)=(0000001) already exists.
"""
if isinstance(exc, DjangoValidationError):
message_dict = getattr(exc, "message_dict", None)
if message_dict is not None:
return "path" in message_dict
return "path" in str(exc).lower()
# search in the IntegrityError exception
return "impress_document_path_key" in str(exc).lower()
def create_tree_node_with_retry(create_fn):
"""Run `create_fn` in a fresh atomic block, retrying on path collisions.
The Document.path field carries a unique constraint, which is the source of
truth that prevents duplicate paths. On collision we let the failed
transaction roll back, and call `create_fn` again so treebeard recomputes
the path from the latest state.
"""
max_attempts = settings.TREEBEARD_PATH_COMPUTE_RETRY_MAX_ATTEMPTS
for attempt in range(max_attempts):
try:
with transaction.atomic():
return create_fn()
except (IntegrityError, DjangoValidationError) as exc:
if not _is_tree_path_collision(exc) or attempt == max_attempts - 1:
raise
logger.info(
"tree path collision on attempt %d/%d, retrying",
attempt + 1,
max_attempts,
)
time.sleep(attempt * 0.1)
raise RuntimeError("create_tree_node_with_retry exited without result")

View File

@@ -0,0 +1,55 @@
"""User sharing cache utilities."""
import logging
import time
from django.core.cache import cache
from django.db import models as db
from django.db.models import Subquery
from core import models
logger = logging.getLogger(__name__)
def get_users_sharing_documents_with_cache_key(user):
"""Generate a unique cache key for each user."""
return f"users_sharing_documents_with_{user.id}"
def users_sharing_documents_with(user):
"""
Returns a map of users sharing documents with the given user,
sorted by last shared date.
"""
start_time = time.time()
cache_key = get_users_sharing_documents_with_cache_key(user)
cached_result = cache.get(cache_key)
if cached_result is not None:
elapsed = time.time() - start_time
logger.info(
"users_sharing_documents_with cache hit for user %s (took %.3fs)",
user.id,
elapsed,
)
return cached_result
user_docs_qs = models.DocumentAccess.objects.filter(user=user).values_list(
"document_id", flat=True
)
shared_qs = (
models.DocumentAccess.objects.filter(document_id__in=Subquery(user_docs_qs))
.exclude(user=user)
.values("user")
.annotate(last_shared=db.Max("created_at"))
)
result = {item["user"]: item["last_shared"] for item in shared_qs}
cache.set(cache_key, result, 86400) # Cache for 1 day
elapsed = time.time() - start_time
logger.info(
"users_sharing_documents_with cache miss for user %s (took %.3fs)",
user.id,
elapsed,
)
return result

View File

@@ -0,0 +1,36 @@
"""Yjs document conversion utilities."""
import base64
import re
import pycrdt
from bs4 import BeautifulSoup
from core import enums
def base64_yjs_to_xml(base64_string):
"""Extract xml from base64 yjs document."""
decoded_bytes = base64.b64decode(base64_string)
doc = pycrdt.Doc()
doc.apply_update(decoded_bytes)
return str(doc.get("document-store", type=pycrdt.XmlFragment))
def base64_yjs_to_text(base64_string):
"""Extract text from base64 yjs document."""
blocknote_structure = base64_yjs_to_xml(base64_string)
soup = BeautifulSoup(blocknote_structure, "lxml-xml")
return soup.get_text(separator=" ", strip=True)
def extract_attachments(content):
"""Helper method to extract media paths from a document's content."""
if not content:
return []
xml_content = base64_yjs_to_xml(content)
return re.findall(enums.MEDIA_STORAGE_URL_EXTRACT, xml_content)

View File

@@ -161,7 +161,8 @@
},
"onboarding": {
"enabled": true,
"learn_more_url": ""
"learn_more_url": "",
"ready_template_url": ""
},
"help": {
"documentation_url": ""

View File

@@ -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")
@@ -500,6 +507,11 @@ class Base(Configuration):
environ_name="COLLABORATION_WS_NOT_CONNECTED_READY_ONLY",
environ_prefix=None,
)
COLLABORATION_WS_INACTIVITY_TIMEOUT = values.IntegerValue(
None,
environ_name="COLLABORATION_WS_INACTIVITY_TIMEOUT",
environ_prefix=None,
)
# Frontend
FRONTEND_THEME = values.Value(
@@ -801,8 +813,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 +1082,16 @@ class Base(Configuration):
),
}
CONTENT_METADATA_CACHE_TIMEOUT = values.IntegerValue(
60 * 60 * 24, environ_name="CONTENT_METADATA_CACHE_TIMEOUT", environ_prefix=None
)
TREEBEARD_PATH_COMPUTE_RETRY_MAX_ATTEMPTS = values.IntegerValue(
10,
environ_name="TREEBEARD_PATH_COMPUTE_RETRY_MAX_ATTEMPTS",
environ_prefix=None,
)
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):
@@ -1138,6 +1182,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 +1219,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

View File

@@ -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 ""

View File

@@ -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"

View File

@@ -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"

View File

@@ -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 ""

View File

@@ -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"

View File

@@ -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"

View File

@@ -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 ""

View File

@@ -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"

View File

@@ -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 ""

View File

@@ -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 ИИ"

View File

@@ -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 ""

View File

@@ -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 ""

View File

@@ -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 ""

View File

@@ -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 ШІ"

View File

@@ -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 ""

View File

@@ -2,12 +2,12 @@
# impress package
#
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
requires = ["uv_build>=0.11.9,<0.12"]
build-backend = "uv_build"
[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",
@@ -21,12 +21,11 @@ classifiers = [
]
description = "Docs is a collaborative text editor designed to address common challenges in knowledge building and sharing."
keywords = ["Django", "Contacts", "Templates", "RBAC"]
license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.12"
license = "MIT"
requires-python = "~=3.13.0"
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 +33,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.0.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,29 +79,30 @@ 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]
packages = { find = { where = ["."], exclude = ["tests"] } }
zip-safe = true
[tool.distutils.bdist_wheel]
universal = true
[tool.uv.build-backend]
module-root = ""
source-exclude = [
"**/tests/**",
"**/test_*.py",
"**/tests.py",
]
[tool.ruff]
exclude = [

View File

@@ -1,7 +0,0 @@
#!/usr/bin/env python
"""Setup file for the impress module. All configuration stands in the setup.cfg file."""
# coding: utf-8
from setuptools import setup
setup()

2440
src/backend/uv.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -2,7 +2,6 @@ PORT=3000
BASE_URL=http://localhost:3000
BASE_API_URL=http://localhost:8071/api/v1.0
COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY=true
MEDIA_BASE_URL=http://localhost:8083
CUSTOM_SIGN_IN=false
IS_INSTANCE=false

View File

@@ -2,7 +2,6 @@ PORT=3000
BASE_URL=http://localhost:3000
BASE_API_URL=http://localhost:8071/api/v1.0
COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY=true
MEDIA_BASE_URL=http://localhost:8083
IS_INSTANCE=false
CUSTOM_SIGN_IN=false

File diff suppressed because one or more lines are too long

View File

@@ -192,10 +192,10 @@ endobj
(react-pdf)
endobj
55 0 obj
(D:20260403132357Z)
(D:20260505110445Z)
endobj
56 0 obj
(chromium-8651-0-doc-export-override-content)
(chromium-4903-0-doc-export-override-content)
endobj
52 0 obj
<<
@@ -216,7 +216,7 @@ endobj
58 0 obj
<<
/Type /FontDescriptor
/FontName /VIBRRZ+Inter18pt-Regular
/FontName /HRJUFI+Inter18pt-Regular
/Flags 4
/FontBBox [-742.1875 -323.242187 2579.589844 1109.375]
/ItalicAngle 0
@@ -232,7 +232,7 @@ endobj
<<
/Type /Font
/Subtype /CIDFontType2
/BaseFont /VIBRRZ+Inter18pt-Regular
/BaseFont /HRJUFI+Inter18pt-Regular
/CIDSystemInfo <<
/Registry (Adobe)
/Ordering (Identity)
@@ -247,7 +247,7 @@ endobj
<<
/Type /Font
/Subtype /Type0
/BaseFont /VIBRRZ+Inter18pt-Regular
/BaseFont /HRJUFI+Inter18pt-Regular
/Encoding /Identity-H
/DescendantFonts [59 0 R]
/ToUnicode 60 0 R
@@ -256,7 +256,7 @@ endobj
62 0 obj
<<
/Type /FontDescriptor
/FontName /TDKMKH+Inter18pt-Bold
/FontName /XKLDZR+Inter18pt-Bold
/Flags 4
/FontBBox [-790.527344 -334.472656 2580.566406 1114.746094]
/ItalicAngle 0
@@ -272,7 +272,7 @@ endobj
<<
/Type /Font
/Subtype /CIDFontType2
/BaseFont /TDKMKH+Inter18pt-Bold
/BaseFont /XKLDZR+Inter18pt-Bold
/CIDSystemInfo <<
/Registry (Adobe)
/Ordering (Identity)
@@ -287,7 +287,7 @@ endobj
<<
/Type /Font
/Subtype /Type0
/BaseFont /TDKMKH+Inter18pt-Bold
/BaseFont /XKLDZR+Inter18pt-Bold
/Encoding /Identity-H
/DescendantFonts [63 0 R]
/ToUnicode 64 0 R
@@ -296,7 +296,7 @@ endobj
66 0 obj
<<
/Type /FontDescriptor
/FontName /JYBWBW+Inter18pt-Italic
/FontName /QHBJWW+Inter18pt-Italic
/Flags 68
/FontBBox [-747.558594 -323.242187 2595.703125 1109.375]
/ItalicAngle -9.398804
@@ -312,7 +312,7 @@ endobj
<<
/Type /Font
/Subtype /CIDFontType2
/BaseFont /JYBWBW+Inter18pt-Italic
/BaseFont /QHBJWW+Inter18pt-Italic
/CIDSystemInfo <<
/Registry (Adobe)
/Ordering (Identity)
@@ -327,7 +327,7 @@ endobj
<<
/Type /Font
/Subtype /Type0
/BaseFont /JYBWBW+Inter18pt-Italic
/BaseFont /QHBJWW+Inter18pt-Italic
/Encoding /Identity-H
/DescendantFonts [67 0 R]
/ToUnicode 68 0 R
@@ -336,7 +336,7 @@ endobj
70 0 obj
<<
/Type /FontDescriptor
/FontName /DLRHPN+GeistMono-Regular
/FontName /NBHLIK+GeistMono-Regular
/Flags 5
/FontBBox [-1738 -247 654 1012]
/ItalicAngle 0
@@ -352,7 +352,7 @@ endobj
<<
/Type /Font
/Subtype /CIDFontType2
/BaseFont /DLRHPN+GeistMono-Regular
/BaseFont /NBHLIK+GeistMono-Regular
/CIDSystemInfo <<
/Registry (Adobe)
/Ordering (Identity)
@@ -367,7 +367,7 @@ endobj
<<
/Type /Font
/Subtype /Type0
/BaseFont /DLRHPN+GeistMono-Regular
/BaseFont /NBHLIK+GeistMono-Regular
/Encoding /Identity-H
/DescendantFonts [71 0 R]
/ToUnicode 72 0 R
@@ -376,7 +376,7 @@ endobj
74 0 obj
<<
/Type /FontDescriptor
/FontName /LHWXUO+Inter18pt-BoldItalic
/FontName /VMRKYJ+Inter18pt-BoldItalic
/Flags 68
/FontBBox [-795.898437 -334.472656 2596.191406 1114.746094]
/ItalicAngle -9.398804
@@ -392,7 +392,7 @@ endobj
<<
/Type /Font
/Subtype /CIDFontType2
/BaseFont /LHWXUO+Inter18pt-BoldItalic
/BaseFont /VMRKYJ+Inter18pt-BoldItalic
/CIDSystemInfo <<
/Registry (Adobe)
/Ordering (Identity)
@@ -407,7 +407,7 @@ endobj
<<
/Type /Font
/Subtype /Type0
/BaseFont /LHWXUO+Inter18pt-BoldItalic
/BaseFont /VMRKYJ+Inter18pt-BoldItalic
/Encoding /Identity-H
/DescendantFonts [75 0 R]
/ToUnicode 76 0 R
@@ -713,30 +713,21 @@ endobj
/Filter /FlateDecode
>>
stream
xœí]ݎ㸱¾ï§ð ´Â?°èdO=6;@.\Ìhfr0}‚Í¢DŠÿ”e¯»·¶×cWY*ŠUÉ*Êö‡/Hý=cõ<63>dx<64>Bx¼Ì¯O?Û?lÀ:êÿÕ?ª¿ä?{ëèBbßu:Œ¾h÷ý—ÿÿßùË_þôûËüË2Gü2ÿßÓïþô ¾üã<75>Û™‰üýϪ÷Ö<65>äÉåÃ×§¿}‡RzÄÔƒª÷…z¨—ø³úgTÏxyFüå"ù0a†8½è“ÈË…Ða"£<>Ø(Ä‹º
9Œ#ãR<18> ¿_>ü÷Ó}P—º^Œ<> ¢$㲟<C2B2>ÛÜŸï#,Ä€&J©\Ž~“>$§ûP¢aDxiéÈ,9ü ûðË-|ÈbLù0ð¶Qüp^“½£™Èħ@ÖNªéI¢ËëÓÈÑ@¤rç¤åo<C3A5>\fÿX…uåªo±ª®˜õµ¬ço/ã³½æoöÙÿw~úŸ§¿^œ“ƒ “dSAF6©á£ÆÃ0<C383>db Î¥T“Ò õ3uz¶ÅþHÆõ¢™¾}yúêů—‡ŠÓJõ¢‡·!gíˆ^åg¥x^—(ó&2ºÍòøòý?mì<6D>AÏ4j G<C2A0>h%°·^C<E28093>ßo"/½qs·ÑsY¦>ó`vºÓÇÝëõÔ˜[Z<>¾óÕDÉŽîw3ˆÝcô<77>åØ-ÏzŠÒ áë4ªäo<C3A4>\fÿXµýo±ª®˜õµ¬ço/ã³½æoöÙÿ¿ºØ¸UŬ&ziÙ­5Ô,@ËR„„]šL„—F"¶«6®ÜÕ$Yp²KÍÒï¹HÌ ³è7 ÓåÏxmÎ+9p2pfð齌ûåYZÃW6°žPpÐÞ<C390>¹ä×ä¥^ãdøI¡C%äþâÒ¦úé~"—?ýôô·¿«ëülà°oëõ´ØÙq*”QÕóa<C3B3>yb-¬Õ<C2AC>ô!/áÔŸ #<03>bc‰ˆì<CB86>½X*„ônSä<53> »4÷Ú3#¯ÐCÑ«ðy>±<¶sŽ£ûÁ³÷•‡¬ïŒ0F \ƒtÓµö€Ûšy-®Fhy+µ–µ€ e"”ïs{Ør+YÛúWŽÿ¶ôEƒ±7'º] ý:Û¶¶O8Ù F<>µ)5fl¹lºSnMýÜZo\}µYØ>«JS»±Å²ù·Ž"WFùí«9*rÅ({¹L2föÒÌœÞe³ïK]¤ê·a)V¾ý.qÒGõø¤³÷ú³ó<C2B3>©rWÿèý<4˜ÍM»Ÿ§[z¹ÈA5g÷ø´ó0K:MW"z—ÇßÖK­»õÙ-?ìÕ.<2E>nënˆ“Òí½¡÷•n¯}TuÅ.SqvœŠgTuÅO·Wg”@øLOfL‰ˆÜ.ݾ*üwìÒÜ«ïÍíeüÂÌ)+ôPô*|žOD,<2C>íœãè~ðì}•K·}#Þ `Œ@¸éPÿZ#{ãtÛÚ±t»6”‰P¾Ïía»:Ý.Ä[ú¢Á˜<19>o(Ýþ5v°õ½¨q £Édœæž‰½eŒ?ÚtV½&Øfžt½<74>be¶Üj&ëq*ád
Ì&¡Ú†>UknIì5L{•1¬ra:0,ô¾ùz!a#™ÌU<C38C>a2MÞ!ß÷|‡µ<E280A1>¸­RDúNÆiK©ïÔ´Ÿîw‡Û1Íÿ¸3eJ„éÞŤ)’¤-”& íq)¨°~ÌËÃÀþóRlmŸœ°Çlï­èW{»Q83ÎäRµé¡°<C2A1>X0¼#8.5ä†IM1“¸ #æMJ2,Ñ…‰_c¢RŒ`)—ZÙÎTÌ»ÃKí„¡B@Ò{jiðŠÜß̈Üí¬|Zæk<1F>zÞ&D<>MtqïÇä³i<C2B3>~‡­<C2AD>ÿ¸Z¦ØÀõ‡Ž¾Cч<C391>¶UJØgW<1B><˜÷>-Ãt¹]tÈ2˜ã¿X[VÖÓñÞÛlØ…h[õyiœá‰<58>œK´<4B>.”¯Kÿ¿•×05£I%­+8­æ[˜¥:åø)ù™$×<C397>Ca<06>jù¥„ªdò£½\fÿXdNEY1/\¹ #F…TŽUÊâ^ŽòììwkÓ§¯‡œÓ|¿óß²¸Ø·õšê[¤ª+f?ìÎŽS¡Œª®˜Ìka­†¤u —ø<ªúǦÑHl¾ÂÙ3ޝ
ý<EFBFBD>àzã{<7B>ç¡ÇvÎqt/tžsw:3&¼ãp С¾µFõ·{Ÿ³´ÜúÕ²æ•cÿžïqÞkÓe¿q j2p½B·|V.·ìð×t©@L‰ÄÉ/.ì³¼ýÊ÷úD<C3BA>º2NÒf/—¤Ù?™SQVÌ ‰¤ÑóBiñ·PG»./­Ã >P1a9^Så5O¸¨ÐÐ~Pdì…çBØó;Ú)wðš0ßr^Ý·õšê{¤ª+f|ÎŽS¡Œª®˜ÃÜka¯†¤ ¯ Ÿ¶@ö™ xŠM&Â’vC?ßžéå*,Ü ¿.Ç);8LÓŠLÖYL Q˜ó‰CÇvÎ Z/Ë^÷³Ò´ÉÄù  <>àPŸÏBÎm«® ¹x=ëY—
rg.pÎÈH7çD¥~µ4¡+Çhƒw”fdç´Î ¬÷©V¶&@æFQz+Yl“Dì÷<>ü+j½%€Ù€&½¯^¯L¶õÅ~Xˆ0183ÿ<E280B9>\fÿX“£1žó·¾M6Õóļ0û]Ù¥ˆÓ:}¹$mŸ¬Pc3®<©½N šd.Ê7´â9{áùùöü‡vʼ&Ì7Íìvm½¦ú©êŠÙŸ³ãT(£ª+æã0wb-ìÕ<C3AC>ôá5áÓÈ>“‰Ød",ÉQaíåãÛ3½\……;á×%¥o#U]±›Þ¢´¿ ª+懂ˆíœ´n8½î#2c2q~ôÈ7š´NBÎmKñ ¹ÄzÖ³.9pîÓïî\à¤á©š<C2A9>Õ•nøUÒ„¾£ ÞQš<C5A1>Ó:'°n\ܧ2 Z}ÜÊäs©2È|ŠóÍW&v}…MôÇwФ*G Ÿ<>\fÿX3<58>L$+æ·¾<C2B7>¦zž˜f¿+~Џô,”KûÌ÷4)'Æö£<tQ¾¡`ˆgì…ççCØó;Ú)wðš0ßt¡Ûµõšê{¤ª+f|ÎŽS¡Œª®˜<C2AE>Ã܉µ°WC҇ׄO »[8aIŽ
k/ßžéå*,Ü ¿.)<29>|©êŠÝô¦ý%U]1Ÿ8hl眠uñèu“‰óë <C3AB><>àФurn[ŠÍ%Ö³žuÉ<75>s—~÷ç'<27> OÕ„¬ö¨tï&ôåMðŽÒŒìœÖ9<C396>uãâN•ɾՇ­LËW&|@rÒ_Èyó•‰]_ˆÝĤTª`[ÖúRîõìŽ2?þIERH¿t«±[ξ˜fÿÂý„<C3BD>¬[¶;¹$­sÕ7Â(š”»ý1½wH¡™ýpÎX Ïϸ;˜ü®†6Ê];Î[.d~K¯qEYœ¼Üù«%eqîï&”Ytvö"oµ€Ï_îòîNbÛÚJG¬mB8×» Ð%<25>#U]±<C2B1>´¼¤ª+æ`-Ãó¯ PÌÊö±6˜8¿åºk¨;{y2n[
ÍÅkLÏzá<7A>o—þö¯Îç ßW5¡©=*½<>+/Ý]«~  £¥?;_uNNݘ¸SU°oõa«åûüÙORQ:No¿*°+ˆœì"•úW7¨ä¶¨ÜË%iö<69>5Y•¤Y1/lk˜¾$äÆË"æ…ÙÔÙž…rIÚf ŽÕ<AôÇýT-ôP¾<50> WK  0çw7´SîÞ5A¾iƶkë5Õ÷HUWÌ>ôœ§BU]1¹ka¯†¤ ­ —Ö»KúŠAI k.Ýž©å*$Ü ½. <0A>\©êŠÝÔ$öEU]1Ÿ8Xl眠õ¢±èt<1F>i‰Óë<C393><C3AB> ¾AàЄunn[^Í%Ö²ž5ÉAÓOºd'<27> ¬ö¨ô¢¯’!teMàŽŒì|Ö9yu£â>IÐêÃV$æÇ–òŸ ¢’,ßɺ¶,Y#“‚³ ëm¹½\fÿXdN•Y1/xKMñœ”{í-«û´pÁž˜Ö™éßÇähd\<5C>©I!Zò„g²<67>ì‡uÆZpvÁíÁ,áuôµ½gׄõ«Ú¾­×T¿#U]1û`svœ
eTuÅ|ÖžX y5$]@Mø´¬Ïx˜ˆJj4XsùèöL&W!áNèuùgèÙPS“ýÉ,Jí³šš<Ÿˆÿ)¶sN¬zAXr¶ôÁøìœGÀ^¨Ãg å¶5ö¾µhÝj^€÷Iu×ý ð4 j A'Ì*kOÞЄá0sÈÍU=3S_ôïTWì}ܲb|Ñ?0MÌÏ™æê 6Œìþ?1{ƒúb]AìÇ ˜`
ÌÁ£ÿN.I³¬É¼ÍŠyÁ-`ÌæoNʽž½^ø9[°'æ…mžä˜$d¿wy&×H<C397>²e¬gçݘó:úÚÞ³kÂzÓ m×Ökªßª®˜}°9;N…2ªºb>k'ÖB^ IP>­ƒÕß…+G%5¬¹|t{&“«<E2809C>p'ôº43ôl¨©Éþd&ðyMMžOÄ?‹íœ«nœíÃ0c0>»ŽóØ[àMS' å¶ô¾µxÝj^€6<îrê¾5þ„Aà4 j A/ÌÊkWÞÐá0sÈÍU=3S_ôïS_ì}Üú‚o[0†ÙÛ¿m±.ÈþXîÊ9…liº—KÒìës¦Ä¼à.$mâæ‰yaö»â'|H®Ã×—KÒ:QŒ ˜(oºB<Žtš.*4´Ù{áù…3…ßáÐN¹ƒ×„ùˆGp\û©êŠÙŸ³ãT(£ª+æÃ0÷ÄZØ«!éÂk§-<2D>}Vƒ Å&aIŽŠ=Sa"¾=ÓËUX¸~]Âù6RÕ»é-ÌçKªºb>q(ðØÎ9Aë…cÙë>"Ó&ç7€>ù
C}> 9·-­ƒæâõ¬g]òÀ¹Ë¹ûs<C3BB>sF†¯jBV{TzáWKºrŒ6xGiFvNëœÀºqq§ÂdßêãV&â]~ *¦q&ítàq[Ÿ÷xÜ$ð¸<01>ð¸<01>ð¸m`Ûš·Ür ÂÌÈË:xÜÌ7<C38C>±¥þê(ˆRå4õ4¸t4õ@á†#Þªc0
·èڀ (Ü2A
·ãÈ
·Ã#(Ü€Âí1Êâ—_ÔCÍ¿dJ¦ÿDÖŠ<ii9€'­fàIž4àIž4àIž4àI;àIž´ƒé?ù¨ŸJéÿH(£²!ý2²H22 #³ <02><19>µ #22 #22 #»iú?Û Ÿþ3.ÅØ<C385>þã0~ã
ã0~ã0~<7E> `ü:Œ|`üƯK½Í®{)õ¦BõƒÕSo ÕŠ% Õ*6! ÕZ- ÕZ- ÕZ- ÕZ­[¦ýŸí®{6í'¸¨«ÃPW¥Â
ÔU@]ÔU@]µÙê* ®ê* ®êªc_•ÅöQøº,C„6ìÚ5”c0j¨TX<54>
¨¡€
¨¡6;@ ÔP@ ¨¡²ÑÿÍSCEù;)ß•!±þ;p0áH&à`¦°¸&à`ºj(p0p0Ó­Kó}×Âw^ÅÀõ'©HªˆÉŽ0Ùú TGþ<>êHÕPÕPÕѶ­ :Ê-wÑ ÌŒ¼¬ó€êÈd{òåÂUmÀ0S¹ùw ï¢*õ“–óÈç?¢Ét<C389>£<EFBFBD>sÑTßþ£Xþ#à?²_Êþ#à?þ£ö¡üGÀüGÀüG·å?z¹0U#,ߺÔ_Ð$/Êà0<C3A0>£À$Y`1P$Ç–hf¤Xf$`F²É0#30#µ`Ff$`FjŠ
0#3Òaf¤— W9¾$Óµ0 Ó€øR<16>Oé²€Ó$ e0&E0&c](€1 “€1©}(c0&c0&cÒm“TY@&øÈmY°Ô
é[£ù÷†Zè“€> è“P`è“€> è“€>éd}Òaä}Ð'=XžÞ~*ÚŒà†ŒX“b X“
AÖ¤€,X“€5 X“€5 X“€5 X“€5馬I/— BÚÏäëÏâìä9÷a<>´¡:¥íWÿ<57>N)V S:% S:¥ÍÐ)<01>Ð)<01>Ð)]E§Ôú…[…]eG_$p+·Ò¸•öøÛ’àVʸ•ºÐër:àV:+àVn%àVŒ¾en¥¦/ɪà#I'ÙB“
4K4K@³´¨€f hŽâ×%y@³4K@³4Kû¨ÍÐ,]E³ý~Nö²ª%A4I»šã^¢ª·oýõ{L hLvV…h"*йÚÇï¥:¨mý#×·úÅlÔ²l[ŽY˜‡éãd¯Vµ?Œk? —ïÿ¹½ÝÁ[wz¹<s”úiíÉC¹õ¤á/ɇI³g˜lè2¾<77>\84ˆz<03>ƒ-üõ¡˜ªÇd´˜r<CB9C>'gÃ>>/”øs²×ø=^Ê£väÉY §&g²*oKŠBØÖ?lƒ@íƒÙøM:&¸Œ3ɘ™³QmêXS4<53>žº¡5èÚÔGû0fn䢡p×ó$ä}þ{ƒó;£c)òWëŸOŸ“E
êF
Ks댃:<3A>Š&(pþ¾¡ §f(Lz “Ê6ËhPÝ^¢ß {No<0F>t<EFBFBD>HPÝöü6bå!}uäýbcdh™>¦ð!ôÏÝr@ÁºbOë<1B> Y—[
ÿ1ªyZ ÷|ªBä=× £š<ήÊ©¹­>ÍKn¾Yþ<59>>Vf~¼#ì½td|/áï¥#â½tD>VGÞaN¾*ý×¥œ6ó§e]4%6ZÖX³/š®¢õœþ ºucòÀ•«&m7<6D>UY£ÊúçÖSûOÕÜZ\rüe9êYåÔÂ$f;Cû1È6ÄU=&[¶ŽÎ¡æ½Uþl_ +g62('*[¼ÍeeÕþÙxóõI,Ë4OîŸY+ĸ¼ü¯ pÍ[þ Vê.ûè.^æh¬í,=Ò'7ÒÃmÙÿ§os2
xœí][<5B>㸱~ï_á?Ð
XôC²'Áž‡ Af439Ÿ`³ÀÉßIQÅ;eÙÛÝ[Û뱫,Ū<C385>deûäÿž±þG2<H…/Óõég÷‡íøBGó¿~ãGý—|ãgïoy]È@Ü»›ã<>ÏÚß}ÿåÿÿwúò—?ýþ2ýò„ì¿Lÿ÷ô»?ý‚/ÿøEŸõãzfâ"ÿÁ³ê½õA[þ#¹`rùðõéoß!„´ ú}¡ú%þ¬ÿõ3žŸ¹H>(̧s:(2
­B¼è«<EFBFBD>Ã82.…ÕÈ— úûåÃ?ý×}©ËÅ9 Á± Z².ûÙºmûó}„…<18>¢”Êùè7éCrº%F„ÕH[|HG6`ɹào؇_îáC.°cʇ<C38A>ȰŽâWçµ1Ù;:EW9€,<2C>ÔÓ“D—ëÓÈÑ@¤v§2ò·@.I“¬ÆºvÕ·XUWLæZó×—ñÉ]ó7÷ìÿ;=ýÏÓ_/›“ƒ J2Š© #Szøèñ0¨(†±àL QJ=) Ò<ÓMoÃ6Ûɸ\43×ò¯/O_½8âå2òPÙ´Rÿ…èámÈYgG7¢ùY+ž—%ʾ‰¬nµü¾|ÿOûcг<C390>¤G ì-—µ¡Îï7ŒÞȸyØÅ˜¹ŽÌSŸ}07Ý™ã¿îõfjÌ-­“RH…|õǦ  Qr£ûÝ̲a÷<>ãc9vóß³™¢ÌbCø2<C3B8>jù[ —¤É?`cÿ[¬ª+&s-ËùëËøÅä®ù›{öÿ¯.6ÛªbW³´ìÖj y)BÂ-M6Âs#ëU[W®Ë<C2AE>nÌ8Ù¥véß¹HÌ è7ÓùÏz­Î+9p2pfñ齌ûåYZÂW6°œPpÐÞ<C390>½äkòRoq²‡Ž?ü¤Ñ¡rÿ?qiSýô‡??‘Ë¿Ÿ~zúÛßõu~¶pØ·u=mŒxÞìl*”QÕÓa<C393>yb-¬Õ<C2AC>ô!/áÔŸ #U±±DDvÈž-Bú°)òA<C3B2><41>»öÌÈ ôPô*|žND,<2C>íœãè~ðì}åá&kÄ;£Œ— Ýu­½'àÖf®ÅÕÍo¥Ö²°¡L„ò}n[n%k[ÿÊñ_—¾h0öæD÷¡_g»Öö 'Äh²Ö ¥ÆŒ #‚©åÖÔÏ­ÍÆÕW·™…ݳ®ô0u[,(rm”ß¿š£"WŒ²—ˆ”1»—f÷àÌ.{_š"Õì¸ j¤Xûö»ÄIõã“~LÞëÏol•»øÇìç¡Ánnºý<ÓÒËEº9·Çgœ‡YÒi¦1»<þ¶^­Ïnùa¯v<C2AF>tÛtCœ”nï ½¯t{é[¤ª+v™ÊfgSñŒª®xãéö⌟ âÉŒ)û¥Û7…ÿA<C3BF><41>»úÞ\_Æ/윲@E¯ÂçéDÄòØÎ9ŽîÏÞW¹tÛ7â<37>ÑÆ„K<E2809E>õ¯5²wN·= K·ë`C™åûÜÓíBü×¥/Œ™ø†Òí_cÛÜ‹*0R6ã´÷LÜ-cüÑ¥³ú5Á.ó¤Ë}'³ùV3YŽÓ 'Ó¨`. 56Ì©æX{KÚf¯aÚ«<C39A>a<EFBFBD> Ó<>aaöÍ— Éd® Ê6ù€|ßó6>â®Jé;xÕšR?,¨i?=îvcÿqgÊêÑŤ-’¤+””ƒö8TØ<¦ùaaÿy.¶ÖON¸cÖ÷ô«»Ý(-œgr®ÚÌPXO,œ rƒÒSŒ÷#aļII†å º0ñkLTÚ!,å\+»™Šywx©0tHzo@/ ^‘û›»<E28098>•Oó|íãÑÌÛ„h#Eg÷~L^1Sƒ$„ðl­ìüÇõ2Ån>tôŠ>ì´®RÂ<52><·Ú˜äÁ¾÷i¦j|änÑ!óT`<60>ÿâl9ÙLÄ{oµá¢ue4ç¥q†°0;9—h;](_þk¯ajG“NZp:Í·0K±LÊñSò3I[B…M<êå—ªGÍ<E28099>örIšüc=eżpã&Œ5R9Ö)Ëö*î•gg¿[>}9¼àœæû<C3A6>·8øžÅž­kªoª®˜ü°ov6ʨêŠé0À<±ÖjHzP—pi€Ï£®ßql*<11>ÄæK!œ=ãø¦Ð?®w¾×yZqlçG÷Bç9w§3cÂ;¾ˆê[kT»÷9ëAË­_-k^9öïùç£6]öº–!7; tÍgå|ËM—
dÀ”HœüâÂ>ËÛ¯|×'ô¥<C3B4>QI˜½\&ÿXdOEY1/$FOÌ ¥ÅßAíV¸¼´ #,ø@…Âr¼60¦ËkžpQ¡¡ý ÈØ Ï/„ °çw8´Sîà-a¾ç¼ºoëšê{¤ª+&|<>M…2ªºb: sO¬…½.¼&|ÚÙg6`L„%9*܆~>¾=ÓËMXx§Xîà09L+2Yg1<67>Da•VL'Û9'h½p,{ÝÏJÓ&ç7€>ùC}> ­ºæâõ¬g]*È<>¹À9##YÜœ•^øÕÒ„®£ ÞQš<C5A1>Ó:'°n\<¦Z}µ5²7ŠÒ[ɺ`S±Üò¯¨õ–fRf_½^™¬ë‹û°ab°pfÿ;¹$Mþ±6Gc<+æo}S.Õóļ0ù]Ù¥ˆjɾ\ÖOVè±<C3A8>מ4^§) Ù„‹ò í‡xÎ^x~><04>=¿Ã¡<C383>ro ó]3»][×Tß#U]1ùàÛìl*”QÕÓq˜ob-ìÕ<C3AC>ôá5áÓÈ>“‰Ød",ÉQáìåãÛ3½Ü„…áwKJ#ßFªºb7½EiAUWL'Û9'hÝp,zÝGdÆdâü:è#<23>¯ 84i<34>„œûâAs‰õ¬g]ÚÀ¹O¿»s<C2BB>“F†§jBV{TºáWIúrŒ&xGiFvNëœÀºqñ˜Ê$hõõV&ŸK•‰@öSœo¾2qëË(\ª`>¾ƒ”®|vrIšücí4¢HVÌ Ûú6
—êyb^˜ü®ø)âܳP.IÛg¾Õ ¤TŒíGyè¢|CÁÏØ Ïχ °çw8´Sîà-a¾ëB·këšê{¤ª+&|<>M…2ªºb:óM¬…½>¼&|ÚÙÝÂY KrT8{ùøöL/7aáAøÝÒÈ·ª®ØMoaÚ_RÕÓ‰C<E280B0>ÆvÎ Z7™1™8¿úä+MZ'!ç¾¥xÐ\b=ëY—6pîÒïþ\à¤á©š<C2A9>Õ•nøUÒ„¾£ ÞQš<C5A1>Ó:'°n\<¨2Ù·új+Âò• <1F> 9o¾2që q˜”JìÊZ_ʽž¶£ì<C2A3>RÒ/·ÕŒ¸-g_Ì “á~BH<>\9ša)ínRhf?œ3ÖÂó3«¡<C2AB>r׎„óž ™ßÒ5îo (‹Ó¯íüE<C3BC>в8uƒwJÁ,:»{·ZÀç/wyw'±íl¥#Ö6!ŒëC¸%<25>#U]±<C2B1>´¼¤ª+¦`-Ãóo PÌÊö±6˜8¿åºK¨;{y2î[
ÍÅkLÏzá<7A>o—þö¯Îç ßW5¡©=*½<>+/Ý]«~  £¥?;_uNNݘxPU°oõÕVó÷ù³Ÿ¤¢tTo¿*p+ˆTnJó«TrWTîå4ùÇÚ¬JÒ¬˜Ö5Ì\ÚÆË,æ…ÉԹž…rIZg Žõ<AÌÇýT-ôP¾<50> WK  0çw7´SîÞ-A¾kƶkëšê{¤ª+&z<7A>M…2ªºb:òM¬…½.´&\Zì.é+%9$œ¹|t{¦<E28093>ð ônihäÚHUW즶 ±/ªêŠéÄ<C3A9>Àb;ç­<17>E§û€L[Lœ^‡|ñ‡&¬“psßò:h.±õ¬I4ý¤û@¸ðTMÀj<C380>J/ú*BWvÑî(ÁÈÎg<C38E>“W7*S­¾ÚŠÄþØRþTTùû£o»"YÖ9K`Di8 ²Ü–ÛË%iò<69>EöT™ó‚·´¹o“r¯½euŸ.#ØóÂ2S0óû˜<1C>Œ1¥4¢%Ox&ÛÈ~Xg¬gÜÌ^G¯í=»%¬÷\Õöm]SýŽTuÅäƒm³³©PFUWL‡a퉵<E280B0>WCÒÔ„OÀúŒ<07>c‰¨¤Fƒ3—<33>nÏdr„Þ-ÿ =jj²?™E©}VS“§ñ¯b;çĪ„%gû0LŒÏnÀyì%ð‡:|Zî[cï[‹Ö­æhÃã>©îZãož¦C­!è„YeíïÉš0f¹¹ªgfêþƒêŠ]£¯·¬Lûs¦¹ú #{üOÌÞ¡¾XV÷1(&˜³"xtàßÉ%iò<69>µ™— Y1/l sùÛ&å^O^/üœ<C3BC>-ØóÂ:OŒrL²ß;ˆ<“k$HÙ2Ö³ónÌy½¶÷ì°Þ5CÛµuMõ;RÕ¶ÍΦBU]1‡õ&ÖB^ IP>­ƒÕß…+G%5œ¹|t{&“<E2809C>ð ônifèÙPS“ýÉ,Làóšš<<3C>ˆÛ9'VÝ ,8Û‡aÆ`|vç°×Àš¦N@Ë} è}kñºÕ¼­xÜåÔ}kü ƒ`Ó4`¨5½0+¯ý]yC †ÃÌ!7WõÌL}ÑL}±oôõÖ¼xÛ1ÌÞþmeá@îÇrÎ)äJÓ½\&ÿXŸ+0%æ…máBÒ%nž˜&¿+~‡ä2|}¹$-ňЀ‰ö¶¸è+ÄãH•J¸¨ÐÐ~dgì…çBÌ~‡C;åÞæGÐ "Áqé{¤ª+&|<>M…2ªºb: sO¬…½.¼&|ÚÙg=ØPl2䍨3&âÛ3½Ü„…áwK8#ßFªºb7½…ù|IUWL'Û9'h½p,{ÝGdÚdâüÐG _@p¨Ïg!ç¾¥uÐ\¼žõ¬K8w9w.pÎÈðUMÈj<C388>J/üjiBWŽÑï(ÍÈÎi<C38E>X7.T˜ì[}½•‰x—¨ŠiÜ”2N·åy?0<>ÇM<02>ð¸<01>ð¸<01>Û
¶µàqË-wÑ ÌŒ¼¬ó€ÇÍ~Ó;Jà¯ñLªœ¦žFM=P¸áH
·Ãa
·€·êÌ<>Â-º6 p
·LЀÂí8r€ÂíðÈ
7 p{åñË<C3B1>/ú¡ç_¢é?1Ÿµ"„OÚAZàI+„xÒ€' xÒ€' xÒ€' xÒGxÒ€'í`úO>êǧRú?ʨlHÿ<48>Œ,’€Œ ÈÈÜ‚dd@FddíCÈÈ€Œ ÈÈ€Œ ÈÈîšþO®ȧÿŒK16¤ÿÀøŒ_Àø…ƒÀøŒ_ÀøŒ_§ ¿#¿€ñë•¥Þv×½”zS¡ûÁê©7ÐjÅÐj ´ZÐj­Ðj­Ðj­Ðj­Ö=ÓþÏn×=›öÜ´áÔUÃPW¥Â
ÔU@]ÔU@]µÚê* ®ê* ®êªc_•ÅîQøº,C„6ìÚ5ÔÆ`ÔP©°5PC5PC­v€
¨¡€*úè PCe£ÿ§†ŠòwRþ¾+$Bbù!và`‘ LÀÁLaqLÀÁtÓP&à`&à`¦{—öû®…*<C2BC>ORT a²ö¨ŽüÕ‘ª# :ª# :ª£lk3@u”[î¢A˜yYçÕÍöäË…ëÚ€a¦sóïfÞ#Duê'çÏD“é Gç#Ußþ£Xþ#à?r_Êþ#à?þ£ö¡üGÀüGÀüG÷å?z¹0]#Ìߺ4_Ð$/Úà FЉU`, °(´3R,30#¹ä˜‘€ ˜‘Ú‡0#30#5E˜‘€é03ÒË…ë_‰éRP5 >—äSº,àƒR1ÑPcR$c0&¹…“€1 “Ú‡0&c0&c0&Ý—1I—t`<>Ü•s­<73>¾E0Úqo¨€> è“€> <06>> è“€> è“NAÐ'F>Ð'}Ò+ËÃÓÛïBGÜ<>qkR,kR!ÈÀš<14>Åk°&k°&k°&k°&Ý•5éå2¢AH÷™|óYœ<59><å>Œcй6”@§´þê?Ð)¥Â
tJ@§tJ@§´Ú:% S:% Sè”Z¿p«±«í˜n%àVº·ÒkÜJY°·Rz·œ¸•Ç
¸•€[ ¸•€[©£o™[©éK²:øHR%[hR<68>f)€f hfÐ,ÍÒQünIÐ,ÍÐ,ÍÒ>*@³4K7Ñ,E¿Ÿ“ý¢¬®FIMҮ渗¨î-Æký“Wá4&;«C¤ˆŽb®öñûG© j[ÿÈ«ë[ýbÖj9¶­<C2B6>Y؇í£rW«ÛÆ¥Ÿ?<3F>Ë÷ÿÜ <0A>Þîàµ;½\ž9J}€Œ´öäU¹õ¤á/ÉeØ3ì¶t_»uHÎD¿<44>ÇÁþæPLõCY-¦\`µÙp<C399>Ï3%þœìµ”î~¿áÜ>…Œ<9EâÔJAå}Z[û‡"¨{0&e€eÆ8“ŒÙ…Уaó“Â4´ ИúèöÀÌ]e4î){žd˜´OÆop±atL"Eþjýó¹|²HAÝHai¢ŸqЧSÑÎß7¤j†2;úâXP:õ-£Aw{Ž~$Üi8½W=ÒA"AYv¯ÚóÛˆµ‡ÌÕ÷<C3B7>¡Adú˜Â‡0¿‰ôëŠ l<e,o4d9|œïo$üǨ!<21>-ÜÐ𨫢÷\´Œzò8»h)× ®¶oÎ…ÂjùúºÊ„ãaï¥#ã{é/ï¥#òuuä¤ä«Ö<C396>k{[¾š×E[ï£y<C2A3>µ´éÞlšÄkY'9&¯¸r5 ò¶³:kÔYâÜqŒš¯"êšÛˆs<0E>¿ÌG=ëœZØDÒî­?ÙÆœ¸ê‡reë¸9Ô¾·ÈŸÝkáäÌ®ª`¥£¢²ÅÛ\VVퟭ7¯ObþŒ¨}Úþ™ŒBŒóÁÿ†<C3BF>×¾åŸàô1»ï¼©¿ÅË­ƒµžeFºÚFz¸=`
endstream
endobj
77 0 obj
@@ -1405,7 +1396,7 @@ trailer
/Size 87
/Root 3 0 R
/Info 52 0 R
/ID [<7800bd1e70bdb9114e48fc6d480ec696> <7800bd1e70bdb9114e48fc6d480ec696>]
/ID [<f859d5aa137a1b00be187261c0c2b77d> <f859d5aa137a1b00be187261c0c2b77d>]
>>
startxref
101711

View File

@@ -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: [
{

View File

@@ -0,0 +1,352 @@
import path from 'path';
import { expect, test } from '@playwright/test';
import { createDoc, overrideConfig, verifyDocName } from './utils-common';
import { writeInEditor } from './utils-editor';
import { connectOtherUserToDoc, updateShareLink } from './utils-share';
import { createRootSubPage } from './utils-sub-pages';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Collaboration', () => {
/**
* We check:
* - connection to the collaborative server
* - signal of the backend to the collaborative server (connection should close)
* - reconnection to the collaborative server
*/
test('checks the connection with collaborative server', async ({ page }) => {
let webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket
.url()
.includes(`${process.env.COLLABORATION_WS_URL}?room=`);
});
await page
.getByRole('button', {
name: 'New doc',
})
.click();
let webSocket = await webSocketPromise;
expect(webSocket.url()).toContain(
`${process.env.COLLABORATION_WS_URL}?room=`,
);
// Is connected
let framesentPromise = webSocket.waitForEvent('framesent');
await writeInEditor({ page, text: 'Hello World' });
let framesent = await framesentPromise;
expect(framesent.payload).not.toBeNull();
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByTestId('doc-visibility');
// When the visibility is changed, the ws should close the connection (backend signal)
const wsClosePromise = webSocket.waitForEvent('close');
await selectVisibility.click();
await page.getByRole('menuitemradio', { name: 'Connected' }).click();
// Assert that the doc reconnects to the ws
const wsClose = await wsClosePromise;
expect(wsClose.isClosed()).toBeTruthy();
// Check the ws is connected again
webSocket = await page.waitForEvent('websocket', (webSocket) => {
return webSocket
.url()
.includes(`${process.env.COLLABORATION_WS_URL}?room=`);
});
framesentPromise = webSocket.waitForEvent('framesent');
framesent = await framesentPromise;
expect(framesent.payload).not.toBeNull();
});
test('it cannot edit if viewer but see and can get resources', async ({
page,
browserName,
}) => {
const [docTitle] = await createDoc(page, 'doc-viewer', browserName, 1);
await verifyDocName(page, docTitle);
await writeInEditor({ page, text: 'Hello World' });
await page.getByRole('button', { name: 'Share' }).click();
await updateShareLink(page, 'Public', 'Reading');
// Close the modal
await page.getByRole('button', { name: 'close' }).first().click();
const { otherPage, cleanup } = await connectOtherUserToDoc({
browserName,
docUrl: page.url(),
withoutSignIn: true,
docTitle,
});
await expect(
otherPage.getByLabel('It is the card information').getByText('Reader'),
).toBeVisible();
// Cannot edit
const editor = otherPage.locator('.ProseMirror');
await expect(editor).toHaveAttribute('contenteditable', 'false');
// Owner add a image
const fileChooserPromise = page.waitForEvent('filechooser');
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Resizable image with caption').click();
await page.getByText('Upload image').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(
path.join(__dirname, 'assets/logo-suite-numerique.png'),
);
// Owner see the image
await expect(
page.locator('.--docs--editor-container img.bn-visual-media').first(),
).toBeVisible();
// Viewser see the image
const viewerImg = otherPage
.locator('.--docs--editor-container img.bn-visual-media')
.first();
await expect(viewerImg).toBeVisible({
timeout: 10000,
});
// Viewer can download the image
await viewerImg.click();
const downloadPromise = otherPage.waitForEvent('download');
await otherPage.getByRole('button', { name: 'Download image' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('logo-suite-numerique.png');
await cleanup();
});
test('it checks block editing when not connected to collab server', async ({
page,
browserName,
}) => {
test.slow();
/**
* The good port is 4444, but we want to simulate a not connected
* collaborative server.
* So we use a port that is not used by the collaborative server.
* The server will not be able to connect to the collaborative server.
*/
await overrideConfig(page, {
COLLABORATION_WS_URL: 'ws://localhost:5555/collaboration/ws/',
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY: true,
});
await page.goto('/');
const [parentTitle] = await createDoc(
page,
'editing-blocking',
browserName,
1,
);
const card = page.getByLabel('It is the card information');
await expect(
card.getByText('Others are editing. Your network prevent changes.'),
).toBeHidden();
const editor = page.locator('.ProseMirror');
await expect(editor).toHaveAttribute('contenteditable', 'true');
let responseCanEditPromise = page.waitForResponse(
(response) =>
response.url().includes(`/can-edit/`) && response.status() === 200,
);
await page.getByRole('button', { name: 'Share' }).click();
await updateShareLink(page, 'Public', 'Editing');
// Close the modal
await page.getByRole('button', { name: 'close' }).first().click();
const urlParentDoc = page.url();
const { name: childTitle } = await createRootSubPage(
page,
browserName,
'editing-blocking - child',
);
let responseCanEdit = await responseCanEditPromise;
expect(responseCanEdit.ok()).toBeTruthy();
let jsonCanEdit = (await responseCanEdit.json()) as { can_edit: boolean };
expect(jsonCanEdit.can_edit).toBeTruthy();
const urlChildDoc = page.url();
/**
* We open another browser that will connect to the collaborative server
* and will block the current browser to edit the doc.
*/
const { otherPage, cleanup } = await connectOtherUserToDoc({
browserName,
docUrl: urlChildDoc,
docTitle: childTitle,
withoutSignIn: true,
});
const webSocketPromise = otherPage.waitForEvent(
'websocket',
(webSocket) => {
return webSocket
.url()
.includes(`${process.env.COLLABORATION_WS_URL}?room=`);
},
);
await otherPage.goto(urlChildDoc);
const webSocket = await webSocketPromise;
expect(webSocket.url()).toContain(
`${process.env.COLLABORATION_WS_URL}?room=`,
);
await verifyDocName(otherPage, childTitle);
await page.reload();
responseCanEdit = await page.waitForResponse(
(response) =>
response.url().includes(`/can-edit/`) && response.status() === 200,
);
expect(responseCanEdit.ok()).toBeTruthy();
jsonCanEdit = (await responseCanEdit.json()) as { can_edit: boolean };
expect(jsonCanEdit.can_edit).toBeFalsy();
await expect(
card.getByText('Others are editing. Your network prevent changes.'),
).toBeVisible({
timeout: 10000,
});
await expect(editor).toHaveAttribute('contenteditable', 'false');
await expect(
page.getByRole('textbox', { name: 'Document title' }),
).toBeHidden();
await expect(page.getByRole('heading', { name: childTitle })).toBeVisible();
await page.goto(urlParentDoc);
await verifyDocName(page, parentTitle);
await page.getByRole('button', { name: 'Share' }).click();
await page.getByTestId('doc-access-mode').click();
await page.getByRole('menuitemradio', { name: 'Reading' }).click();
// Close the modal
await page.getByRole('button', { name: 'close' }).first().click();
await page.goto(urlChildDoc);
await expect(editor).toHaveAttribute('contenteditable', 'true');
await expect(
page.getByRole('textbox', { name: 'Document title' }),
).toContainText(childTitle);
await expect(page.getByRole('heading', { name: childTitle })).toBeHidden();
await expect(
card.getByText('Others are editing. Your network prevent changes.'),
).toBeHidden();
await cleanup();
});
test('checks disconnection and reconnection when changing tab visibility', async ({
page,
}) => {
await overrideConfig(page, {
COLLABORATION_WS_INACTIVITY_TIMEOUT: 2, // 2 seconds for the test to be faster
});
await page.goto('/');
let webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket
.url()
.includes(`${process.env.COLLABORATION_WS_URL}?room=`);
});
await page
.getByRole('button', {
name: 'New doc',
})
.click();
let webSocket = await webSocketPromise;
expect(webSocket.url()).toContain(
`${process.env.COLLABORATION_WS_URL}?room=`,
);
// Is connected
let framesentPromise = webSocket.waitForEvent('framesent');
await writeInEditor({ page, text: 'Hello World' });
let framesent = await framesentPromise;
expect(framesent.payload).not.toBeNull();
// When the visibility is changed, the ws should close the connection
const wsClosePromise = webSocket.waitForEvent('close');
// Simulate the tab being hidden
await page.evaluate(() => {
Object.defineProperty(document, 'hidden', {
value: true,
writable: true,
configurable: true,
});
document.dispatchEvent(new Event('visibilitychange'));
});
// Assert the ws connection is closed after inactivity timeout
const wsClose = await wsClosePromise;
expect(wsClose.isClosed()).toBeTruthy();
// Check the ws is connected again
webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket
.url()
.includes(`${process.env.COLLABORATION_WS_URL}?room=`);
});
// Simulate the tab becoming visible again
await page.evaluate(() => {
Object.defineProperty(document, 'hidden', {
value: false,
writable: true,
configurable: true,
});
document.dispatchEvent(new Event('visibilitychange'));
});
webSocket = await webSocketPromise;
framesentPromise = webSocket.waitForEvent('framesent');
framesent = await framesentPromise;
// Assert the ws connection is working again
expect(framesent.payload).not.toBeNull();
});
});

View File

@@ -52,29 +52,7 @@ test.describe('Doc Create', () => {
).toBeVisible();
});
test('it creates a sub doc from interlinking dropdown', async ({
page,
browserName,
}) => {
const [title] = await createDoc(page, 'my-new-slash-doc', browserName, 1);
await verifyDocName(page, title);
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Link a doc').first().click();
await page
.locator('.quick-search-container')
.getByText('New sub-doc')
.click();
const input = page.getByRole('textbox', { name: 'Document title' });
await expect(input).toHaveText('', { timeout: 10000 });
await expect(
page.locator('.c__tree-view--row-content').getByText('Untitled document'),
).toBeVisible();
});
test('it creates a doc with link "/doc/new/', async ({
test('it creates a doc with link "/docs/new/"', async ({
page,
browserName,
}) => {

View File

@@ -3,14 +3,9 @@ import path from 'path';
import { expect, test } from '@playwright/test';
import cs from 'convert-stream';
import {
createDoc,
goToGridDoc,
overrideConfig,
verifyDocName,
} from './utils-common';
import { createDoc, goToGridDoc, verifyDocName } from './utils-common';
import { getEditor, openSuggestionMenu, writeInEditor } from './utils-editor';
import { connectOtherUserToDoc, updateShareLink } from './utils-share';
import { updateShareLink } from './utils-share';
import {
createRootSubPage,
getTreeRow,
@@ -65,19 +60,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 +91,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();
@@ -125,63 +106,6 @@ test.describe('Doc Editor', () => {
).toBeVisible();
});
/**
* We check:
* - connection to the collaborative server
* - signal of the backend to the collaborative server (connection should close)
* - reconnection to the collaborative server
*/
test('checks the connection with collaborative server', async ({ page }) => {
let webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket
.url()
.includes(`${process.env.COLLABORATION_WS_URL}?room=`);
});
await page
.getByRole('button', {
name: 'New doc',
})
.click();
let webSocket = await webSocketPromise;
expect(webSocket.url()).toContain(
`${process.env.COLLABORATION_WS_URL}?room=`,
);
// Is connected
let framesentPromise = webSocket.waitForEvent('framesent');
await writeInEditor({ page, text: 'Hello World' });
let framesent = await framesentPromise;
expect(framesent.payload).not.toBeNull();
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByTestId('doc-visibility');
// When the visibility is changed, the ws should close the connection (backend signal)
const wsClosePromise = webSocket.waitForEvent('close');
await selectVisibility.click();
await page.getByRole('menuitemradio', { name: 'Connected' }).click();
// Assert that the doc reconnects to the ws
const wsClose = await wsClosePromise;
expect(wsClose.isClosed()).toBeTruthy();
// Check the ws is connected again
webSocket = await page.waitForEvent('websocket', (webSocket) => {
return webSocket
.url()
.includes(`${process.env.COLLABORATION_WS_URL}?room=`);
});
framesentPromise = webSocket.waitForEvent('framesent');
framesent = await framesentPromise;
expect(framesent.payload).not.toBeNull();
});
test('markdown button converts from markdown to the editor syntax json', async ({
page,
browserName,
@@ -299,70 +223,6 @@ test.describe('Doc Editor', () => {
await expect(editor.getByText('Hello World Doc persisted 2')).toBeVisible();
});
test('it cannot edit if viewer but see and can get resources', async ({
page,
browserName,
}) => {
const [docTitle] = await createDoc(page, 'doc-viewer', browserName, 1);
await verifyDocName(page, docTitle);
await writeInEditor({ page, text: 'Hello World' });
await page.getByRole('button', { name: 'Share' }).click();
await updateShareLink(page, 'Public', 'Reading');
// Close the modal
await page.getByRole('button', { name: 'close' }).first().click();
const { otherPage, cleanup } = await connectOtherUserToDoc({
browserName,
docUrl: page.url(),
withoutSignIn: true,
docTitle,
});
await expect(
otherPage.getByLabel('It is the card information').getByText('Reader'),
).toBeVisible();
// Cannot edit
const editor = otherPage.locator('.ProseMirror');
await expect(editor).toHaveAttribute('contenteditable', 'false');
// Owner add a image
const fileChooserPromise = page.waitForEvent('filechooser');
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Resizable image with caption').click();
await page.getByText('Upload image').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(
path.join(__dirname, 'assets/logo-suite-numerique.png'),
);
// Owner see the image
await expect(
page.locator('.--docs--editor-container img.bn-visual-media').first(),
).toBeVisible();
// Viewser see the image
const viewerImg = otherPage
.locator('.--docs--editor-container img.bn-visual-media')
.first();
await expect(viewerImg).toBeVisible({
timeout: 10000,
});
// Viewer can download the image
await viewerImg.click();
const downloadPromise = otherPage.waitForEvent('download');
await otherPage.getByRole('button', { name: 'Download image' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('logo-suite-numerique.png');
await cleanup();
});
test('it adds an image to the doc editor', async ({ page, browserName }) => {
await createDoc(page, 'doc-image', browserName, 1);
@@ -507,151 +367,6 @@ test.describe('Doc Editor', () => {
await expect(editor.getByText('Analyzing file...')).toBeHidden();
});
if (process.env.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY === 'true') {
test('it checks block editing when not connected to collab server', async ({
page,
browserName,
}) => {
test.slow();
/**
* The good port is 4444, but we want to simulate a not connected
* collaborative server.
* So we use a port that is not used by the collaborative server.
* The server will not be able to connect to the collaborative server.
*/
await overrideConfig(page, {
COLLABORATION_WS_URL: 'ws://localhost:5555/collaboration/ws/',
});
await page.goto('/');
const [parentTitle] = await createDoc(
page,
'editing-blocking',
browserName,
1,
);
const card = page.getByLabel('It is the card information');
await expect(
card.getByText('Others are editing. Your network prevent changes.'),
).toBeHidden();
const editor = page.locator('.ProseMirror');
await expect(editor).toHaveAttribute('contenteditable', 'true');
let responseCanEditPromise = page.waitForResponse(
(response) =>
response.url().includes(`/can-edit/`) && response.status() === 200,
);
await page.getByRole('button', { name: 'Share' }).click();
await updateShareLink(page, 'Public', 'Editing');
// Close the modal
await page.getByRole('button', { name: 'close' }).first().click();
const urlParentDoc = page.url();
const { name: childTitle } = await createRootSubPage(
page,
browserName,
'editing-blocking - child',
);
let responseCanEdit = await responseCanEditPromise;
expect(responseCanEdit.ok()).toBeTruthy();
let jsonCanEdit = (await responseCanEdit.json()) as { can_edit: boolean };
expect(jsonCanEdit.can_edit).toBeTruthy();
const urlChildDoc = page.url();
/**
* We open another browser that will connect to the collaborative server
* and will block the current browser to edit the doc.
*/
const { otherPage } = await connectOtherUserToDoc({
browserName,
docUrl: urlChildDoc,
docTitle: childTitle,
withoutSignIn: true,
});
const webSocketPromise = otherPage.waitForEvent(
'websocket',
(webSocket) => {
return webSocket
.url()
.includes(`${process.env.COLLABORATION_WS_URL}?room=`);
},
);
await otherPage.goto(urlChildDoc);
const webSocket = await webSocketPromise;
expect(webSocket.url()).toContain(
`${process.env.COLLABORATION_WS_URL}?room=`,
);
await verifyDocName(otherPage, childTitle);
await page.reload();
responseCanEdit = await page.waitForResponse(
(response) =>
response.url().includes(`/can-edit/`) && response.status() === 200,
);
expect(responseCanEdit.ok()).toBeTruthy();
jsonCanEdit = (await responseCanEdit.json()) as { can_edit: boolean };
expect(jsonCanEdit.can_edit).toBeFalsy();
await expect(
card.getByText('Others are editing. Your network prevent changes.'),
).toBeVisible({
timeout: 10000,
});
await expect(editor).toHaveAttribute('contenteditable', 'false');
await expect(
page.getByRole('textbox', { name: 'Document title' }),
).toBeHidden();
await expect(
page.getByRole('heading', { name: childTitle }),
).toBeVisible();
await page.goto(urlParentDoc);
await verifyDocName(page, parentTitle);
await page.getByRole('button', { name: 'Share' }).click();
await page.getByTestId('doc-access-mode').click();
await page.getByRole('menuitemradio', { name: 'Reading' }).click();
// Close the modal
await page.getByRole('button', { name: 'close' }).first().click();
await page.goto(urlChildDoc);
await expect(editor).toHaveAttribute('contenteditable', 'true');
await expect(
page.getByRole('textbox', { name: 'Document title' }),
).toContainText(childTitle);
await expect(
page.getByRole('heading', { name: childTitle }),
).toBeHidden();
await expect(
card.getByText('Others are editing. Your network prevent changes.'),
).toBeHidden();
});
}
test('it checks if callout custom block', async ({ page, browserName }) => {
await createDoc(page, 'doc-toolbar', browserName, 1);
@@ -702,25 +417,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();
@@ -730,7 +443,7 @@ test.describe('Doc Editor', () => {
await page.getByText('Link a doc').first().click();
const input = page.locator(
"span[data-inline-content-type='interlinkingSearchInline'] input",
"span[data-inline-content-type='interlinkingLinkInline'] input",
);
const searchContainer = page.locator('.quick-search-container');

View File

@@ -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 }) => {

View File

@@ -31,6 +31,8 @@ test.describe('Inherited share accesses', () => {
.getByRole('link')
.click();
await page.getByRole('button', { name: 'close' }).first().click();
await verifyDocName(page, parentTitle);
});

View File

@@ -1,6 +1,11 @@
import { expect, test } from '@playwright/test';
import { createDoc, getCurrentConfig, verifyDocName } from './utils-common';
import {
createDoc,
getCurrentConfig,
overrideConfig,
verifyDocName,
} from './utils-common';
import { writeInEditor } from './utils-editor';
import { SignIn, expectLoginPage } from './utils-signin';
import { createRootSubPage } from './utils-sub-pages';
@@ -145,6 +150,29 @@ test.describe('Doc Routing', () => {
);
await expect(page).toHaveTitle(/401 Unauthorized - Docs/);
});
test('checks redirect if unsync version', async ({ page }) => {
await overrideConfig(page, {
RELEASE_VERSION: '0.0.0',
});
let counterReload = 0;
await page.route(/.*\/users\/me\/$/, async (route) => {
counterReload += 1;
await route.continue();
});
await page.waitForTimeout(1000);
// The sessionStorage guard should be set to the mismatched backend version.
const reloadVersion = await page.evaluate(() =>
sessionStorage.getItem('reload-version'),
);
expect(reloadVersion).toBe('0.0.0');
// The page should have reloaded once
expect(counterReload).toBe(2);
});
});
test.describe('Doc Routing: Not logged', () => {

View File

@@ -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');
});
});

View File

@@ -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 ({

View File

@@ -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 ({

View File

@@ -4,6 +4,7 @@ import path from 'path';
import { Locator, Page, TestInfo, expect } from '@playwright/test';
import theme_customization from '../../../../../backend/impress/configuration/theme/default.json';
import { version as packageJsonVersion } from '../../package.json';
export type BrowserName = 'chromium' | 'firefox' | 'webkit';
export const BROWSERS: BrowserName[] = ['chromium', 'webkit', 'firefox'];
@@ -13,11 +14,12 @@ 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,
COLLABORATION_WS_INACTIVITY_TIMEOUT: 15,
COLLABORATION_WS_URL: process.env.COLLABORATION_WS_URL,
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY: true,
CONVERSION_UPLOAD_ENABLED: true,
@@ -39,6 +41,7 @@ export const CONFIG = {
],
LANGUAGE_CODE: 'en-us',
POSTHOG_KEY: {},
RELEASE_VERSION: packageJsonVersion,
SENTRY_DSN: null,
TRASHBIN_CUTOFF_DAYS: 30,
theme_customization,
@@ -131,6 +134,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 +152,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 +253,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 +270,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 +315,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[] = []) => {

View File

@@ -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();

View File

@@ -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"

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