Compare commits

...

88 Commits

Author SHA1 Message Date
Anthony LC 03ea6b29df 🔖(minor) release 4.6.0
Added:
- (frontend) integrate new Blocknote AI feature
- 👷(docker) add arm64 platform support for image builds
- (tracking) add UTM parameters to shared document links
- (frontend) add floating bar with leftpanel collapse button
- (frontend) Can print a doc
- (backend) manage reconciliation requests for user accounts
- 👷(CI) add GHCR workflow for forked repo testing
- (frontend) Move doc modal
- ️(backend) remove content from Document serializer when asked
- (backend) allow the duplication of subpages
- (backend) Onboarding docs for new users
- 🩺(trivy) add trivyignore file and add minimatch CVE
- 🚩 Add feature flags for the AI feature

Changed:
- ️(frontend) prevent dates from being focusable
- ️(frontend) Focus main container after navigation
- 💄(frontend) align colors and logo with ui-kit v2
- 🚸(backend) sort user search results by proximity
  with the active user
- 🚸(oidc) ignore case when fallback on email
- ️(CI) optimize Docker Hub workflow

Fixed:
- 🐛(frontend) fix broadcast store sync
- 🐛(helm) use celery resources instead of backend resources
- 🐛(helm) reverse liveness and readiness for backend deployment
- 🐛(y-provider) use CONVERSION_FILE_MAX_SIZE settings
- 🐛(frontend) fix callout block spacing for old browsers
2026-03-05 12:42:03 +01:00
Anthony LC ea0a1aef10 (e2e) fix flaky test on doc move
Some tests on the doc move functionality
were flaky, blocking the workflow.
2026-03-05 12:42:03 +01:00
AntoLC bb7d1353f6 🌐(i18n) update translated strings
Update translated files with new translations
2026-03-05 09:30:41 +01:00
Anthony LC 1944f6177e ♻️(frontend) Add default data to move modal
By default the move modal has the last updated doc
in the list, when typing it starts filtering.
In order to have this behaviour possible, we had to
refactorise DocSearchContent and the QuickSearch
components to be able to set a default value in
the input and a default list of items.
2026-03-04 16:00:59 +01:00
Anthony LC 6ce847d6e1 ♻️(frontend) change the closing buttons of the modals
We changed the closing buttons
of the modals to be consistent with the new design
system. We adapted the modals to fit the new design
and ensure a better user experience.
2026-03-04 16:00:59 +01:00
Anthony LC e48080b27e ♻️(frontend) refact HorizontalSeparator to be more flexible
We need HorizontalSeparator to be more flexible,
so we can use it in more places. We change the
props to be more generic, we updated the components
that use it.
2026-03-04 16:00:59 +01:00
Anthony LC 73621c91e5 🍱(frontend) add icons ui-kit
We have a mix of Material icons and custom icons
in the app, which makes it difficult to maintain
and update them. To solve this problem, we downloaded
all the icons from the ui-kit and added them to
the project. We will gradually replace the old
icons with the new ones to ensure a consistent
and up-to-date icon set across the app.
2026-03-04 16:00:59 +01:00
Anthony LC ee2462310f ♻️(frontend) Update base role for access request
The base role for access request is now "editor"
instead of "viewer".
2026-03-03 15:31:03 +01:00
Manuel Raynaud 2d6e34c555 ♻️(ci) reuse amd64 to build arm64 image when possible
Building twice the image take lof of time. In soma cases, building the
arm64 image using the artifacts build in the amd64 and thant can be
reused should speed up the build of the arm64 image.
2026-03-03 14:48:13 +01:00
Manuel Raynaud 3f638b22c4 ♻️(ci) extract docker build job in a dedicated job to custom it
We extract the docker build and push job to a dedicated action called
to make the docker job for each application. This action avoid
copy/paste between each job.
2026-03-03 14:48:13 +01:00
Anthony LC c9f42e7924 🩹(frontend) improve minor ux/ui
- Better logo resolution (x4)
- Remove floating bar when printing a doc
- Add loader during doc import
2026-03-02 11:49:21 +01:00
Anthony LC a30384573e 🔇(frontend) remove ads console i18next
i18next was adding ads to the console. We remove it from
the console thanks to "showSupportNotice" option.
We upgraded to have less noisy console as well
during development.
2026-03-02 11:12:49 +01:00
Anthony LC 54dc72209c 🩺(trivy) add minimatch CVE to trivyignore
We added CVE-2026-27903 and CVE-2026-27904 to
the .trivyignore file
We need to wait for the community to update the
docker image with a fix for these CVEs before we
can remove them from the .trivyignore file.
2026-02-27 09:58:57 +01:00
Anthony LC 9cf30a0d5f 🔒️(secu) fix CVE warning
Fix CVE warning about:
- CVE-2026-27606 about rollup
- CVE-2026-26996 about minimatch
2026-02-27 09:55:36 +01:00
Anthony LC f24b047a7c 🛂(frontend) add access request modal on move modal
If a user tries to move a document for which they
don't have the right to move, we now display a
modal to request access to the owners of the
document.
2026-02-27 09:13:29 +01:00
Anthony LC 3411df09ae 🛂(frontend) add confirmation modal on move modal
If the document has more than 1 direct access,we want to
display a confirmation modal before moving the document.
This is to prevent users from accidentally moving a document
that is shared with multiple people.
The accesses and invitations will be removed from the
document.
2026-02-27 09:12:04 +01:00
Anthony LC 2718321fbe (frontend) Move doc modal
We can now move a doc to another doc from a search
modal. It will make it easier to move a doc
without having to scroll through the doc grid to
find the destination doc.
We kept most of the logic implemented in the
doc grid dnd.
2026-02-27 09:09:25 +01:00
Anthony LC 217af2e2a8 🐛(frontend) disable DND grid item when dialog open
We could drag and drop the items even if the
modal was opened, which could cause some unexpected
behaviors. This commit disables the DND
functionality when a dialog box is open.
2026-02-27 09:08:51 +01:00
Anthony LC 53985f77f3 🚚(frontend) move modal Share directly to concern component
To keep consistency with the other modals, we move
the share modal directly to the
DocsGridActions component. This way, we avoid
having to pass down the openShareModal function
from parent components and keep the logic related
to sharing a doc encapsulated within the
concern component.
2026-02-27 09:08:51 +01:00
Anthony LC a51ceeb409 🚩(frontend) add Analytics feature flag on Blocknote AI
We want to have a fine grained control over the
Blocknote AI feature.
By adding the feature Analytics feature flag,
we can enable or disable this feature for specific
users or groups without deploying new code.
This allows us to test the feature in a controlled
environment and gather feedback before a full rollout.
2026-02-26 13:50:17 +01:00
Anthony LC 1070b91d2f 🚩(project) add more backend AI feature flags
The Blocknote AI feature is a bit flaky, we want
to be able to disable it if to much issues arise,
without having to do a new release.
We add a bunch of feature flags to be able to
disable the AI features if needed:
- add AI_FEATURE_BLOCKNOTE_ENABLED, to display
or not the feature powered by blocknote
- add AI_FEATURE_LEGACY_ENABLED, to display or not
the legacy AI features
2026-02-26 13:50:17 +01:00
Manuel Raynaud 24ec1fa70e 🔥(backend) remove settings AI_STREAM
We don't need anymore the AI_STREAM settings, we use the stream all the
time.
2026-02-26 09:52:34 +01:00
Manuel Raynaud 0ba6f02d1a (backend) force usage of system prompt along when using tools
When the tool applyDocumentOperations is used, we have to force the
usage of a system prompt in order to force the model to use it the right
without inventing different actions. The pydantic Agent class can use a
system prompt but this noe is ignoried when a UI adapter is used like
the VercelAiAdapter.
2026-02-26 09:52:34 +01:00
Manuel Raynaud 8ce216f6e8 (backend) use pydantic AI to manage vercel data stream protocol
The frontend application is using Vercel AI SDK and it's data stream
protocol. We decided to use the pydantic AI library to use it's vercel
ai adapter. It will make the payload validation, use AsyncIterator and
deal with vercel specification.
2026-02-26 09:52:33 +01:00
Manuel Raynaud 050b106a8f ️(asgi) use uvicorn to serve backend
This is a naive first switch from sync to async.
This enables the backend to still answer to incomming requests
while streaming LLM results to the user.

For sure there is room for code cleaning and improvements, but
this provides a nice improvement out of the box.
2026-02-26 09:52:33 +01:00
Cyril 5011db9bd7 ️(frontend) announce ai status with aria-live
notify screen readers about ai thinking, writing, ready, or error
2026-02-26 09:52:33 +01:00
Anthony LC e1e0e5ebd8 🛂(frontend) bind ai_proxy abilities with AI feature
Bind ai_proxy abilities to the AI feature.
If ai_proxy is false, the AI feature will
not be available.
2026-02-26 09:52:33 +01:00
Anthony LC 5c8fff01a5 📄(frontend) remove AI feature when MIT
AI feature is under AGPL license, so it is removed
when the project is under MIT license.
NEXT_PUBLIC_PUBLISH_AS_MIT manage this.
2026-02-26 09:52:33 +01:00
Anthony LC 1a022450c6 (frontend) integrate new Blocknote AI feature
We integrate the new Blocknote AI feature
into Docs, enhancing the document editing experience
with AI capabilities.
2026-02-26 09:52:33 +01:00
Anthony LC 09438a8941 🛂(backend) harden payload proxy ai
Standard can vary depending on the AI service used.
To work with Albert API:
- a description field is required in the payload
  for every tools call.
- if stream is set to false, stream_options must
  be omitted from the payload.
- the response from Albert sometimes didn't respect
  the format expected by Blocknote, so we added a
  system prompt to enforce it.
2026-02-26 09:48:03 +01:00
Manuel Raynaud 6f0dac4f48 (back) manage streaming with the ai service
We want to handle both streaming or not when interacting with the AI
backend service.
2026-02-26 09:48:03 +01:00
Anthony LC 9d6fe5da8f 🔧(backend) make frontend ai bot configurable
We make the AI bot configurable with settings.
We will be able to have different AI bot name
per instance.
2026-02-26 09:48:03 +01:00
Anthony LC 1ee313efb1 (backend) add ai_proxy
Add AI proxy to handle AI related requests
to the AI service.
2026-02-26 09:48:02 +01:00
Anthony LC 1ac6b42ae3 ️(frontend) fetch document without content when not needed
To improve performance, especially for documents
with large content, an optional query parameter
`without_content` has been added to the document
fetching API endpoint. When this parameter is set
to true, the API will return the document
metadata without the content, allowing the frontend
to load faster and reduce unnecessary data transfer.
2026-02-25 21:18:01 +01:00
Manuel Raynaud ffae927c93 ️(backend) remove content from Document serializer when asked
The frontend can fetch the retrieve endpoint just for having the title.
Everytime, the content is added and to get the content, a request is
made to the s3 bucket. A query string `without_content` can be used, if
the value is `true` then the content is not added (and not also not
fetch).
2026-02-25 21:18:01 +01:00
Anthony LC 0d335105a1 🔒️(secu) fix CVE warning
Fix CVE warning about:
- CVE-2026-22036 about undici
- CVE-2025-13465 about lodash
- CVE-2026-2327 about markdown-it
- CVE-2025-64756 about glob
- CVE-2026-2391 about qs
- CVE-2026-24001 about diff
2026-02-25 15:25:30 +01:00
Anthony LC dc23883a9c ⬆️(frontend) update dependencies to fit eslint v10
Update dependencies and code to fit eslint v10.
2026-02-25 14:59:13 +01:00
renovate[bot] a8ce9eabf8 ⬆️(dependencies) update js dependencies 2026-02-25 14:59:13 +01:00
Anthony LC 21217be587 ️(CI) optimize Docker Hub workflow
We use the Docker Hub Workflow to build and push
our images to Docker Hub, but to check if we
have vulnerabilities in our images as well.
When we are just checking for vulnerabilities,
we don't need to do all the builing steps.
This commit optimizes the workflow by only doing the
necessary steps when we are just checking for
vulnerabilities, so during pull requests
without label "preview" we skip the build steps,
and we do not activate QEMU.
2026-02-25 14:40:38 +01:00
Anthony LC a8212753aa 🩺(trivy) add trivyignore file and add minimatch CVE
We added a .trivyignore file to ignore the
CVE-2026-26996 vulnerability coming from the y-provider
image.
We also updated the docker-hub.yml workflow to
use this .trivyignore file when scanning our
Docker images with Trivy.
2026-02-25 11:14:02 +01:00
Anthony LC c37dc8dd34 🐛(frontend) fix callout block spacing for old browsers
On the old Firefox versions, the spacing of the
callout block was not applied correctly.
We changed the "white-space" property to "pre-wrap"
to ensure that the spacing is applied correctly on
all browsers.
2026-02-24 15:45:49 +01:00
Anthony LC e323af2cdb 🐛(y-provider) use CONVERSION_FILE_MAX_SIZE settings
The settings CONVERSION_FILE_MAX_SIZE was not used
in the y-provider, which caused a 413 Payload
Too Large error when trying to convert a file larger
than 500kb.
This commit updates the y-provider to use the
CONVERSION_FILE_MAX_SIZE settings, allowing it to
handle larger files without throwing an error.
CONVERSION_FILE_MAX_SIZE should follow the same
value as the one defined in the backend settings,
which is 20mb by default.
2026-02-24 14:26:08 +01:00
Sylvain Boissel 9f9f26974c 🚸(backend) update missing doc management in onboarding sandbox feature
Update _duplicate_onboarding_sandbox_document() to return immediately
if the doc defined in settings can't be found.
2026-02-23 16:29:08 +01:00
Sylvain Boissel c80e7d05bb 🚸(backend) add onboarding docs for new users
Adds two methods to allow new users to start with some docs.

User._handle_onboarding_documents_access() gives READER access to
each document listed in settings.USER_ONBOARDING_DOCUMENTS.

User._duplicate_onboarding_sandbox_document() creates a local copy
of the sandbox document specified in
settings.USER_ONBOARDING_SANDBOX_DOCUMENT.
2026-02-23 16:29:08 +01:00
Sylvain Boissel 5d5ac0c1c8 (backend) allow the duplication of subpages
Adds a new with_descendants parameter to the doc duplication API.
The logic of the duplicate() method has been moved to a new
internal _duplicate_document() method to allow for recursion.
Adds unit tests for the new feature.
2026-02-23 15:31:19 +01:00
Stephan Meijer d0b756550b 👷(docker) add arm64 platform support for image builds
Signed-off-by: Stephan Meijer <me@stephanmeijer.com>
2026-02-19 17:38:18 +01:00
Cyril 010ed4618a (frontend) add floating bar with collapse button
Add sticky floating bar at top of document with leftpanelcollapse btn
2026-02-19 13:10:48 +01:00
Chaïb Martinez c0994d7d1f (tracking) add UTM parameters to shared document links
Add utm_source=docssharelink and utm_campaign={docId} query parameters
to shared document links (copy link and invitation emails).

Signed-off-by: Chaïb Martinez <chaibax@gmail.com>
2026-02-19 10:43:48 +01:00
Manuel Raynaud fa0c3847e4 🐛(hub) fix mistake in docker user
Remove :-1000 after docker user in github workflows
2026-02-16 11:43:24 +01:00
Manuel Raynaud 49871c45b1 🐛(helm) reverse liveness and readiness for backend deployment
The liveness and readiness are reversed. The liveness was using the
heartbeat process that is cheking all django checks and the database
connection.
2026-02-13 09:51:41 +01:00
Manuel Raynaud 2cc0d71b89 🐛(helm) use celery resources instead of backend resources
In the celery deployment, the backend resources are used and not the one
from the celery values.
2026-02-13 09:31:51 +01:00
dtinth-ampere-devbox 33785440c6 👷(CI) add GHCR workflow for forked repo testing
Add workflow_dispatch-triggered workflow that builds and pushes images
to GitHub Container Registry (ghcr.io). This allows forked repositories
to build their own images for testing without requiring Docker Hub
credentials.

Images are tagged with branch names, semver tags, and commit SHA for
easy testing of specific builds.

Signed-off-by: dtinth on MBP M1 <dtinth@spacet.me>
2026-02-12 14:08:24 +01:00
Anthony LC 75c7811755 💄(frontend) align colors with theme from figma
Some colors were not exactly the same as the ones
in figma, this commit aligns them with the theme
tokens.
2026-02-12 12:53:51 +01:00
Anthony LC f4cb66d6b6 💄(frontend) Title header optional
On the DSFR instance the title will not
be displayed anymore in favor of
a icon that include the title.
So we make the title optional, it
will be configurable from the
theme configuration.
2026-02-12 12:53:30 +01:00
Anthony LC 57dc56f83e 🎨(frontend) improve overriding from configuration theme
We were partially overriding the frontend with the
cunningham theme meaning at build time. We stop to
do this way to do it only from the configuration
theme. This way it will be easier to maintain and
to update.
We improve as well the typing with more global types
like Image type from logo and icons, and HTMLLinkElement
type for the favicon, meaning you can really
override compoments from the configuration theme.
2026-02-12 12:53:30 +01:00
Cyril de1a0e4a73 ️(frontend) prevent dates from being focusable
Keep date links clickable with mouse while removing keyboard focus.
2026-02-12 11:40:29 +01:00
BEY Quentin 17cb213ecd 🚸(oidc) ignore case when fallback on email
Some identity providers might change the case, but in our products we
don't consider case variation to be consider as different email
addresses.

Next step would be to normalize the DB value of email to be lower-case.
2026-02-11 18:48:51 +00:00
Sylvain Boissel 3ab0a47c3a (backend) manage reconciliation requests for user accounts (#1878)
For now, the reconciliation requests are imported through CSV in the
Django admin, which sends confirmation email to both addresses. When
both are checked, the actual reconciliation is processed, and all
user-related content is updated.

## Purpose

Fix #1616 // Replaces #1708

For now, the reconciliation requests are imported through CSV in the
Django admin, which sends confirmation email to both addresses. When
both are checked, the actual reconciliation is processed, and all
user-related content is updated.


## Proposal
- [x] New `UserReconciliationCsvImport` model to manage the import of
reconciliation requests through a task
(`user_reconciliation_csv_import_job`)
- [x] New `UserReconciliation` model to store the user reconciliation
requests themselves (a row = a `active_user`/`inactive_user` pair)
  - [x] On save, a confirmation email is sent to the users
- [x] A `process_reconciliation` admin action process the action on the
requested entries, if both emails have been checked.
- [x] Bulk update the `DocumentAccess` items, while managing the case
where both users have access to the document (keeping the higher role)
- [x] Bulk update the `LinkTrace` items, while managing the case where
both users have link traces to the document
- [x] Bulk update the `DocumentFavorite` items, while managing the case
where both users have put the document in their favorites
- [x] Bulk update the comment system items (`Thread`, `Comment` and
`Reaction` items)
  - [x] Bulk update the `is_active` status on both users
- [x] New `USER_RECONCILIATION_FORM_URL` env variable for the "make a
new request" URL in an email.
- [x] Write unit tests
- [x] Remove the unused `email_user()` method on `User`, replaced with
`send_email()` similar to the one on the `Document` model


## Demo page reconciliation success

<img width="1149" height="746" alt="image"
src="https://github.com/user-attachments/assets/09ba2b38-7af3-41fa-a64f-ce3c4fd8548d"
/>

---------

Co-authored-by: Anthony LC <anthony.le-courric@mail.numerique.gouv.fr>
2026-02-11 18:09:20 +00:00
Sylvain Boissel 685464f2d7 🚸(backend) sort user search results by proximity with the active user (#1802)
## Purpose
Allows a user to find more easily the other users they search, with the
following order of priority:
- users they already share documents with (more recent first)
- users that share the same full email domain
- ~~users that share the same partial email domain (last two parts)~~
- ~~other users~~

Edit: We need to ilter out other users in order to not reveal email
addresses from members of other organisations. It's still possible to
invite them by email.

Solves #1521

## Proposal
- [x] Add a new function in `core/utils.py`:
`users_sharing_documents_with()`
- [x] Use it as a key to sort the results of a basic user search
- [x] Filter user results to avoid reveal of users (and email addresses)
of other orgs or that have not been interacted with.
- [x] User research through "full" email address (contains the '@') is
left unaffected.

---------

Co-authored-by: Anthony LC <anthony.le-courric@mail.numerique.gouv.fr>
2026-02-11 18:51:45 +01:00
virgile-dev 9af540de35 📝(readme) replace demo link (#1875)
so that the sandbox is not a public gov one

Signed-off-by: virgile-deville <virgile.deville@beta.gouv.fr>
2026-02-11 16:48:24 +00:00
Manuel Raynaud 6c43ecc324 🔧(docker) change mime.types url in Dockerfile
Change mime.types url in Dockerfile
2026-02-11 14:17:32 +00:00
renovate[bot] 607bae0022 ⬆️(dependencies) update axios to v1.13.5 [SECURITY] 2026-02-10 09:29:05 +00:00
Anthony LC 1d8b730715 (e2e) add threshold in regression test
When comparing PDF screenshots, we can have some
minor differences due to the different environments
(OS, fonts, etc.).
To avoid false positives in our regression
tests, we can set a threshold for the number of
different pixels allowed before considering the
test as failed.
If the test fails we will now report the PDF
and the differences to identify quickly
what are the regressions.
2026-02-09 16:17:05 +01:00
Anthony LC d02c6250c9 🔒️(frontend) harden security check on url
We harden the security check on url to prevent attacks.
2026-02-09 16:17:05 +01:00
Anthony LC b8c1504e7a ♻️(export) change pdf block from embed to iframe
When trying to print with a embed PDF the
browser's print dialog stays blocked and the user
can't print the document. Changing the PDF block
to use an iframe instead of an embed resolves
this issue.
2026-02-09 16:17:05 +01:00
Anthony LC 18edcf8537 🚨(ci) limit print check to backend
We added a feature to print documents directly
from the browser. The function is called
`window.print()`, this name collides with Python's
`print()` function. To avoid false positives in our
CI when checking for print statements, we limit
the search to only the backend code.
2026-02-09 16:17:05 +01:00
Anthony LC 5d8741a70a (frontend) print a doc with native browser
We can now print a doc with the native browser
print dialog.
This feature uses the browser's built-in print
capabilities to generate a print preview and
allows users to print directly from the application.
It has as well a powerfull print to PDF feature
that leverages the browser's PDF generation
capabilities for better compatibility and
quality.

Co-authored-by: AntoLC <anthony.le-courric@mail.numerique.gouv.fr>
Co-authored-by: Cyril <c.gromoff@gmail.com>
2026-02-09 16:17:04 +01:00
Cyril 48df68195a ️(frontend) focus docs list title after filter navigation
Explain focus shift to match skip-to-content behavior.

hook useRouteChangeCompleteFocus

Positionne the focus on the first target or main element after a route change.
2026-02-05 11:13:30 +01:00
buildwithricky 7cf42e6404 🐛(frontend) fix doc timestamp display
Implemented the logic to show 'Just now' instead
of '0 seconds ago' when the difference is under
one second.

Signed-off-by: buildwithricky <nwakezepatrick@gmail.com>
2026-02-04 09:34:21 +01:00
Manuel Raynaud 9903bd73e2 ️(actions) enable trivy scan on backend image
The trivy was disabled because protobuf library was blocking the release
process. We can now enable it again, a new release of protobuf is
available.
2026-02-03 16:45:13 +00:00
Anthony LC 44b38347c4 🐛(frontend) fix broadcast store sync
When going from one subdoc to another by example,
the broadcast store could have difficulty to resync.
This commit ensures that the broadcast store
cleans up and resets its state when rerendering.
It will stop as well triggering the action for
the current user avoiding potential unecessary
requests.
2026-02-03 14:25:35 +01:00
Thai Pangsakulyanont 709076067b 🐛(backend) add AWS_S3_SIGNATURE_VERSION environment variable support
Add support for the `AWS_S3_SIGNATURE_VERSION` environment variable to
allow configuring S3 signature version for compatibility with
S3-compatible storage services like Linode Object Storage.

Fixes #1788

Signed-off-by: dtinth on MBP M1 <dtinth@spacet.me>
2026-02-02 10:47:14 +00:00
Anthony LC db014cfc6f 🔖(minor) release 4.5.0
Added:
- (frontend) integrate configurable Waffle
-  Import of documents
- 🚨(CI) gives warning if theme not updated
- (frontend) Add stat for Crisp
- (auth) add silent login
- 🔧(project) add DJANGO_EMAIL_URL_APP environment variable

Changed:
- (frontend) improve accessibility:
  - ️(frontend) fix subdoc opening and emoji pick focus
- (backend) add field for button label in email template

Fixed:
- (e2e) fix e2e test for other browsers
- 🐛(export) fix export column NaN
- 🐛(frontend) add fallback for unsupported Blocknote
  languages
- 🐛(frontend) fix emojipicker closing in tree
- 🐛(frontend) display children in favorite
- 🐛(frontend) preserve typed text after @ on escape

Removed:
- 🔥(project) remove all code related to template

Security:
- 🔒️(trivy) fix vulnerability about jaraco.context
2026-01-29 16:13:37 +01:00
Manuel Raynaud 52cd76eb93 🔧(backend) customize cache config
We want to split the cache config between the app cache and the session
cache. In the app cache, the default one, we allow to configure a
prefix. By default this prefix is a fixed string so the cache will be
never revoked because it is changing but it allow every instance to
implement its own strategy like prefixing the keyx cache with a
timestamp.
To not impact session, the session cache is splitted in the settings.
2026-01-29 16:13:36 +01:00
Anthony LC 505b144968 🔊(CHANGELOG) change link version CHANGELOG
A "v" was introduced in the link format, we need
to update the version in the link accordingly to
match it.
We add "v" to fit with others projects.
2026-01-29 10:24:28 +01:00
lunika 009de5299f 🌐(i18n) update translated strings
Update translated files with new translations
2026-01-29 10:11:56 +01:00
Anthony LC 0fddabb354 🩺(CI) remove backend trivy guard
We need to do a release but the backend cannot
build because of a trivy issue.
So we temporarily remove the backend trivy guard
to be able to release.
2026-01-29 10:11:56 +01:00
Anthony LC cd25c3a63b 🚚(frontend) move from hook to hooks
We has 2 folders for hooks: "hook" and "hooks".
To keep consistency, we move all hooks to "hooks"
folder and delete "hook" folder.
2026-01-28 16:59:45 +01:00
Anthony LC adb216fbdf (frontend) add stat from Crisp
We want to track document views with user
authentication status using Crisp analytics.
2026-01-28 16:59:45 +01:00
Anthony LC 235c1828e6 💄(export) improve heading line height
Import heading line height mapping for PDF export
to match the one used in the doc.
2026-01-28 11:12:47 +01:00
Anthony LC 4588c71e8a 🏷️(frontend) adjust typing to fit styled-component
Recent upgrade of styled-components caused
type issues in Box and Text components.
We adjust the typing to fit the new version.
2026-01-28 11:12:47 +01:00
Anthony LC 6b7fc915dd ️(frontend) load Marianne font
Ui-kit expose now correctly the Marianne font.
We can use it now just by importing the font
from our css, no need to copy paste the font
in the public folder.
2026-01-28 11:12:46 +01:00
Anthony LC c3e83c6612 ⬆️(dependencies) let docx dependencies be upgraded
Blocknote seems to manage the last version of
docx, we do not have to pin it to a specific version
anymore.
2026-01-28 11:12:46 +01:00
Anthony LC 586089c8e4 📌(dependencies) stop upgrading react-resizable-panels
Last versions of react-resizable-panels have some
issues performance issues. We will stick to
version 3.0.6 for now.
2026-01-28 11:12:46 +01:00
renovate[bot] 1b5ce3ed10 ⬆️(dependencies) update js dependencies 2026-01-28 11:12:46 +01:00
Anthony LC 989c70ed57 🚩(project) add FRONTEND_SILENT_LOGIN_ENABLED feature flag
Not every project requires silent login.
This commit adds a new feature flag
FRONTEND_SILENT_LOGIN_ENABLED to enable or
disable silent login functionality.
2026-01-28 10:35:34 +01:00
Anthony LC c6ded3f267 (auth) add silent login
Currently users already logged in to the SSO have to click on
the login button again to be connected.
This extra step should not be necessary.

This commit uses the "silent=true" parameter to the login
endpoint to avoid the extra step.
2026-01-28 10:35:33 +01:00
Cyril 781f0815a8 🐛(frontend) preserve typed text after @ on escape
prevents losing input when closing interlink search
2026-01-27 15:14:30 +01:00
678 changed files with 17290 additions and 6542 deletions
+3
View File
@@ -0,0 +1,3 @@
CVE-2026-26996
CVE-2026-27903
CVE-2026-27904
+43 -126
View File
@@ -5,149 +5,66 @@ on:
workflow_dispatch:
push:
branches:
- 'main'
- "main"
tags:
- 'v*'
- "v*"
pull_request:
branches:
- 'main'
- 'ci/trivy-fails'
- "main"
env:
DOCKER_USER: 1001:127
SHOULD_PUSH: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
permissions:
contents: read
jobs:
build-and-push-backend:
runs-on: ubuntu-latest
steps:
-
name: Checkout repository
uses: actions/checkout@v4
-
name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/impress-backend
-
name: Login to DockerHub
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '--target backend-production -f Dockerfile'
docker-image-name: 'docker.io/lasuite/impress-backend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
with:
context: .
target: backend-production
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
-
name: Cleanup Docker after build
if: always()
run: |
docker system prune -af
docker volume prune -f
uses: ./.github/workflows/docker-publish.yml
permissions:
contents: read
secrets: inherit
with:
image_name: lasuite/impress-backend
context: .
file: Dockerfile
target: backend-production
should_push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
docker_user: 1001:127
build-and-push-frontend:
runs-on: ubuntu-latest
steps:
-
name: Checkout repository
uses: actions/checkout@v4
-
name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/impress-frontend
-
name: Login to DockerHub
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./src/frontend/Dockerfile
target: frontend-production
build-args: |
DOCKER_USER=${{ env.DOCKER_USER }}:-1000
PUBLISH_AS_MIT=false
push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
-
name: Cleanup Docker after build
if: always()
run: |
docker system prune -af
docker volume prune -f
uses: ./.github/workflows/docker-publish.yml
permissions:
contents: read
secrets: inherit
with:
image_name: lasuite/impress-frontend
context: .
file: src/frontend/Dockerfile
target: frontend-production
arm64_reuse_amd64_build_arg: "FRONTEND_IMAGE"
should_push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
docker_user: 1001:127
build-and-push-y-provider:
runs-on: ubuntu-latest
steps:
-
name: Checkout repository
uses: actions/checkout@v4
-
name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/impress-y-provider
-
name: Login to DockerHub
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f src/frontend/servers/y-provider/Dockerfile --target y-provider'
docker-image-name: 'docker.io/lasuite/impress-y-provider:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./src/frontend/servers/y-provider/Dockerfile
target: y-provider
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
-
name: Cleanup Docker after build
if: always()
run: |
docker system prune -af
docker volume prune -f
uses: ./.github/workflows/docker-publish.yml
permissions:
contents: read
secrets: inherit
with:
image_name: lasuite/impress-y-provider
context: .
file: src/frontend/servers/y-provider/Dockerfile
target: y-provider
should_push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
docker_user: 1001:127
notify-argocd:
needs:
- build-and-push-frontend
- build-and-push-backend
- build-and-push-frontend
- build-and-push-y-provider
runs-on: ubuntu-latest
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
steps:
+142
View File
@@ -0,0 +1,142 @@
name: Build and Push Container Image
description: Build and push a container image based on the input arguments provided
"on":
workflow_call:
inputs:
image_name:
type: string
required: true
description: The suffix for the image name, without the registry and without the repository path.
context:
type: string
required: true
description: The path to the context to start `docker build` into.
file:
type: string
required: true
description: The path to the Dockerfile
target:
type: string
required: false
default: ""
description: The Dockerfile target stage to build the image for.
should_push:
type: boolean
required: false
default: false
description: if the image should be pushed on the docker registry
docker_user:
type: string
required: false
default: ""
description: The docker_user ARGUMENT to pass to the build step
arm64_reuse_amd64_build_arg:
type: string
required: false
default: ""
description: "Build arg name to pass first amd64 tag to arm64 build (skips arch-independent build steps)"
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
if: ${{ inputs.should_push }}
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ inputs.image_name }}
- name: Generate platform-specific tags
id: platform-tags
run: |
AMD64_TAGS=$(echo "${{ steps.meta.outputs.tags }}" | sed 's/$/-amd64/')
ARM64_TAGS=$(echo "${{ steps.meta.outputs.tags }}" | sed 's/$/-arm64/')
FIRST_AMD64_TAG=$(echo "${{ steps.meta.outputs.tags }}" | head -1)-amd64
{
echo "amd64<<EOF"
echo "$AMD64_TAGS"
echo "EOF"
echo "arm64<<EOF"
echo "$ARM64_TAGS"
echo "EOF"
echo "amd64_first=$FIRST_AMD64_TAG"
} >> "$GITHUB_OUTPUT"
# - name: Run trivy scan
# if: ${{ vars.TRIVY_SCAN_ENABLED }} == 'true'
# uses: numerique-gouv/action-trivy-cache@main
# with:
# docker-build-args: "--target ${{ inputs.target }} -f ${{ inputs.file }}"
# docker-image-name: "docker.io/${{ inputs.image_name }}:${{ github.sha }}"
# trivyignores: ./.github/.trivyignore
- name: Build and push (amd64)
if: ${{ inputs.should_push }}||${{ vars.TRIVY_SCAN_ENABLED }} != 'true'
uses: docker/build-push-action@v6
with:
context: ${{ inputs.context }}
file: ${{ inputs.file }}
target: ${{ inputs.target }}
platforms: linux/amd64
build-args: |
DOCKER_USER=${{ inputs.docker_user }}
PUBLISH_AS_MIT=false
push: ${{ inputs.should_push }}
provenance: false
tags: ${{ steps.platform-tags.outputs.amd64 }}
labels: ${{ steps.meta.outputs.labels }}
- name: Build and push (arm64)
if: ${{ inputs.should_push }}
uses: docker/build-push-action@v6
with:
context: ${{ inputs.context }}
file: ${{ inputs.file }}
target: ${{ inputs.target }}
platforms: linux/arm64
build-args: |
DOCKER_USER=${{ inputs.docker_user }}
PUBLISH_AS_MIT=false
${{ inputs.arm64_reuse_amd64_build_arg && format('{0}={1}', inputs.arm64_reuse_amd64_build_arg, steps.platform-tags.outputs.amd64_first) || '' }}
push: ${{ inputs.should_push }}
provenance: false
tags: ${{ steps.platform-tags.outputs.arm64 }}
labels: ${{ steps.meta.outputs.labels }}
- name: Create multi-arch manifests
if: ${{ inputs.should_push }}
id: create-manifest
run: |
IMAGE="${{ inputs.image_name }}"
readarray -t TAGS <<< "${{ steps.meta.outputs.tags }}"
FIRST_TAG=""
for tag in "${TAGS[@]}"; do
[ -z "$tag" ] && continue
docker buildx imagetools create -t "$tag" \
"${tag}-amd64" "${tag}-arm64"
if [ -z "$FIRST_TAG" ]; then
FIRST_TAG="$tag"
fi
done
# Get the digest of the multi-arch manifest for attestation
# Note: --format '{{.Manifest.Digest}}' is broken (docker/buildx#1175),
# so we compute it from the raw manifest JSON instead.
if [ -n "$FIRST_TAG" ]; then
DIGEST="sha256:$(docker buildx imagetools inspect "$FIRST_TAG" --raw | sha256sum | awk '{print $1}')"
echo "digest=$DIGEST" >> "$GITHUB_OUTPUT"
fi
- name: Cleanup Docker after build
if: always()
run: |
docker system prune -af
docker volume prune -f
+157
View File
@@ -0,0 +1,157 @@
name: Build and Push to GHCR
run-name: Build and Push to GHCR
on:
workflow_dispatch:
push:
branches:
- "main"
tags:
- "v*"
env:
DOCKER_USER: 1001:127
REGISTRY: ghcr.io
jobs:
build-and-push-backend:
runs-on: ubuntu-latest
if: github.event.repository.fork == true
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ github.repository }}/backend
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
target: backend-production
platforms: linux/amd64,linux/arm64
build-args: DOCKER_USER=${{ env.DOCKER_USER }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Cleanup Docker after build
if: always()
run: |
docker system prune -af
docker volume prune -f
build-and-push-frontend:
runs-on: ubuntu-latest
if: github.event.repository.fork == true
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ github.repository }}/frontend
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./src/frontend/Dockerfile
target: frontend-production
platforms: linux/amd64,linux/arm64
build-args: |
DOCKER_USER=${{ env.DOCKER_USER }}
PUBLISH_AS_MIT=false
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Cleanup Docker after build
if: always()
run: |
docker system prune -af
docker volume prune -f
build-and-push-y-provider:
runs-on: ubuntu-latest
if: github.event.repository.fork == true
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ github.repository }}/y-provider
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./src/frontend/servers/y-provider/Dockerfile
target: y-provider
platforms: linux/amd64,linux/arm64
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Cleanup Docker after build
if: always()
run: |
docker system prune -af
docker volume prune -f
+2 -2
View File
@@ -27,7 +27,7 @@ jobs:
- name: Enforce absence of print statements in code
if: always()
run: |
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/impress.yml' | grep "print("
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- src/backend ':(exclude)**/impress.yml' | grep "print("
- name: Check absence of fixup commits
if: always()
run: |
@@ -202,7 +202,7 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y gettext pandoc shared-mime-info
sudo wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types
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
+99 -56
View File
@@ -6,11 +6,51 @@ and this project adheres to
## [Unreleased]
### Added
## [v4.6.0] - 2026-03-03
### Added
- ✨(frontend) integrate new Blocknote AI feature #1847
- 👷(docker) add arm64 platform support for image builds #1901
- ✨(tracking) add UTM parameters to shared document links #1896
- ✨(frontend) add floating bar with leftpanel collapse button #1876
- ✨(frontend) Can print a doc #1832
- ✨(backend) manage reconciliation requests for user accounts #1878
- 👷(CI) add GHCR workflow for forked repo testing #1851
- ✨(frontend) Move doc modal #1886
- ⚡️(backend) remove content from Document serializer when asked #1910
- ✨(backend) allow the duplication of subpages #1893
- ✨(backend) Onboarding docs for new users #1891
- 🩺(trivy) add trivyignore file and add minimatch CVE #1915
- 🚩 Add feature flags for the AI feature #1922
- 🍱(frontend) add icons ui-kit #1943
### Changed
- ♿️(frontend) prevent dates from being focusable #1855
- ♿️(frontend) Focus main container after navigation #1864
- 💄(frontend) align colors and logo with ui-kit v2 #1869
- 🚸(backend) sort user search results by proximity with the active user #1802
- 🚸(oidc) ignore case when fallback on email #1880
- ⚡️(CI) optimize Docker Hub workflow #1919
### Fixed
- 🐛(frontend) fix broadcast store sync #1846
- 🐛(helm) use celery resources instead of backend resources #1887
- 🐛(helm) reverse liveness and readiness for backend deployment #1887
- 🐛(y-provider) use CONVERSION_FILE_MAX_SIZE settings #1913
- 🐛(frontend) fix callout block spacing for old browsers #1914
## [v4.5.0] - 2026-01-28
### Added
- ✨(frontend) integrate configurable Waffle #1795
- ✨ Import of documents #1609
- 🚨(CI) gives warning if theme not updated #1811
- ✨(frontend) Add stat for Crisp #1824
- ✨(auth) add silent login #1690
- 🔧(project) add DJANGO_EMAIL_URL_APP environment variable #1825
### Changed
@@ -26,6 +66,7 @@ and this project adheres to
- 🐛(frontend) add fallback for unsupported Blocknote languages #1810
- 🐛(frontend) fix emojipicker closing in tree #1808
- 🐛(frontend) display children in favorite #1782
- 🐛(frontend) preserve typed text after @ on escape #1833
### Removed
@@ -35,7 +76,7 @@ and this project adheres to
- 🔒️(trivy) fix vulnerability about jaraco.context #1806
## [4.4.0] - 2026-01-13
## [v4.4.0] - 2026-01-13
### Added
@@ -63,7 +104,7 @@ and this project adheres to
- 🔒️(backend) validate more strictly url used by cors-proxy endpoint #1768
- 🔒️(frontend) fix props vulnerability in Interlinking #1792
## [4.3.0] - 2026-01-05
## [v4.3.0] - 2026-01-05
### Added
@@ -82,7 +123,7 @@ and this project adheres to
- 🐛(frontend) fix tables deletion #1739
- 🐛(frontend) fix children not display when first resize #1753
## [4.2.0] - 2025-12-17
## [v4.2.0] - 2025-12-17
### Added
@@ -106,7 +147,7 @@ and this project adheres to
- 🐛(frontend) Select text + Go back one page crash the app #1733
- 🐛(frontend) fix versioning conflict #1742
## [4.1.0] - 2025-12-09
## [v4.1.0] - 2025-12-09
### Added
@@ -125,7 +166,7 @@ and this project adheres to
- 🐛(nginx) fix / location to handle new static pages #1682
- 🐛(frontend) rerendering during resize window #1715
## [4.0.0] - 2025-12-01
## [v4.0.0] - 2025-12-01
### Added
@@ -148,7 +189,7 @@ and this project adheres to
- 🐛(frontend) preserve left panel width on window resize #1588
- 🐛(frontend) prevent duplicate as first character in title #1595
## [3.10.0] - 2025-11-18
## [v3.10.0] - 2025-11-18
### Added
@@ -182,7 +223,7 @@ and this project adheres to
- 🔥(backend) remove api managing templates
## [3.9.0] - 2025-11-10
## [v3.9.0] - 2025-11-10
### Added
@@ -208,13 +249,13 @@ and this project adheres to
- 🐛(frontend) button new doc UI fix #1557
- 🐛(frontend) interlinking UI fix #1557
## [3.8.2] - 2025-10-17
## [v3.8.2] - 2025-10-17
### Fixed
- 🐛(service-worker) fix sw registration and page reload logic #1500
## [3.8.1] - 2025-10-17
## [v3.8.1] - 2025-10-17
### Fixed
@@ -228,7 +269,7 @@ and this project adheres to
- 🔥(backend) remove treebeard form for the document admin #1470
## [3.8.0] - 2025-10-14
## [v3.8.0] - 2025-10-14
### Added
@@ -281,7 +322,7 @@ and this project adheres to
- 🔥(frontend) remove custom DividerBlock ##1375
## [3.7.0] - 2025-09-12
## [v3.7.0] - 2025-09-12
### Added
@@ -313,7 +354,7 @@ and this project adheres to
- 🐛(frontend) fix callout emoji list #1366
## [3.6.0] - 2025-09-04
## [v3.6.0] - 2025-09-04
### Added
@@ -349,7 +390,7 @@ and this project adheres to
- 🐛(frontend) fix display bug on homepage #1332
- 🐛link role update #1287
## [3.5.0] - 2025-07-31
## [v3.5.0] - 2025-07-31
### Added
@@ -377,7 +418,7 @@ and this project adheres to
- 🐛(frontend) 401 redirection overridden #1214
- 🐛(frontend) include root parent in search #1243
## [3.4.2] - 2025-07-18
## [v3.4.2] - 2025-07-18
### Changed
@@ -387,7 +428,7 @@ and this project adheres to
- 🐛(backend) improve prompt to not use code blocks delimiter #1188
## [3.4.1] - 2025-07-15
## [v3.4.1] - 2025-07-15
### Fixed
@@ -398,7 +439,7 @@ and this project adheres to
- 🐛(frontend) fix crash share modal on grid options #1174
- 🐛(frontend) fix unfold subdocs not clickable at the bottom #1179
## [3.4.0] - 2025-07-09
## [v3.4.0] - 2025-07-09
### Added
@@ -442,7 +483,7 @@ and this project adheres to
- 🔥(frontend) remove Beta from logo #1095
## [3.3.0] - 2025-05-06
## [v3.3.0] - 2025-05-06
### Added
@@ -474,14 +515,14 @@ and this project adheres to
- 🔥(back) remove footer endpoint #948
## [3.2.1] - 2025-05-06
## [v3.2.1] - 2025-05-06
## Fixed
- 🐛(frontend) fix list copy paste #943
- 📝(doc) update contributing policy (commit signatures are now mandatory) #895
## [3.2.0] - 2025-05-05
## [v3.2.0] - 2025-05-05
## Added
@@ -508,7 +549,7 @@ and this project adheres to
- 🐛(backend) race condition create doc #633
- 🐛(frontend) fix breaklines in custom blocks #908
## [3.1.0] - 2025-04-07
## [v3.1.0] - 2025-04-07
## Added
@@ -526,7 +567,7 @@ and this project adheres to
- 🐛(back) validate document content in serializer #822
- 🐛(frontend) fix selection click past end of content #840
## [3.0.0] - 2025-03-28
## [v3.0.0] - 2025-03-28
## Added
@@ -542,7 +583,7 @@ and this project adheres to
- 🐛(backend) compute ancestor_links in get_abilities if needed #725
- 🔒️(back) restrict access to document accesses #801
## [2.6.0] - 2025-03-21
## [v2.6.0] - 2025-03-21
## Added
@@ -560,7 +601,7 @@ and this project adheres to
- 🔒️(back) throttle user list endpoint #636
- 🔒️(back) remove pagination and limit to 5 for user list endpoint #636
## [2.5.0] - 2025-03-18
## [v2.5.0] - 2025-03-18
## Added
@@ -590,7 +631,7 @@ and this project adheres to
- 🚨(helm) fix helmfile lint #736
- 🚚(frontend) redirect to 401 page when 401 error #759
## [2.4.0] - 2025-03-06
## [v2.4.0] - 2025-03-06
## Added
@@ -604,7 +645,7 @@ and this project adheres to
- 🐛(frontend) fix collaboration error #684
## [2.3.0] - 2025-03-03
## [v2.3.0] - 2025-03-03
## Added
@@ -631,7 +672,7 @@ and this project adheres to
- ♻️(frontend) improve table pdf rendering
- 🐛(email) invitation emails in receivers language
## [2.2.0] - 2025-02-10
## [v2.2.0] - 2025-02-10
## Added
@@ -650,7 +691,7 @@ and this project adheres to
- 🐛(frontend) fix cursor breakline #609
- 🐛(frontend) fix style pdf export #609
## [2.1.0] - 2025-01-29
## [v2.1.0] - 2025-01-29
## Added
@@ -679,14 +720,14 @@ and this project adheres to
- 🔥(backend) remove "content" field from list serializer # 516
## [2.0.1] - 2025-01-17
## [v2.0.1] - 2025-01-17
## Fixed
-🐛(frontend) share modal is shown when you don't have the abilities #557
-🐛(frontend) title copy break app #564
## [2.0.0] - 2025-01-13
## [v2.0.0] - 2025-01-13
## Added
@@ -717,7 +758,7 @@ and this project adheres to
- 🐛(frontend) hide search and create doc button if not authenticated #555
- 🐛(backend) race condition creation issue #556
## [1.10.0] - 2024-12-17
## [v1.10.0] - 2024-12-17
## Added
@@ -738,7 +779,7 @@ and this project adheres to
- 🐛(frontend) update doc editor height #481
- 💄(frontend) add doc search #485
## [1.9.0] - 2024-12-11
## [v1.9.0] - 2024-12-11
## Added
@@ -759,19 +800,19 @@ and this project adheres to
- 🐛(frontend) Fix hidden menu on Firefox #468
- 🐛(backend) fix sanitize problem IA #490
## [1.8.2] - 2024-11-28
## [v1.8.2] - 2024-11-28
## Changed
- ♻️(SW) change strategy html caching #460
## [1.8.1] - 2024-11-27
## [v1.8.1] - 2024-11-27
## Fixed
- 🐛(frontend) link not clickable and flickering firefox #457
## [1.8.0] - 2024-11-25
## [v1.8.0] - 2024-11-25
## Added
@@ -799,7 +840,7 @@ and this project adheres to
- 🐛(frontend) users have view access when revoked #387
- 🐛(frontend) fix placeholder editable when double clicks #454
## [1.7.0] - 2024-10-24
## [v1.7.0] - 2024-10-24
## Added
@@ -826,7 +867,7 @@ and this project adheres to
- 🔥(helm) remove infra related codes #366
## [1.6.0] - 2024-10-17
## [v1.6.0] - 2024-10-17
## Added
@@ -848,13 +889,13 @@ and this project adheres to
- 🐛(backend) fix nginx docker container #340
- 🐛(frontend) fix copy paste firefox #353
## [1.5.1] - 2024-10-10
## [v1.5.1] - 2024-10-10
## Fixed
- 🐛(db) fix users duplicate #316
## [1.5.0] - 2024-10-09
## [v1.5.0] - 2024-10-09
## Added
@@ -882,7 +923,7 @@ and this project adheres to
- 🔧(backend) fix configuration to avoid different ssl warning #297
- 🐛(frontend) fix editor break line not working #302
## [1.4.0] - 2024-09-17
## [v1.4.0] - 2024-09-17
## Added
@@ -902,7 +943,7 @@ and this project adheres to
- 🐛(backend) Fix forcing ID when creating a document via API endpoint #234
- 🐛 Rebuild frontend dev container from makefile #248
## [1.3.0] - 2024-09-05
## [v1.3.0] - 2024-09-05
## Added
@@ -926,14 +967,14 @@ and this project adheres to
- 🔥(frontend) remove saving modal #213
## [1.2.1] - 2024-08-23
## [v1.2.1] - 2024-08-23
## Changed
- ♻️ Change ordering docs datagrid #195
- 🔥(helm) use scaleway email #194
## [1.2.0] - 2024-08-22
## [v1.2.0] - 2024-08-22
## Added
@@ -959,7 +1000,7 @@ and this project adheres to
- 🔥(helm) remove htaccess #181
## [1.1.0] - 2024-07-15
## [v1.1.0] - 2024-07-15
## Added
@@ -974,7 +1015,7 @@ and this project adheres to
- ♻️(frontend) create a doc from a modal #132
- ♻️(frontend) manage members from the share modal #140
## [1.0.0] - 2024-07-02
## [v1.0.0] - 2024-07-02
## Added
@@ -1012,14 +1053,16 @@ and this project adheres to
- 💚(CI) Remove trigger workflow on push tags on CI (#68)
- 🔥(frontend) Remove coming soon page (#121)
## [0.1.0] - 2024-05-24
## [v0.1.0] - 2024-05-24
## Added
- ✨(frontend) Coming Soon page (#67)
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/suitenumerique/docs/compare/v4.4.0...main
[unreleased]: https://github.com/suitenumerique/docs/compare/v4.6.0...main
[v4.6.0]: https://github.com/suitenumerique/docs/releases/v4.6.0
[v4.5.0]: https://github.com/suitenumerique/docs/releases/v4.5.0
[v4.4.0]: https://github.com/suitenumerique/docs/releases/v4.4.0
[v4.3.0]: https://github.com/suitenumerique/docs/releases/v4.3.0
[v4.2.0]: https://github.com/suitenumerique/docs/releases/v4.2.0
@@ -1056,12 +1099,12 @@ and this project adheres to
[v1.8.0]: https://github.com/suitenumerique/docs/releases/v1.8.0
[v1.7.0]: https://github.com/suitenumerique/docs/releases/v1.7.0
[v1.6.0]: https://github.com/suitenumerique/docs/releases/v1.6.0
[1.5.1]: https://github.com/suitenumerique/docs/releases/v1.5.1
[1.5.0]: https://github.com/suitenumerique/docs/releases/v1.5.0
[1.4.0]: https://github.com/suitenumerique/docs/releases/v1.4.0
[1.3.0]: https://github.com/suitenumerique/docs/releases/v1.3.0
[1.2.1]: https://github.com/suitenumerique/docs/releases/v1.2.1
[1.2.0]: https://github.com/suitenumerique/docs/releases/v1.2.0
[1.1.0]: https://github.com/suitenumerique/docs/releases/v1.1.0
[1.0.0]: https://github.com/suitenumerique/docs/releases/v1.0.0
[0.1.0]: https://github.com/suitenumerique/docs/releases/v0.1.0
[v1.5.1]: https://github.com/suitenumerique/docs/releases/v1.5.1
[v1.5.0]: https://github.com/suitenumerique/docs/releases/v1.5.0
[v1.4.0]: https://github.com/suitenumerique/docs/releases/v1.4.0
[v1.3.0]: https://github.com/suitenumerique/docs/releases/v1.3.0
[v1.2.1]: https://github.com/suitenumerique/docs/releases/v1.2.1
[v1.2.0]: https://github.com/suitenumerique/docs/releases/v1.2.0
[v1.1.0]: https://github.com/suitenumerique/docs/releases/v1.1.0
[v1.0.0]: https://github.com/suitenumerique/docs/releases/v1.0.0
[v0.1.0]: https://github.com/suitenumerique/docs/releases/v0.1.0
+24 -11
View File
@@ -36,7 +36,7 @@ COPY ./src/mail /mail/app
WORKDIR /mail/app
RUN yarn install --frozen-lockfile && \
yarn build
yarn build
# ---- static link collector ----
@@ -58,7 +58,7 @@ WORKDIR /app
# collectstatic
RUN DJANGO_CONFIGURATION=Build \
python manage.py collectstatic --noinput
python manage.py collectstatic --noinput
# Replace duplicated file by a symlink to decrease the overall size of the
# final image
@@ -81,7 +81,7 @@ RUN apk add --no-cache \
pango \
shared-mime-info
RUN wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types
RUN wget https://raw.githubusercontent.com/suitenumerique/django-lasuite/refs/heads/main/assets/conf/mime.types -O /etc/mime.types
# Copy entrypoint
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
@@ -98,9 +98,9 @@ COPY --from=back-builder /install /usr/local
# when python is upgraded and the path to the certificate changes.
# The space between print and the ( is intended otherwise the git lint is failing
RUN mkdir /cert && \
path=`python -c 'import certifi;print (certifi.where())'` && \
mv $path /cert/ && \
ln -s /cert/cacert.pem $path
path=`python -c 'import certifi;print (certifi.where())'` && \
mv $path /cert/ && \
ln -s /cert/cacert.pem $path
# Copy impress application (see .dockerignore)
COPY ./src/backend /app/
@@ -109,7 +109,7 @@ WORKDIR /app
# Generate compiled translation messages
RUN DJANGO_CONFIGURATION=Build \
python manage.py compilemessages
python manage.py compilemessages
# We wrap commands run in this container by the following entrypoint that
@@ -138,7 +138,7 @@ USER ${DOCKER_USER}
# Target database host (e.g. database engine following docker compose services
# name) & port
ENV DB_HOST=postgresql \
DB_PORT=5432
DB_PORT=5432
# Run django development server
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
@@ -151,7 +151,7 @@ RUN rm -rf /var/cache/apk/*
ARG IMPRESS_STATIC_ROOT=/data/static
# Gunicorn
# Gunicorn - not used by default but configuration file is provided
RUN mkdir -p /usr/local/etc/gunicorn
COPY docker/files/usr/local/etc/gunicorn/impress.py /usr/local/etc/gunicorn/impress.py
@@ -165,5 +165,18 @@ COPY --from=link-collector ${IMPRESS_STATIC_ROOT} ${IMPRESS_STATIC_ROOT}
# Copy impress mails
COPY --from=mail-builder /mail/backend/core/templates/mail /app/core/templates/mail
# The default command runs gunicorn WSGI server in impress's main module
CMD ["gunicorn", "-c", "/usr/local/etc/gunicorn/impress.py", "impress.wsgi:application"]
# The default command runs uvicorn ASGI server in dics's main module
# WEB_CONCURRENCY: number of workers to run <=> --workers=4
ENV WEB_CONCURRENCY=4
CMD [\
"uvicorn",\
"--app-dir=/app",\
"--host=0.0.0.0",\
"--timeout-graceful-shutdown=300",\
"--limit-max-requests=20000",\
"--lifespan=off",\
"impress.asgi:application"\
]
# To run using gunicorn WSGI server use this instead:
#CMD ["gunicorn", "-c", "/usr/local/etc/gunicorn/conversations.py", "impress.wsgi:application"]
+1 -1
View File
@@ -72,7 +72,7 @@ For some advanced features (ex: Export as PDF) Docs relies on XL packages from B
### Test it
You can test Docs on your browser by visiting this [demo document](https://impress-preprod.beta.numerique.gouv.fr/docs/6ee5aac4-4fb9-457d-95bf-bb56c2467713/)
You can test Docs on your browser by visiting this [demo document](https://docs.la-suite.eu/docs/9137bbb5-3e8a-4ff7-8a36-fcc4e8bd57f4/)
### Run Docs locally
+6
View File
@@ -16,6 +16,12 @@ the following command inside your docker container:
## [Unreleased]
## [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.
https://github.com/suitenumerique/docs/blob/f24b047a7cc146411412bf759b5b5248a45c3d99/src/backend/impress/configuration/theme/default.json#L129-L161
## [4.0.0] - 2025-11-26
- ⚠️ We updated `@gouvfr-lasuite/ui-kit` to `0.18.0`, so if you are customizing Docs with a css layer or with a custom template, you need to update your customization to follow the new design system structure.
+28 -20
View File
@@ -7,23 +7,29 @@ Here we describe all environment variables that can be set for the docs applicat
These are the environment variables you can set for the `impress-backend` container.
| Option | Description | default |
|-------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------|
| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| AI_ALLOW_REACH_FROM | Users that can use AI must be this level. options are "public", "authenticated", "restricted" | authenticated |
| AI_API_KEY | AI key to be used for AI Base url | |
| AI_BASE_URL | OpenAI compatible AI base url | |
| AI_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_MODEL | AI Model to use | |
| 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 |
| API_USERS_LIST_THROTTLE_RATE_SUSTAINED | Throttle rate for api | 180/hour |
| API_USERS_SEARCH_QUERY_MIN_LENGTH | Minimum characters to insert to search a user | 3 |
| AWS_S3_ACCESS_KEY_ID | Access id for s3 endpoint | |
| AWS_S3_ENDPOINT_URL | S3 endpoint | |
| AWS_S3_REGION_NAME | Region name for s3 endpoint | |
| AWS_S3_SECRET_ACCESS_KEY | Access key for s3 endpoint | |
| AWS_S3_SIGNATURE_VERSION | S3 signature version (`s3v4` or `s3`) | s3v4 |
| AWS_STORAGE_BUCKET_NAME | Bucket name for s3 endpoint | impress-media-storage |
| CACHES_DEFAULT_TIMEOUT | Cache default timeout | 30 |
| CACHES_KEY_PREFIX | The prefix used to every cache keys. | docs |
| 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_NOT_CONNECTED_READY_ONLY | Users not connected to the collaboration server cannot edit | false |
@@ -33,7 +39,7 @@ These are the environment variables you can set for the `impress-backend` contai
| CONVERSION_API_SECURE | Require secure conversion api | false |
| CONVERSION_API_TIMEOUT | Conversion api timeout | 30 |
| CONVERSION_FILE_MAX_SIZE | The file max size allowed when uploaded to convert it | 20971520 (20MB) |
| CONVERSION_FILE_EXTENSIONS_ALLOWED | Extension list managed by the conversion service | [".docx", ".md"]
| CONVERSION_FILE_EXTENSIONS_ALLOWED | Extension list managed by the conversion service | [".docx", ".md"] |
| CRISP_WEBSITE_ID | Crisp website id for support | |
| DB_ENGINE | Engine to use for database connections | django.db.backends.postgresql_psycopg2 |
| DB_HOST | Host of the database | localhost |
@@ -56,22 +62,22 @@ These are the environment variables you can set for the `impress-backend` contai
| DJANGO_EMAIL_HOST_USER | User to authenticate with on the email host | |
| DJANGO_EMAIL_LOGO_IMG | Logo for the email | |
| DJANGO_EMAIL_PORT | Port used to connect to email host | |
| DJANGO_EMAIL_URL_APP | Url used in the email to go to the app | |
| DJANGO_EMAIL_URL_APP | Url used in the email to go to the app | |
| DJANGO_EMAIL_USE_SSL | Use ssl for email host connection | false |
| DJANGO_EMAIL_USE_TLS | Use tls for email host connection | false |
| DJANGO_SECRET_KEY | Secret key | |
| DJANGO_SERVER_TO_SERVER_API_TOKENS | | [] |
| DOCSPEC_API_URL | URL to endpoint of DocSpec conversion API | |
| DOCSPEC_API_URL | URL to endpoint of DocSpec conversion API | |
| DOCUMENT_IMAGE_MAX_SIZE | Maximum size of document in bytes | 10485760 |
| FRONTEND_CSS_URL | To add a external css file to the app | |
| FRONTEND_JS_URL | To add a external js file to the app | |
| FRONTEND_JS_URL | To add a external js file to the app | |
| FRONTEND_HOMEPAGE_FEATURE_ENABLED | Frontend feature flag to display the homepage | false |
| FRONTEND_THEME | Frontend theme to use | |
| LANGUAGE_CODE | Default language | en-us |
| LANGFUSE_SECRET_KEY | The Langfuse secret key used by the sdk | None |
| LANGFUSE_PUBLIC_KEY | The Langfuse public key used by the sdk | None |
| LANGFUSE_BASE_URL | The Langfuse base url used by the sdk | None |
| LASUITE_MARKETING_BACKEND | Backend used when SIGNUP_NEW_USER_TO_MARKETING_EMAIL is True. See https://github.com/suitenumerique/django-lasuite/blob/main/documentation/how-to-use-marketing-backend.md | lasuite.marketing.backends.dummy.DummyBackend |
| LASUITE_MARKETING_BACKEND | Backend used when SIGNUP_NEW_USER_TO_MARKETING_EMAIL is True. See https://github.com/suitenumerique/django-lasuite/blob/main/documentation/how-to-use-marketing-backend.md | lasuite.marketing.backends.dummy.DummyBackend |
| LASUITE_MARKETING_PARAMETERS | The parameters to configure LASUITE_MARKETING_BACKEND. See https://github.com/suitenumerique/django-lasuite/blob/main/documentation/how-to-use-marketing-backend.md | {} |
| LOGGING_LEVEL_LOGGERS_APP | Application logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
| LOGGING_LEVEL_LOGGERS_ROOT | Default logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
@@ -118,10 +124,12 @@ These are the environment variables you can set for the `impress-backend` contai
| THEME_CUSTOMIZATION_FILE_PATH | Full path to the file customizing the theme. An example is provided in src/backend/impress/configuration/theme/default.json | BASE_DIR/impress/configuration/theme/default.json |
| TRASHBIN_CUTOFF_DAYS | Trashbin cutoff | 30 |
| USER_OIDC_ESSENTIAL_CLAIMS | Essential claims in OIDC token | [] |
| 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 | |
| USER_RECONCILIATION_FORM_URL | URL of a third-party form for user reconciliation requests | |
| Y_PROVIDER_API_BASE_URL | Y Provider url | |
| Y_PROVIDER_API_KEY | Y provider API key | |
## impress-frontend image
These are the environment variables you can set to build the `impress-frontend` image.
@@ -132,31 +140,31 @@ If you want to build the Docker image, this variable is used as an argument in t
Example:
```
```bash
docker build -f src/frontend/Dockerfile --target frontend-production --build-arg PUBLISH_AS_MIT=false docs-frontend:latest
```
```
If you want to build the front-end application using the yarn build command, you can edit the file `src/frontend/apps/impress/.env` with the `NODE_ENV=production` environment variable and modify it. Alternatively, you can use the listed environment variables with the prefix `NEXT_PUBLIC_` (for example, `NEXT_PUBLIC_PUBLISH_AS_MIT=false`).
Example:
```
```bash
cd src/frontend/apps/impress
NODE_ENV=production NEXT_PUBLIC_PUBLISH_AS_MIT=false yarn build
```
| Option | Description | default |
| ----------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| API_ORIGIN | backend domain - it uses the current domain if not initialized | |
| SW_DEACTIVATED | To not install the service worker | |
| PUBLISH_AS_MIT | Removes packages whose licences are incompatible with the MIT licence (see below) | true |
| Option | Description | default |
| -------------- | ---------------------------------------------------------------------------------- | ------- |
| API_ORIGIN | backend domain - it uses the current domain if not initialized | |
| SW_DEACTIVATED | To not install the service worker | |
| PUBLISH_AS_MIT | Removes packages whose licences are incompatible with the MIT licence (see below) | true |
Packages with licences incompatible with the MIT licence:
* `xl-docx-exporter`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-docx-exporter/LICENSE),
* `xl-pdf-exporter`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE),
* `xl-multi-column`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-multi-column/LICENSE).
* `xl-docx-exporter`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-docx-exporter/LICENSE),
* `xl-pdf-exporter`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE),
* `xl-multi-column`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-multi-column/LICENSE).
In `.env.development`, `PUBLISH_AS_MIT` is set to `false`, allowing developers to test Docs with all its features.
⚠️ If you run Docs in production with `PUBLISH_AS_MIT` set to `false` make sure you fulfill your BlockNote licensing or [subscription](https://www.blocknotejs.org/about#partner-with-us) obligations.
+1
View File
@@ -67,6 +67,7 @@ backend:
AWS_S3_SECRET_ACCESS_KEY: password
AWS_STORAGE_BUCKET_NAME: docs-media-storage
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
USER_RECONCILIATION_FORM_URL: https://docs.127.0.0.1.nip.io
Y_PROVIDER_API_BASE_URL: http://impress-y-provider:443/api/
Y_PROVIDER_API_KEY: my-secret
CACHES_KEY_PREFIX: "{{ now | unixEpoch }}"
+2
View File
@@ -138,6 +138,8 @@ AI is disabled by default. To enable it, the following environment variables mus
```env
AI_FEATURE_ENABLED=true # is false by default
AI_FEATURE_BLOCKNOTE_ENABLED=true # is false by default
AI_FEATURE_LEGACY_ENABLED=true # is true by default, AI_FEATURE_ENABLED must be set to true to enable it
AI_BASE_URL=https://openaiendpoint.com
AI_API_KEY=<API key>
AI_MODEL=<model used> e.g. llama
+30
View File
@@ -0,0 +1,30 @@
# User account reconciliation
It is possible to merge user accounts based on their email addresses.
Docs does not have an internal process to requests, but it allows the import of a CSV from an external form
(e.g. made with Grist) in the Django admin panel (in "Core" > "User reconciliation CSV imports" > "Add user reconciliation")
## CSV file format
The CSV must contain the following mandatory columns:
- `active_email`: the email of the user that will remain active after the process.
- `inactive_email`: the email of the user(s) that will be merged into the active user. It is possible to indicate several emails, so the user only has to make one request even if they have more than two accounts.
- `id`: a unique row id, so that entries already processed in a previous import are ignored.
The following columns are optional: `active_email_checked` and `inactive_email_checked` (both must contain `0` (False) or `1` (True), and both default to False.)
If present, it allows to indicate that the source form has a way to validate that the user making the request actually controls the email addresses, skipping the need to send confirmation emails (cf. below)
Once the CSV file is processed, this will create entries in "Core" > "User reconciliations" and send verification emails to validate that the user making the request actually controls the email addresses (unless `active_email_checked` and `inactive_email_checked` were set to `1` in the CSV)
In "Core" > "User reconciliations", an admin can then select all rows they wish to process and check the action "Process selected user reconciliations". Only rows that have the status `ready` and for which both emails have been validated will be processed.
## Settings
If there is a problem with the reconciliation attempt (e.g., one of the addresses given by the user does not match an existing account), the email signaling the error can give back the link to the reconciliation form. This is configured through the following environment variable:
```env
USER_RECONCILIATION_FORM_URL=<url used in the email for reconciliation with errors to allow a new requests>
# e.g. "https://yourgristinstance.tld/xxxx/UserReconciliationForm"
```
+6 -1
View File
@@ -48,7 +48,7 @@ LOGIN_REDIRECT_URL=http://localhost:3000
LOGIN_REDIRECT_URL_FAILURE=http://localhost:3000
LOGOUT_REDIRECT_URL=http://localhost:3000
OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
OIDC_REDIRECT_ALLOWED_HOSTS="localhost:8083,localhost:3000"
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
# Store OIDC tokens in the session. Needed by search/ endpoint.
@@ -59,8 +59,13 @@ OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
# To create one, use the bin/fernetkey command.
# OIDC_STORE_REFRESH_TOKEN_KEY="your-32-byte-encryption-key=="
# 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
+5
View File
@@ -53,8 +53,13 @@ LOGOUT_REDIRECT_URL=https://${DOCS_HOST}
OIDC_REDIRECT_ALLOWED_HOSTS=["https://${DOCS_HOST}"]
# User reconciliation
#USER_RECONCILIATION_FORM_URL=https://${DOCS_HOST}
# AI
#AI_FEATURE_ENABLED=true # is false by default
#AI_FEATURE_BLOCKNOTE_ENABLED=true # is false by default
#AI_FEATURE_LEGACY_ENABLED=true # is true by default, AI_FEATURE_ENABLED must be set to true to enable it
#AI_BASE_URL=https://openaiendpoint.com
#AI_API_KEY=<API key>
#AI_MODEL=<model used> e.g. llama
+1 -2
View File
@@ -32,7 +32,6 @@
"allowedVersions": "<6.0.0"
},
{
"groupName": "allowed celery versions",
"matchManagers": ["pep621"],
"matchPackageNames": ["celery"],
@@ -44,12 +43,12 @@
"matchManagers": ["npm"],
"matchPackageNames": [
"@next/eslint-plugin-next",
"docx",
"eslint-config-next",
"fetch-mock",
"next",
"node",
"node-fetch",
"react-resizable-panels",
"workbox-webpack-plugin"
]
}
+42 -2
View File
@@ -1,12 +1,14 @@
"""Admin classes and registrations for core app."""
from django.contrib import admin
from django.contrib import admin, messages
from django.contrib.auth import admin as auth_admin
from django.shortcuts import redirect
from django.utils.translation import gettext_lazy as _
from treebeard.admin import TreeAdmin
from . import models
from core import models
from core.tasks.user_reconciliation import user_reconciliation_csv_import_job
@admin.register(models.User)
@@ -95,6 +97,44 @@ class UserAdmin(auth_admin.UserAdmin):
search_fields = ("id", "sub", "admin_email", "email", "full_name")
@admin.register(models.UserReconciliationCsvImport)
class UserReconciliationCsvImportAdmin(admin.ModelAdmin):
"""Admin class for UserReconciliationCsvImport model."""
list_display = ("id", "__str__", "created_at", "status")
def save_model(self, request, obj, form, change):
"""Override save_model to trigger the import task on creation."""
super().save_model(request, obj, form, change)
if not change:
user_reconciliation_csv_import_job.delay(obj.pk)
messages.success(request, _("Import job created and queued."))
return redirect("..")
@admin.action(description=_("Process selected user reconciliations"))
def process_reconciliation(_modeladmin, _request, queryset):
"""
Admin action to process selected user reconciliations.
The action will process only entries that are ready and have both emails checked.
"""
processable_entries = queryset.filter(
status="ready", active_email_checked=True, inactive_email_checked=True
)
for entry in processable_entries:
entry.process_reconciliation_request()
@admin.register(models.UserReconciliation)
class UserReconciliationAdmin(admin.ModelAdmin):
"""Admin class for UserReconciliation model."""
list_display = ["id", "__str__", "created_at", "status"]
actions = [process_reconciliation]
class DocumentAccessInline(admin.TabularInline):
"""Inline admin class for document accesses."""
+4 -1
View File
@@ -2,6 +2,7 @@
import unicodedata
from django.conf import settings
from django.utils.translation import gettext_lazy as _
import django_filters
@@ -135,4 +136,6 @@ class UserSearchFilter(django_filters.FilterSet):
Custom filter for searching users.
"""
q = django_filters.CharFilter(min_length=5, max_length=254)
q = django_filters.CharFilter(
min_length=settings.API_USERS_SEARCH_QUERY_MIN_LENGTH, max_length=254
)
+14 -3
View File
@@ -225,8 +225,16 @@ class DocumentSerializer(ListDocumentSerializer):
fields = super().get_fields()
request = self.context.get("request")
if request and request.method == "POST":
fields["id"].read_only = False
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
@@ -591,10 +599,13 @@ class LinkDocumentSerializer(serializers.ModelSerializer):
class DocumentDuplicationSerializer(serializers.Serializer):
"""
Serializer for duplicating a document.
Allows specifying whether to keep access permissions.
Allows specifying whether to keep access permissions,
and whether to duplicate descendant documents as well
(deep copy) or not (shallow copy).
"""
with_accesses = serializers.BooleanField(default=False)
with_descendants = serializers.BooleanField(default=False)
def create(self, validated_data):
"""
+290 -52
View File
@@ -37,9 +37,12 @@ from csp.constants import NONE
from csp.decorators import csp_update
from lasuite.malware_detection import malware_detection
from lasuite.oidc_login.decorators import refresh_oidc_access_token
from lasuite.tools.email import get_domain_from_email
from pydantic import ValidationError as PydanticValidationError
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 core import authentication, choices, enums, models
from core.api.filters import remove_accents
@@ -61,7 +64,11 @@ 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
from core.utils import (
extract_attachments,
filter_descendants,
users_sharing_documents_with,
)
from . import permissions, serializers, utils
from .filters import DocumentFilter, ListDocumentFilter, UserSearchFilter
@@ -220,18 +227,80 @@ class UserViewSet(
# Use trigram similarity for non-email-like queries
# For performance reasons we filter first by similarity, which relies on an
# index, then only calculate precise similarity scores for sorting purposes
# index, then only calculate precise similarity scores for sorting purposes.
#
# Additionally results are reordered to prefer users "closer" to the current
# user: users they recently shared documents with, then same email domain.
# To achieve that without complex SQL, we build a proximity score in Python
# and return the top N results.
# For security results, users that match neither of these proximity criteria
# are not returned at all, to prevent email enumeration.
current_user = self.request.user
shared_map = users_sharing_documents_with(current_user)
return (
user_email_domain = get_domain_from_email(current_user.email) or ""
candidates = list(
queryset.annotate(
sim_email=TrigramSimilarity("email", query),
sim_name=TrigramSimilarity("full_name", query),
)
.annotate(similarity=Greatest("sim_email", "sim_name"))
.filter(similarity__gt=0.2)
.order_by("-similarity")[: settings.API_USERS_LIST_LIMIT]
.order_by("-similarity")
)
# Keep only users that either share documents with the current user
# or have an email with the same domain as the current user.
filtered_candidates = []
for u in candidates:
candidate_domain = get_domain_from_email(u.email) or ""
if shared_map.get(u.id) or (
user_email_domain and candidate_domain == user_email_domain
):
filtered_candidates.append(u)
candidates = filtered_candidates
# Build ordering key for each candidate
def _sort_key(u):
# shared priority: most recent first
# Use shared_last_at timestamp numeric for secondary ordering when shared.
shared_last_at = shared_map.get(u.id)
if shared_last_at:
is_shared = 1
shared_score = int(shared_last_at.timestamp())
else:
is_shared = 0
shared_score = 0
# domain proximity
candidate_email_domain = get_domain_from_email(u.email) or ""
same_full_domain = (
1
if candidate_email_domain
and candidate_email_domain == user_email_domain
else 0
)
# similarity fallback
sim = getattr(u, "similarity", 0) or 0
return (
is_shared,
shared_score,
same_full_domain,
sim,
)
# Sort candidates by the key descending and return top N as a queryset-like
# list. Keep return type consistent with previous behavior (QuerySet slice
# was returned) by returning a list of model instances.
candidates.sort(key=_sort_key, reverse=True)
return candidates[: settings.API_USERS_LIST_LIMIT]
@drf.decorators.action(
detail=False,
methods=["get"],
@@ -249,6 +318,59 @@ class UserViewSet(
)
class ReconciliationConfirmView(APIView):
"""API endpoint to confirm user reconciliation emails.
GET /user-reconciliations/{user_type}/{confirmation_id}/
Marks `active_email_checked` or `inactive_email_checked` to True.
"""
permission_classes = [AllowAny]
def get(self, request, user_type, confirmation_id):
"""
Check the confirmation ID and mark the corresponding email as checked.
"""
try:
# validate UUID
uuid_obj = uuid.UUID(str(confirmation_id))
except ValueError:
return drf_response.Response(
{"detail": "Badly formatted confirmation id"},
status=status.HTTP_400_BAD_REQUEST,
)
if user_type not in ("active", "inactive"):
return drf_response.Response(
{"detail": "Invalid user_type"}, status=status.HTTP_400_BAD_REQUEST
)
lookup = (
{"active_email_confirmation_id": uuid_obj}
if user_type == "active"
else {"inactive_email_confirmation_id": uuid_obj}
)
try:
rec = models.UserReconciliation.objects.get(**lookup)
except models.UserReconciliation.DoesNotExist:
return drf_response.Response(
{"detail": "Reconciliation entry not found"},
status=status.HTTP_404_NOT_FOUND,
)
field_name = (
"active_email_checked"
if user_type == "active"
else "inactive_email_checked"
)
if not getattr(rec, field_name):
setattr(rec, field_name, True)
rec.save()
return drf_response.Response({"detail": "Confirmation received"})
class ResourceAccessViewsetMixin:
"""Mixin with methods common to all access viewsets."""
@@ -354,6 +476,9 @@ class DocumentViewSet(
Returns: JSON response with the translated text.
Throttled by: AIDocumentRateThrottle, AIUserRateThrottle.
12. **AI Proxy**: Proxy an AI request to an external AI service.
Example: POST /api/v1.0/documents/<resource_id>/ai-proxy
### Ordering: created_at, updated_at, is_favorite, title
Example:
@@ -1087,11 +1212,7 @@ class DocumentViewSet(
@transaction.atomic
def duplicate(self, request, *args, **kwargs):
"""
Duplicate a document and store the links to attached files in the duplicated
document to allow cross-access.
Optionally duplicates accesses if `with_accesses` is set to true
in the payload.
Duplicate a document, alongside its descendants if requested.
"""
# Get document while checking permissions
document_to_duplicate = self.get_object()
@@ -1100,8 +1221,43 @@ class DocumentViewSet(
data=request.data, partial=True
)
serializer.is_valid(raise_exception=True)
user = request.user
duplicated_document = self._duplicate_document(
document_to_duplicate=document_to_duplicate,
serializer=serializer,
user=user,
)
return drf_response.Response(
{"id": str(duplicated_document.id)}, status=status.HTTP_201_CREATED
)
def _duplicate_document(
self,
document_to_duplicate,
serializer,
user,
new_parent=None,
):
"""
Duplicate a document and store the links to attached files in the duplicated
document to allow cross-access.
Optionally duplicates accesses if `with_accesses` is set to true
in the payload.
Optionally duplicates sub-documents if `with_descendants` is set to true in
the payload. In this case, the whole subtree of the document will be duplicated,
and the links to attached files will be stored in all duplicated documents.
The `with_accesses` option will also be applied to all duplicated documents
if `with_descendants` is set to true.
"""
with_accesses = serializer.validated_data.get("with_accesses", False)
user_role = document_to_duplicate.get_role(request.user)
with_descendants = serializer.validated_data.get("with_descendants", False)
user_role = document_to_duplicate.get_role(user)
is_owner_or_admin = user_role in models.PRIVILEGED_ROLES
base64_yjs_content = document_to_duplicate.content
@@ -1120,11 +1276,41 @@ class DocumentViewSet(
extracted_attachments & set(document_to_duplicate.attachments)
)
title = capfirst(_("copy of {title}").format(title=document_to_duplicate.title))
if not document_to_duplicate.is_root() and choices.RoleChoices.get_priority(
# If parent_duplicate is provided we must add the duplicated document as a child
if new_parent is not None:
duplicated_document = new_parent.add_child(
title=title,
content=base64_yjs_content,
attachments=attachments,
duplicated_from=document_to_duplicate,
creator=user,
**link_kwargs,
)
# Handle access duplication for this child
if with_accesses and is_owner_or_admin:
original_accesses = models.DocumentAccess.objects.filter(
document=document_to_duplicate
).exclude(user=user)
accesses_to_create = [
models.DocumentAccess(
document=duplicated_document,
user_id=access.user_id,
team=access.team,
role=access.role,
)
for access in original_accesses
]
if accesses_to_create:
models.DocumentAccess.objects.bulk_create(accesses_to_create)
elif not document_to_duplicate.is_root() and choices.RoleChoices.get_priority(
user_role
) < choices.RoleChoices.get_priority(models.RoleChoices.EDITOR):
duplicated_document = models.Document.add_root(
creator=self.request.user,
creator=user,
title=title,
content=base64_yjs_content,
attachments=attachments,
@@ -1133,55 +1319,63 @@ class DocumentViewSet(
)
models.DocumentAccess.objects.create(
document=duplicated_document,
user=self.request.user,
user=user,
role=models.RoleChoices.OWNER,
)
return drf_response.Response(
{"id": str(duplicated_document.id)}, status=status.HTTP_201_CREATED
else:
duplicated_document = document_to_duplicate.add_sibling(
"right",
title=title,
content=base64_yjs_content,
attachments=attachments,
duplicated_from=document_to_duplicate,
creator=user,
**link_kwargs,
)
duplicated_document = document_to_duplicate.add_sibling(
"right",
title=title,
content=base64_yjs_content,
attachments=attachments,
duplicated_from=document_to_duplicate,
creator=request.user,
**link_kwargs,
)
# Always add the logged-in user as OWNER for root documents
if document_to_duplicate.is_root():
accesses_to_create = [
models.DocumentAccess(
document=duplicated_document,
user=request.user,
role=models.RoleChoices.OWNER,
)
]
# If accesses should be duplicated, add other users' accesses as per original document
if with_accesses and is_owner_or_admin:
original_accesses = models.DocumentAccess.objects.filter(
document=document_to_duplicate
).exclude(user=request.user)
accesses_to_create.extend(
# Always add the logged-in user as OWNER for root documents
if document_to_duplicate.is_root():
accesses_to_create = [
models.DocumentAccess(
document=duplicated_document,
user_id=access.user_id,
team=access.team,
role=access.role,
user=user,
role=models.RoleChoices.OWNER,
)
for access in original_accesses
]
# If accesses should be duplicated,
# add other users' accesses as per original document
if with_accesses and is_owner_or_admin:
original_accesses = models.DocumentAccess.objects.filter(
document=document_to_duplicate
).exclude(user=user)
accesses_to_create.extend(
models.DocumentAccess(
document=duplicated_document,
user_id=access.user_id,
team=access.team,
role=access.role,
)
for access in original_accesses
)
# Bulk create all the duplicated accesses
models.DocumentAccess.objects.bulk_create(accesses_to_create)
if with_descendants:
for child in document_to_duplicate.get_children().filter(
ancestors_deleted_at__isnull=True
):
# When duplicating descendants, attach duplicates under the duplicated_document
self._duplicate_document(
document_to_duplicate=child,
serializer=serializer,
user=user,
new_parent=duplicated_document,
)
# Bulk create all the duplicated accesses
models.DocumentAccess.objects.bulk_create(accesses_to_create)
return drf_response.Response(
{"id": str(duplicated_document.id)}, status=status.HTTP_201_CREATED
)
return duplicated_document
def _search_simple(self, request, text):
"""
@@ -1642,6 +1836,45 @@ class DocumentViewSet(
return drf.response.Response(body, status=drf.status.HTTP_200_OK)
@drf.decorators.action(
detail=True,
methods=["post"],
name="Proxy AI requests to the AI provider",
url_path="ai-proxy",
throttle_classes=[utils.AIDocumentRateThrottle, utils.AIUserRateThrottle],
)
def ai_proxy(self, request, *args, **kwargs):
"""
POST /api/v1.0/documents/<resource_id>/ai-proxy
Proxy AI requests to the configured AI provider.
This endpoint forwards requests to the AI provider and returns the complete response.
"""
# Check permissions first
self.get_object()
if not settings.AI_FEATURE_ENABLED or not settings.AI_FEATURE_BLOCKNOTE_ENABLED:
raise ValidationError("AI feature is not enabled.")
ai_service = AIService()
try:
stream = ai_service.stream(request)
except PydanticValidationError as err:
logger.info("pydantic validation error: %s", err)
return drf.response.Response(
{"detail": "Invalid submitted payload"},
status=drf.status.HTTP_400_BAD_REQUEST,
)
return StreamingHttpResponse(
stream,
content_type="text/event-stream",
headers={
"x-vercel-ai-data-stream": "v1", # This header is used for Vercel AI streaming,
"X-Accel-Buffering": "no", # Prevent nginx buffering
},
)
@drf.decorators.action(
detail=True,
methods=["post"],
@@ -2337,7 +2570,11 @@ class ConfigView(drf.views.APIView):
Return a dictionary of public settings.
"""
array_settings = [
"AI_BOT",
"AI_FEATURE_ENABLED",
"AI_FEATURE_BLOCKNOTE_ENABLED",
"AI_FEATURE_LEGACY_ENABLED",
"API_USERS_SEARCH_QUERY_MIN_LENGTH",
"COLLABORATION_WS_URL",
"COLLABORATION_WS_NOT_CONNECTED_READY_ONLY",
"CONVERSION_FILE_EXTENSIONS_ALLOWED",
@@ -2347,6 +2584,7 @@ class ConfigView(drf.views.APIView):
"FRONTEND_CSS_URL",
"FRONTEND_HOMEPAGE_FEATURE_ENABLED",
"FRONTEND_JS_URL",
"FRONTEND_SILENT_LOGIN_ENABLED",
"FRONTEND_THEME",
"MEDIA_BASE_URL",
"POSTHOG_KEY",
+18
View File
@@ -19,3 +19,21 @@ class ForceSessionMiddleware:
response = self.get_response(request)
return response
class SaveRawBodyMiddleware:
"""
Save the raw request body to use it later.
"""
def __init__(self, get_response):
"""Initialize the middleware."""
self.get_response = get_response
def __call__(self, request):
"""Save the raw request body in the request to use it later."""
if request.path.endswith(("/ai-proxy/", "/ai-proxy")):
request.raw_body = request.body
response = self.get_response(request)
return response
@@ -0,0 +1,178 @@
# Generated by Django 5.2.11 on 2026-02-10 15:47
import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0028_remove_templateaccess_template_and_more"),
]
operations = [
migrations.CreateModel(
name="UserReconciliationCsvImport",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
help_text="primary key for the record as UUID",
primary_key=True,
serialize=False,
verbose_name="id",
),
),
(
"created_at",
models.DateTimeField(
auto_now_add=True,
help_text="date and time at which a record was created",
verbose_name="created on",
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True,
help_text="date and time at which a record was last updated",
verbose_name="updated on",
),
),
(
"file",
models.FileField(upload_to="imports/", verbose_name="CSV file"),
),
(
"status",
models.CharField(
choices=[
("pending", "Pending"),
("running", "Running"),
("done", "Done"),
("error", "Error"),
],
default="pending",
max_length=20,
),
),
("logs", models.TextField(blank=True)),
],
options={
"verbose_name": "user reconciliation CSV import",
"verbose_name_plural": "user reconciliation CSV imports",
"db_table": "impress_user_reconciliation_csv_import",
},
),
migrations.CreateModel(
name="UserReconciliation",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
help_text="primary key for the record as UUID",
primary_key=True,
serialize=False,
verbose_name="id",
),
),
(
"created_at",
models.DateTimeField(
auto_now_add=True,
help_text="date and time at which a record was created",
verbose_name="created on",
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True,
help_text="date and time at which a record was last updated",
verbose_name="updated on",
),
),
(
"active_email",
models.EmailField(
max_length=254, verbose_name="Active email address"
),
),
(
"inactive_email",
models.EmailField(
max_length=254, verbose_name="Email address to deactivate"
),
),
("active_email_checked", models.BooleanField(default=False)),
("inactive_email_checked", models.BooleanField(default=False)),
(
"active_email_confirmation_id",
models.UUIDField(
default=uuid.uuid4, editable=False, null=True, unique=True
),
),
(
"inactive_email_confirmation_id",
models.UUIDField(
default=uuid.uuid4, editable=False, null=True, unique=True
),
),
(
"source_unique_id",
models.CharField(
blank=True,
max_length=100,
null=True,
verbose_name="Unique ID in the source file",
),
),
(
"status",
models.CharField(
choices=[
("pending", "Pending"),
("ready", "Ready"),
("done", "Done"),
("error", "Error"),
],
default="pending",
max_length=20,
),
),
("logs", models.TextField(blank=True)),
(
"active_user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="active_user",
to=settings.AUTH_USER_MODEL,
),
),
(
"inactive_user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="inactive_user",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "user reconciliation",
"verbose_name_plural": "user reconciliations",
"db_table": "impress_user_reconciliation",
"ordering": ["-created_at"],
},
),
]
+513 -13
View File
@@ -15,7 +15,6 @@ from django.contrib.auth import models as auth_models
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.postgres.fields import ArrayField
from django.contrib.sites.models import Site
from django.core import mail
from django.core.cache import cache
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
@@ -33,14 +32,14 @@ from rest_framework.exceptions import ValidationError
from timezone_field import TimeZoneField
from treebeard.mp_tree import MP_Node, MP_NodeManager, MP_NodeQuerySet
from .choices import (
from core.choices import (
PRIVILEGED_ROLES,
LinkReachChoices,
LinkRoleChoices,
RoleChoices,
get_equivalent_link_definition,
)
from .validators import sub_validator
from core.validators import sub_validator
logger = getLogger(__name__)
@@ -119,11 +118,11 @@ class UserManager(auth_models.UserManager):
if settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
try:
return self.get(email=email)
return self.get(email__iexact=email)
except self.model.DoesNotExist:
pass
elif (
self.filter(email=email).exists()
self.filter(email__iexact=email).exists()
and not settings.OIDC_ALLOW_DUPLICATE_EMAILS
):
raise DuplicateEmailError(
@@ -210,14 +209,77 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
def save(self, *args, **kwargs):
"""
If it's a new user, give its user access to the documents to which s.he was invited.
If it's a new user, give its user access to the documents they were invited to.
"""
is_adding = self._state.adding
super().save(*args, **kwargs)
if is_adding:
self._handle_onboarding_documents_access()
self._duplicate_onboarding_sandbox_document()
self._convert_valid_invitations()
def _handle_onboarding_documents_access(self):
"""
If the user is new and there are documents configured to be given to new users,
give access to these documents and pin them as favorites for the user.
"""
if settings.USER_ONBOARDING_DOCUMENTS:
onboarding_document_ids = set(settings.USER_ONBOARDING_DOCUMENTS)
onboarding_accesses = []
favorite_documents = []
for document_id in onboarding_document_ids:
try:
document = Document.objects.get(id=document_id)
except Document.DoesNotExist:
logger.warning(
"Onboarding document with id %s does not exist. Skipping.",
document_id,
)
continue
onboarding_accesses.append(
DocumentAccess(
user=self, document=document, role=RoleChoices.READER
)
)
favorite_documents.append(
DocumentFavorite(user=self, document_id=document_id)
)
DocumentAccess.objects.bulk_create(onboarding_accesses)
DocumentFavorite.objects.bulk_create(favorite_documents)
def _duplicate_onboarding_sandbox_document(self):
"""
If the user is new and there is a sandbox document configured,
duplicate the sandbox document for the user
"""
if settings.USER_ONBOARDING_SANDBOX_DOCUMENT:
sandbox_id = settings.USER_ONBOARDING_SANDBOX_DOCUMENT
try:
template_document = Document.objects.get(id=sandbox_id)
except Document.DoesNotExist:
logger.warning(
"Onboarding sandbox document with id %s does not exist. Skipping.",
sandbox_id,
)
return
sandbox_document = template_document.add_sibling(
"right",
title=template_document.title,
content=template_document.content,
attachments=template_document.attachments,
duplicated_from=template_document,
creator=self,
)
DocumentAccess.objects.create(
user=self, document=sandbox_document, role=RoleChoices.OWNER
)
def _convert_valid_invitations(self):
"""
Convert valid invitations to document accesses.
@@ -251,11 +313,37 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
valid_invitations.delete()
def email_user(self, subject, message, from_email=None, **kwargs):
"""Email this user."""
if not self.email:
raise ValueError("User has no email address.")
mail.send_mail(subject, message, from_email, [self.email], **kwargs)
def send_email(self, subject, context=None, language=None):
"""Generate and send email to the user from a template."""
emails = [self.email]
context = context or {}
domain = settings.EMAIL_URL_APP or Site.objects.get_current().domain
language = language or get_language()
context.update(
{
"brandname": settings.EMAIL_BRAND_NAME,
"domain": domain,
"logo_img": settings.EMAIL_LOGO_IMG,
}
)
with override(language):
msg_html = render_to_string("mail/html/template.html", context)
msg_plain = render_to_string("mail/text/template.txt", context)
subject = str(subject) # Force translation
try:
send_mail(
subject.capitalize(),
msg_plain,
settings.EMAIL_FROM,
emails,
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as exception:
logger.error("invitation to %s was not sent: %s", emails, exception)
@cached_property
def teams(self):
@@ -266,6 +354,417 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
return []
class UserReconciliation(BaseModel):
"""Model to run batch jobs to replace an active user by another one"""
active_email = models.EmailField(_("Active email address"))
inactive_email = models.EmailField(_("Email address to deactivate"))
active_email_checked = models.BooleanField(default=False)
inactive_email_checked = models.BooleanField(default=False)
active_user = models.ForeignKey(
User,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="active_user",
)
inactive_user = models.ForeignKey(
User,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="inactive_user",
)
active_email_confirmation_id = models.UUIDField(
default=uuid.uuid4, unique=True, editable=False, null=True
)
inactive_email_confirmation_id = models.UUIDField(
default=uuid.uuid4, unique=True, editable=False, null=True
)
source_unique_id = models.CharField(
max_length=100,
blank=True,
null=True,
verbose_name=_("Unique ID in the source file"),
)
status = models.CharField(
max_length=20,
choices=[
("pending", _("Pending")),
("ready", _("Ready")),
("done", _("Done")),
("error", _("Error")),
],
default="pending",
)
logs = models.TextField(blank=True)
class Meta:
db_table = "impress_user_reconciliation"
verbose_name = _("user reconciliation")
verbose_name_plural = _("user reconciliations")
ordering = ["-created_at"]
def __str__(self):
return f"Reconciliation from {self.inactive_email} to {self.active_email}"
def save(self, *args, **kwargs):
"""
For pending queries, identify the actual users and send validation emails
"""
if self.status == "pending":
self.active_user = User.objects.filter(email=self.active_email).first()
self.inactive_user = User.objects.filter(email=self.inactive_email).first()
if self.active_user and self.inactive_user:
if not self.active_email_checked:
self.send_reconciliation_confirm_email(
self.active_user, "active", self.active_email_confirmation_id
)
if not self.inactive_email_checked:
self.send_reconciliation_confirm_email(
self.inactive_user,
"inactive",
self.inactive_email_confirmation_id,
)
self.status = "ready"
else:
self.status = "error"
self.logs = "Error: Both active and inactive users need to exist."
super().save(*args, **kwargs)
@transaction.atomic
def process_reconciliation_request(self):
"""
Process the reconciliation request as a transaction.
- Transfer document accesses from inactive to active user, updating roles as needed.
- Transfer document favorites from inactive to active user.
- Transfer link traces from inactive to active user.
- Transfer comment-related content from inactive to active user
(threads, comments and reactions)
- Activate the active user and deactivate the inactive user.
- Update the reconciliation entry itself.
"""
# Prepare the data to perform the reconciliation on
updated_accesses, removed_accesses = (
self.prepare_documentaccess_reconciliation()
)
updated_linktraces, removed_linktraces = self.prepare_linktrace_reconciliation()
update_favorites, removed_favorites = (
self.prepare_document_favorite_reconciliation()
)
updated_threads = self.prepare_thread_reconciliation()
updated_comments = self.prepare_comment_reconciliation()
updated_reactions, removed_reactions = self.prepare_reaction_reconciliation()
self.active_user.is_active = True
self.inactive_user.is_active = False
# Actually perform the bulk operations
DocumentAccess.objects.bulk_update(updated_accesses, ["user", "role"])
if removed_accesses:
ids_to_delete = [entry.id for entry in removed_accesses]
DocumentAccess.objects.filter(id__in=ids_to_delete).delete()
DocumentFavorite.objects.bulk_update(update_favorites, ["user"])
if removed_favorites:
ids_to_delete = [entry.id for entry in removed_favorites]
DocumentFavorite.objects.filter(id__in=ids_to_delete).delete()
LinkTrace.objects.bulk_update(updated_linktraces, ["user"])
if removed_linktraces:
ids_to_delete = [entry.id for entry in removed_linktraces]
LinkTrace.objects.filter(id__in=ids_to_delete).delete()
Thread.objects.bulk_update(updated_threads, ["creator"])
Comment.objects.bulk_update(updated_comments, ["user"])
# pylint: disable=C0103
ReactionThroughModel = Reaction.users.through
reactions_to_create = []
for updated_reaction in updated_reactions:
reactions_to_create.append(
ReactionThroughModel(
user_id=self.active_user.pk, reaction_id=updated_reaction.pk
)
)
if reactions_to_create:
ReactionThroughModel.objects.bulk_create(reactions_to_create)
if removed_reactions:
ids_to_delete = [entry.id for entry in removed_reactions]
ReactionThroughModel.objects.filter(
reaction_id__in=ids_to_delete, user_id=self.inactive_user.pk
).delete()
User.objects.bulk_update([self.active_user, self.inactive_user], ["is_active"])
# Wrap up the reconciliation entry
self.logs += f"""Requested update for {len(updated_accesses)} DocumentAccess items
and deletion for {len(removed_accesses)} DocumentAccess items.\n"""
self.status = "done"
self.save()
self.send_reconciliation_done_email()
def prepare_documentaccess_reconciliation(self):
"""
Prepare the reconciliation by transferring document accesses from the inactive user
to the active user.
"""
updated_accesses = []
removed_accesses = []
inactive_accesses = DocumentAccess.objects.filter(user=self.inactive_user)
# Check documents where the active user already has access
inactive_accesses_documents = inactive_accesses.values_list(
"document", flat=True
)
existing_accesses = DocumentAccess.objects.filter(user=self.active_user).filter(
document__in=inactive_accesses_documents
)
existing_roles_per_doc = dict(existing_accesses.values_list("document", "role"))
for entry in inactive_accesses:
if entry.document_id in existing_roles_per_doc:
# Update role if needed
existing_role = existing_roles_per_doc[entry.document_id]
max_role = RoleChoices.max(entry.role, existing_role)
if existing_role != max_role:
existing_access = existing_accesses.get(document=entry.document)
existing_access.role = max_role
updated_accesses.append(existing_access)
removed_accesses.append(entry)
else:
entry.user = self.active_user
updated_accesses.append(entry)
return updated_accesses, removed_accesses
def prepare_document_favorite_reconciliation(self):
"""
Prepare the reconciliation by transferring document favorites from the inactive user
to the active user.
"""
updated_favorites = []
removed_favorites = []
existing_favorites = DocumentFavorite.objects.filter(user=self.active_user)
existing_favorite_doc_ids = set(
existing_favorites.values_list("document_id", flat=True)
)
inactive_favorites = DocumentFavorite.objects.filter(user=self.inactive_user)
for entry in inactive_favorites:
if entry.document_id in existing_favorite_doc_ids:
removed_favorites.append(entry)
else:
entry.user = self.active_user
updated_favorites.append(entry)
return updated_favorites, removed_favorites
def prepare_linktrace_reconciliation(self):
"""
Prepare the reconciliation by transferring link traces from the inactive user
to the active user.
"""
updated_linktraces = []
removed_linktraces = []
existing_linktraces = LinkTrace.objects.filter(user=self.active_user)
inactive_linktraces = LinkTrace.objects.filter(user=self.inactive_user)
for entry in inactive_linktraces:
if existing_linktraces.filter(document=entry.document).exists():
removed_linktraces.append(entry)
else:
entry.user = self.active_user
updated_linktraces.append(entry)
return updated_linktraces, removed_linktraces
def prepare_thread_reconciliation(self):
"""
Prepare the reconciliation by transferring threads from the inactive user
to the active user.
"""
updated_threads = []
inactive_threads = Thread.objects.filter(creator=self.inactive_user)
for entry in inactive_threads:
entry.creator = self.active_user
updated_threads.append(entry)
return updated_threads
def prepare_comment_reconciliation(self):
"""
Prepare the reconciliation by transferring comments from the inactive user
to the active user.
"""
updated_comments = []
inactive_comments = Comment.objects.filter(user=self.inactive_user)
for entry in inactive_comments:
entry.user = self.active_user
updated_comments.append(entry)
return updated_comments
def prepare_reaction_reconciliation(self):
"""
Prepare the reconciliation by creating missing reactions for the active user
(ie, the ones that exist for the inactive user but not the active user)
and then deleting all reactions of the inactive user.
"""
inactive_reactions = Reaction.objects.filter(users=self.inactive_user)
updated_reactions = inactive_reactions.exclude(users=self.active_user)
return updated_reactions, inactive_reactions
def send_reconciliation_confirm_email(
self, user, user_type, confirmation_id, language=None
):
"""Method allowing to send confirmation email for reconciliation requests."""
language = language or get_language()
domain = settings.EMAIL_URL_APP or Site.objects.get_current().domain
message = _(
"""You have requested a reconciliation of your user accounts on Docs.
To confirm that you are the one who initiated the request
and that this email belongs to you:"""
)
with override(language):
subject = _("Confirm by clicking the link to start the reconciliation")
context = {
"title": subject,
"message": message,
"link": f"{domain}/user-reconciliations/{user_type}/{confirmation_id}/",
"link_label": str(_("Click here")),
"button_label": str(_("Confirm")),
}
user.send_email(subject, context, language)
def send_reconciliation_done_email(self, language=None):
"""Method allowing to send done email for reconciliation requests."""
language = language or get_language()
domain = settings.EMAIL_URL_APP or Site.objects.get_current().domain
message = _(
"""Your reconciliation request has been processed.
New documents are likely associated with your account:"""
)
with override(language):
subject = _("Your accounts have been merged")
context = {
"title": subject,
"message": message,
"link": f"{domain}/",
"link_label": str(_("Click here to see")),
"button_label": str(_("See my documents")),
}
self.active_user.send_email(subject, context, language)
class UserReconciliationCsvImport(BaseModel):
"""Model to import reconciliations requests from an external source
(eg, )"""
file = models.FileField(upload_to="imports/", verbose_name=_("CSV file"))
status = models.CharField(
max_length=20,
choices=[
("pending", _("Pending")),
("running", _("Running")),
("done", _("Done")),
("error", _("Error")),
],
default="pending",
)
logs = models.TextField(blank=True)
class Meta:
db_table = "impress_user_reconciliation_csv_import"
verbose_name = _("user reconciliation CSV import")
verbose_name_plural = _("user reconciliation CSV imports")
def __str__(self):
return f"User reconciliation CSV import {self.id}"
def send_email(self, subject, emails, context=None, language=None):
"""Generate and send email to the user from a template."""
context = context or {}
domain = settings.EMAIL_URL_APP or Site.objects.get_current().domain
language = language or get_language()
context.update(
{
"brandname": settings.EMAIL_BRAND_NAME,
"domain": domain,
"logo_img": settings.EMAIL_LOGO_IMG,
}
)
with override(language):
msg_html = render_to_string("mail/html/template.html", context)
msg_plain = render_to_string("mail/text/template.txt", context)
subject = str(subject) # Force translation
try:
send_mail(
subject.capitalize(),
msg_plain,
settings.EMAIL_FROM,
emails,
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as exception:
logger.error("invitation to %s was not sent: %s", emails, exception)
def send_reconciliation_error_email(
self, recipient_email, other_email, language=None
):
"""Method allowing to send email for reconciliation requests with errors."""
language = language or get_language()
emails = [recipient_email]
message = _(
"""Your request for reconciliation was unsuccessful.
Reconciliation failed for the following email addresses:
{recipient_email}, {other_email}.
Please check for typos.
You can submit another request with the valid email addresses."""
).format(recipient_email=recipient_email, other_email=other_email)
with override(language):
subject = _("Reconciliation of your Docs accounts not completed")
context = {
"title": subject,
"message": message,
"link": settings.USER_RECONCILIATION_FORM_URL,
"link_label": str(_("Click here")),
"button_label": str(_("Make a new request")),
}
self.send_email(subject, emails, context, language)
class BaseAccess(BaseModel):
"""Base model for accesses to handle resources."""
@@ -783,6 +1282,7 @@ class Document(MP_Node, BaseModel):
return {
"accesses_manage": is_owner_or_admin,
"accesses_view": has_access_role,
"ai_proxy": ai_access,
"ai_transform": ai_access,
"ai_translate": ai_access,
"attachment_upload": can_update,
@@ -824,7 +1324,7 @@ class Document(MP_Node, BaseModel):
"brandname": settings.EMAIL_BRAND_NAME,
"document": self,
"domain": domain,
"link": f"{domain}/docs/{self.id}/",
"link": f"{domain}/docs/{self.id}/?utm_source=docssharelink&utm_campaign={self.id}",
"link_label": self.title or str(_("Untitled Document")),
"button_label": _("Open"),
"logo_img": settings.EMAIL_LOGO_IMG,
@@ -1469,7 +1969,7 @@ class Invitation(BaseModel):
# Check if an identity already exists for the provided email
if (
User.objects.filter(email=self.email).exists()
User.objects.filter(email__iexact=self.email).exists()
and not settings.OIDC_ALLOW_DUPLICATE_EMAILS
):
raise ValidationError(
+283 -1
View File
@@ -1,15 +1,68 @@
"""AI services."""
import asyncio
import json
import logging
import os
import queue
import threading
from collections.abc import AsyncIterator, Iterator
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.openai import OpenAIChatModel
from pydantic_ai.providers.openai import OpenAIProvider
from pydantic_ai.tools import ToolDefinition
from pydantic_ai.toolsets.external import ExternalToolset
from pydantic_ai.ui import SSE_CONTENT_TYPE
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:
from langfuse.openai import OpenAI
OpenAI = OpenAI_Langfuse
else:
from openai import OpenAI
log = logging.getLogger(__name__)
BLOCKNOTE_TOOL_STRICT_PROMPT = """
You are editing a BlockNote document via the tool applyDocumentOperations.
You MUST respond ONLY by calling applyDocumentOperations.
The tool input MUST be valid JSON:
{ "operations": [ ... ] }
Each operation MUST include "type" and it MUST be one of:
- "update" (requires: id, block)
- "add" (requires: referenceId, position, blocks)
- "delete" (requires: id)
VALID SHAPES (FOLLOW EXACTLY):
Update:
{ "type":"update", "id":"<id$>", "block":"<p>...</p>" }
IMPORTANT: "block" MUST be a STRING containing a SINGLE valid HTML element.
Add:
{ "type":"add", "referenceId":"<id$>", "position":"before|after", "blocks":["<p>...</p>"] }
IMPORTANT: "blocks" MUST be an ARRAY OF STRINGS.
Each item MUST be a STRING containing a SINGLE valid HTML element.
Delete:
{ "type":"delete", "id":"<id$>" }
IDs ALWAYS end with "$". Use ids EXACTLY as provided.
Return ONLY the JSON tool input. No prose, no markdown.
"""
AI_ACTIONS = {
"prompt": (
@@ -56,6 +109,40 @@ AI_TRANSLATE = (
)
def convert_async_generator_to_sync(async_gen: AsyncIterator[str]) -> Iterator[str]:
"""Convert an async generator to a sync generator."""
q: queue.Queue[str | object] = queue.Queue()
sentinel = object()
exc_sentinel = object()
async def run_async_gen():
try:
async for async_item in async_gen:
q.put(async_item)
except Exception as exc: # pylint: disable=broad-except #noqa: BLE001
q.put((exc_sentinel, exc))
finally:
q.put(sentinel)
def start_async_loop():
asyncio.run(run_async_gen())
thread = threading.Thread(target=start_async_loop, daemon=True)
thread.start()
try:
while True:
item = q.get()
if item is sentinel:
break
if isinstance(item, tuple) and item[0] is exc_sentinel:
# re-raise the exception in the sync context
raise item[1]
yield item
finally:
thread.join()
class AIService:
"""Service class for AI-related operations."""
@@ -96,3 +183,198 @@ class AIService:
language_display = enums.ALL_LANGUAGES.get(language, language)
system_content = AI_TRANSLATE.format(language=language_display)
return self.call_ai_api(system_content, text)
@staticmethod
def inject_document_state_messages(
messages: list[UIMessage],
) -> list[UIMessage]:
"""Inject document state context before user messages.
Port of BlockNote's injectDocumentStateMessages.
For each user message carrying documentState metadata, an assistant
message describing the current document/selection state is prepended
so the LLM sees it as context.
"""
result: list[UIMessage] = []
for message in messages:
if (
message.role == "user"
and isinstance(message.metadata, dict)
and "documentState" in message.metadata
):
doc_state = message.metadata["documentState"]
selection = doc_state.get("selection")
blocks = doc_state.get("blocks")
if selection:
parts = [
TextUIPart(
text=(
"This is the latest state of the selection "
"(ignore previous selections, you MUST issue "
"operations against this latest version of "
"the selection):"
),
),
TextUIPart(
text=json.dumps(doc_state.get("selectedBlocks")),
),
TextUIPart(
text=(
"This is the latest state of the entire "
"document (INCLUDING the selected text), you "
"can use this to find the selected text to "
"understand the context (but you MUST NOT "
"issue operations against this document, you "
"MUST issue operations against the selection):"
),
),
TextUIPart(text=json.dumps(blocks)),
]
else:
text = (
"There is no active selection. This is the latest "
"state of the document (ignore previous documents, "
"you MUST issue operations against this latest "
"version of the document). The cursor is BETWEEN "
"two blocks as indicated by cursor: true."
)
if doc_state.get("isEmptyDocument"):
text += (
"Because the document is empty, YOU MUST first "
"update the empty block before adding new blocks."
)
else:
text += (
"Prefer updating existing blocks over removing "
"and adding (but this also depends on the "
"user's question)."
)
parts = [
TextUIPart(text=text),
TextUIPart(text=json.dumps(blocks)),
]
result.append(
UIMessage(
role="assistant",
id=f"assistant-document-state-{message.id}",
parts=parts,
)
)
result.append(message)
return result
@staticmethod
def tool_definitions_to_toolset(
tool_definitions: Dict[str, Any],
) -> ExternalToolset:
"""Convert serialized tool definitions to a pydantic-ai ExternalToolset.
Port of BlockNote's toolDefinitionsToToolSet.
Builds ToolDefinition objects from the JSON-Schema-based definitions
sent by the frontend and wraps them in an ExternalToolset so that
pydantic-ai advertises them to the LLM without trying to execute them
server-side (execution is deferred to the frontend).
"""
tool_defs = [
ToolDefinition(
name=name,
description=defn.get("description", ""),
parameters_json_schema=defn.get("inputSchema", {}),
kind="external",
metadata={
"output_schema": defn.get("outputSchema"),
},
)
for name, defn in tool_definitions.items()
]
return ExternalToolset(tool_defs)
def _harden_messages(
self, run_input: RequestData, tool_definitions: Dict[str, Any]
):
"""
Harden messages if applyDocumentOperations tool is used.
We would like the system_prompt property in the Agent initialization
but for UI adapter, like vercel, the agent is ignoring it
see https://github.com/pydantic/pydantic-ai/issues/3315
We have to inject it in the run_input.messages if needed.
"""
for name, _defn in tool_definitions.items():
if name == "applyDocumentOperations":
run_input.messages.insert(
0,
UIMessage(
id="system-force-tool-usage",
role="system",
parts=[TextUIPart(text=BLOCKNOTE_TOOL_STRICT_PROMPT)],
),
)
return
def _build_async_stream(self, request: Request) -> AsyncIterator[str]:
"""Build the async stream from the AI provider."""
instrument_enabled = settings.LANGFUSE_PUBLIC_KEY is not None
if instrument_enabled:
langfuse = get_client()
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(model, instrument=instrument_enabled)
accept = request.META.get("HTTP_ACCEPT", SSE_CONTENT_TYPE)
run_input = VercelAIAdapter.build_run_input(request.raw_body)
# Inject document state context into the conversation
run_input.messages = self.inject_document_state_messages(run_input.messages)
# Build an ExternalToolset from frontend-supplied tool definitions
raw_tool_defs = (
run_input.model_extra.get("toolDefinitions")
if run_input.model_extra
else None
)
toolset = (
self.tool_definitions_to_toolset(raw_tool_defs) if raw_tool_defs else None
)
if raw_tool_defs:
self._harden_messages(run_input, raw_tool_defs)
adapter = VercelAIAdapter(
agent=agent,
run_input=run_input,
accept=accept,
sdk_version=settings.AI_VERCEL_SDK_VERSION,
)
event_stream = adapter.run_stream(
output_type=[str, DeferredToolRequests] if toolset else None,
toolsets=[toolset] if toolset else None,
)
return adapter.encode_stream(event_stream)
def stream(self, request: Request) -> Union[AsyncIterator[str], Iterator[str]]:
"""Stream AI API requests to the configured AI provider.
Returns an async iterator when running in async mode (ASGI)
or a sync iterator when running in sync mode (WSGI).
"""
async_stream = self._build_async_stream(request)
if os.environ.get("PYTHON_SERVER_MODE", "sync") == "async":
return async_stream
return convert_async_generator_to_sync(async_stream)
+20 -2
View File
@@ -4,12 +4,14 @@ Declare and configure the signals for the impress core application
from functools import partial
from django.core.cache import cache
from django.db import transaction
from django.db.models import signals
from django.dispatch import receiver
from . import models
from .tasks.search import trigger_batch_document_indexer
from core import models
from core.tasks.search import trigger_batch_document_indexer
from core.utils import get_users_sharing_documents_with_cache_key
@receiver(signals.post_save, sender=models.Document)
@@ -26,8 +28,24 @@ def document_post_save(sender, instance, **kwargs): # pylint: disable=unused-ar
def document_access_post_save(sender, instance, created, **kwargs): # pylint: disable=unused-argument
"""
Asynchronous call to the document indexer at the end of the transaction.
Clear cache for the affected user.
"""
if not created:
transaction.on_commit(
partial(trigger_batch_document_indexer, instance.document)
)
# Invalidate cache for the user
if instance.user:
cache_key = get_users_sharing_documents_with_cache_key(instance.user)
cache.delete(cache_key)
@receiver(signals.post_delete, sender=models.DocumentAccess)
def document_access_post_delete(sender, instance, **kwargs): # pylint: disable=unused-argument
"""
Clear cache for the affected user when document access is deleted.
"""
if instance.user:
cache_key = get_users_sharing_documents_with_cache_key(instance.user)
cache.delete(cache_key)
@@ -0,0 +1,135 @@
"""Processing tasks for user reconciliation CSV imports."""
import csv
import traceback
import uuid
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.db import IntegrityError
from botocore.exceptions import ClientError
from core.models import UserReconciliation, UserReconciliationCsvImport
from impress.celery_app import app
def _process_row(row, job, counters):
"""Process a single row from the CSV file."""
source_unique_id = row["id"].strip()
# Skip entries if they already exist with this source_unique_id
if UserReconciliation.objects.filter(source_unique_id=source_unique_id).exists():
counters["already_processed_source_ids"] += 1
return counters
active_email_checked = row.get("active_email_checked", "0") == "1"
inactive_email_checked = row.get("inactive_email_checked", "0") == "1"
active_email = row["active_email"]
inactive_emails = row["inactive_email"].split("|")
try:
validate_email(active_email)
except ValidationError:
job.send_reconciliation_error_email(
recipient_email=inactive_emails[0], other_email=active_email
)
job.logs += f"Invalid active email address on row {source_unique_id}."
counters["rows_with_errors"] += 1
return counters
for inactive_email in inactive_emails:
try:
validate_email(inactive_email)
except (ValidationError, ValueError):
job.send_reconciliation_error_email(
recipient_email=active_email, other_email=inactive_email
)
job.logs += f"Invalid inactive email address on row {source_unique_id}.\n"
counters["rows_with_errors"] += 1
continue
if inactive_email == active_email:
job.send_reconciliation_error_email(
recipient_email=active_email, other_email=inactive_email
)
job.logs += (
f"Error on row {source_unique_id}: "
f"{active_email} set as both active and inactive email.\n"
)
counters["rows_with_errors"] += 1
continue
_rec_entry = UserReconciliation.objects.create(
active_email=active_email,
inactive_email=inactive_email,
active_email_checked=active_email_checked,
inactive_email_checked=inactive_email_checked,
active_email_confirmation_id=uuid.uuid4(),
inactive_email_confirmation_id=uuid.uuid4(),
source_unique_id=source_unique_id,
status="pending",
)
counters["rec_entries_created"] += 1
return counters
@app.task
def user_reconciliation_csv_import_job(job_id):
"""Process a UserReconciliationCsvImport job.
Creates UserReconciliation entries from the CSV file.
Does some sanity checks on the data:
- active_email and inactive_email must be valid email addresses
- active_email and inactive_email cannot be the same
Rows with errors are logged in the job logs and skipped, but do not cause
the entire job to fail or prevent the next rows from being processed.
"""
# Imports the CSV file, breaks it into UserReconciliation items
job = UserReconciliationCsvImport.objects.get(id=job_id)
job.status = "running"
job.save()
counters = {
"rec_entries_created": 0,
"rows_with_errors": 0,
"already_processed_source_ids": 0,
}
try:
with job.file.open(mode="r") as f:
reader = csv.DictReader(f)
if not {"active_email", "inactive_email", "id"}.issubset(reader.fieldnames):
raise KeyError(
"CSV is missing mandatory columns: active_email, inactive_email, id"
)
for row in reader:
counters = _process_row(row, job, counters)
job.status = "done"
job.logs += (
f"Import completed successfully. {reader.line_num} rows processed."
f" {counters['rec_entries_created']} reconciliation entries created."
f" {counters['already_processed_source_ids']} rows were already processed."
f" {counters['rows_with_errors']} rows had errors."
)
except (
csv.Error,
KeyError,
ValidationError,
ValueError,
IntegrityError,
OSError,
ClientError,
) as e:
# Catch expected I/O/CSV/model errors and record traceback in logs for debugging
job.status = "error"
job.logs += f"{e!s}\n{traceback.format_exc()}"
finally:
job.save()
@@ -68,6 +68,30 @@ def test_authentication_getter_existing_user_via_email(
assert user == db_user
def test_authentication_getter_existing_user_via_email_case_insensitive(
django_assert_num_queries, monkeypatch
):
"""
If an existing user doesn't match the sub but matches the email with different case,
the user should be returned (case-insensitive email matching).
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory(email="john.doe@example.com")
def get_userinfo_mocked(*args):
return {"sub": "123", "email": "JOHN.DOE@EXAMPLE.COM"}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(4): # user by sub, user by mail, update sub
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == db_user
def test_authentication_getter_email_none(monkeypatch):
"""
If no user is found with the sub and no email is provided, a new user should be created.
@@ -157,6 +181,39 @@ def test_authentication_getter_existing_user_no_fallback_to_email_no_duplicate(
assert models.User.objects.count() == 1
def test_authentication_getter_existing_user_no_fallback_to_email_no_duplicate_case_insensitive(
settings, monkeypatch
):
"""
When the "OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION" setting is set to False,
the system should detect duplicate emails even with different case.
"""
klass = OIDCAuthenticationBackend()
_db_user = UserFactory(email="john.doe@example.com")
# Set the setting to False
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
settings.OIDC_ALLOW_DUPLICATE_EMAILS = False
def get_userinfo_mocked(*args):
return {"sub": "123", "email": "JOHN.DOE@EXAMPLE.COM"}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with pytest.raises(
SuspiciousOperation,
match=(
"We couldn't find a user with this sub but the email is already associated "
"with a registered user."
),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
# Since the sub doesn't match, it should not create a new user
assert models.User.objects.count() == 1
def test_authentication_getter_existing_user_with_email(
django_assert_num_queries, monkeypatch
):
@@ -0,0 +1,6 @@
active_email,inactive_email,active_email_checked,inactive_email_checked,status,id
"user.test40@example.com","user.test41@example.com",0,0,pending,1
"user.test42@example.com","user.test43@example.com",0,1,pending,2
"user.test44@example.com","user.test45@example.com",1,0,pending,3
"user.test46@example.com","user.test47@example.com",1,1,pending,4
"user.test48@example.com","user.test49@example.com",1,1,pending,5
1 active_email inactive_email active_email_checked inactive_email_checked status id
2 user.test40@example.com user.test41@example.com 0 0 pending 1
3 user.test42@example.com user.test43@example.com 0 1 pending 2
4 user.test44@example.com user.test45@example.com 1 0 pending 3
5 user.test46@example.com user.test47@example.com 1 1 pending 4
6 user.test48@example.com user.test49@example.com 1 1 pending 5
@@ -0,0 +1,2 @@
active_email,inactive_email,active_email_checked,inactive_email_checked,status,id
"user.test40@example.com",,0,0,pending,40
1 active_email inactive_email active_email_checked inactive_email_checked status id
2 user.test40@example.com 0 0 pending 40
@@ -0,0 +1,5 @@
merge_accept,active_email,inactive_email,status,id
true,user.test10@example.com,user.test11@example.com|user.test12@example.com,pending,10
true,user.test30@example.com,user.test31@example.com|user.test32@example.com|user.test33@example.com|user.test34@example.com|user.test35@example.com,pending,11
true,user.test20@example.com,user.test21@example.com,pending,12
true,user.test22@example.com,user.test23@example.com,pending,13
1 merge_accept active_email inactive_email status id
2 true user.test10@example.com user.test11@example.com|user.test12@example.com pending 10
3 true user.test30@example.com user.test31@example.com|user.test32@example.com|user.test33@example.com|user.test34@example.com|user.test35@example.com pending 11
4 true user.test20@example.com user.test21@example.com pending 12
5 true user.test22@example.com user.test23@example.com pending 13
@@ -0,0 +1,2 @@
merge_accept,active_email,inactive_email,status,id
true,user.test20@example.com,user.test20@example.com,pending,20
1 merge_accept active_email inactive_email status id
2 true user.test20@example.com user.test20@example.com pending 20
@@ -0,0 +1,6 @@
active_email,inactive_email,active_email_checked,inactive_email_checked,status
"user.test40@example.com","user.test41@example.com",0,0,pending
"user.test42@example.com","user.test43@example.com",0,1,pending
"user.test44@example.com","user.test45@example.com",1,0,pending
"user.test46@example.com","user.test47@example.com",1,1,pending
"user.test48@example.com","user.test49@example.com",1,1,pending
1 active_email inactive_email active_email_checked inactive_email_checked status
2 user.test40@example.com user.test41@example.com 0 0 pending
3 user.test42@example.com user.test43@example.com 0 1 pending
4 user.test44@example.com user.test45@example.com 1 0 pending
5 user.test46@example.com user.test47@example.com 1 1 pending
6 user.test48@example.com user.test49@example.com 1 1 pending
@@ -596,6 +596,38 @@ def test_api_document_invitations_create_cannot_invite_existing_users():
}
def test_api_item_invitations_create_cannot_invite_existing_users_case_insensitive():
"""
It should not be possible to invite already existing users, even with different email case.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, "owner")])
existing_user = factories.UserFactory()
# Build an invitation to the email of an existing identity with different case
invitation_values = {
"email": existing_user.email.upper(),
"role": random.choice(models.RoleChoices.values),
}
client = APIClient()
client.force_login(user)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == 400
assert response.json() == {
"email": ["This email is already associated to a registered user."]
}
def test_api_document_invitations_create_lower_email():
"""
No matter the case, the email should be converted to lowercase.
@@ -0,0 +1,387 @@
"""
Test AI proxy API endpoint for users in impress's core app.
"""
import random
from unittest.mock import patch
from django.test import override_settings
import pytest
from rest_framework.test import APIClient
from core import factories
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
@pytest.fixture(autouse=True)
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.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
@override_settings(
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
)
@pytest.mark.parametrize(
"reach, role",
[
("restricted", "reader"),
("restricted", "editor"),
("authenticated", "reader"),
("authenticated", "editor"),
("public", "reader"),
],
)
def test_api_documents_ai_proxy_anonymous_forbidden(reach, role):
"""
Anonymous users should not be able to request AI proxy if the link reach
and role don't allow it.
"""
document = factories.DocumentFactory(link_reach=reach, link_role=role)
url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/"
response = APIClient().post(
url,
{
"messages": [{"role": "user", "content": "Hello"}],
},
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
@override_settings(AI_ALLOW_REACH_FROM="public")
@patch("core.services.ai_services.AIService.stream")
def test_api_documents_ai_proxy_anonymous_success(mock_stream):
"""
Anonymous users should be able to request AI proxy to a document
if the link reach and role permit it.
"""
document = factories.DocumentFactory(link_reach="public", link_role="editor")
mock_stream.return_value = iter(["data: chunk1\n", "data: chunk2\n"])
url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/"
response = APIClient().post(
url,
b"{}",
content_type="application/json",
)
assert response.status_code == 200
assert response["Content-Type"] == "text/event-stream"
assert response["x-vercel-ai-data-stream"] == "v1"
assert response["X-Accel-Buffering"] == "no"
content = b"".join(response.streaming_content).decode()
assert "chunk1" in content
assert "chunk2" in content
mock_stream.assert_called_once()
@override_settings(AI_ALLOW_REACH_FROM=random.choice(["authenticated", "restricted"]))
def test_api_documents_ai_proxy_anonymous_limited_by_setting():
"""
Anonymous users should not be able to request AI proxy to a document
if AI_ALLOW_REACH_FROM setting restricts it.
"""
document = factories.DocumentFactory(link_reach="public", link_role="editor")
url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/"
response = APIClient().post(
url,
b"{}",
content_type="application/json",
)
assert response.status_code == 401
@pytest.mark.parametrize(
"reach, role",
[
("restricted", "reader"),
("restricted", "editor"),
("authenticated", "reader"),
("public", "reader"),
],
)
def test_api_documents_ai_proxy_authenticated_forbidden(reach, role):
"""
Users who are not related to a document can't request AI proxy if the
link reach and role don't allow it.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach, link_role=role)
url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/"
response = client.post(
url,
b"{}",
content_type="application/json",
)
assert response.status_code == 403
@pytest.mark.parametrize(
"reach, role",
[
("authenticated", "editor"),
("public", "editor"),
],
)
@patch("core.services.ai_services.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
if the link reach and role permit it.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach, link_role=role)
mock_stream.return_value = iter(["data: response\n"])
url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/"
response = client.post(
url,
b"{}",
content_type="application/json",
)
assert response.status_code == 200
assert response["Content-Type"] == "text/event-stream"
mock_stream.assert_called_once()
@pytest.mark.parametrize("via", VIA)
def test_api_documents_ai_proxy_reader(via, mock_user_teams):
"""Users with reader access should not be able to request AI proxy."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted")
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="reader")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="reader"
)
url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/"
response = client.post(
url,
b"{}",
content_type="application/json",
)
assert response.status_code == 403
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
@pytest.mark.parametrize("via", VIA)
@patch("core.services.ai_services.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()
client = APIClient()
client.force_login(user)
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", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
mock_stream.return_value = iter(["data: success\n"])
url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/"
response = client.post(
url,
b"{}",
content_type="application/json",
)
assert response.status_code == 200
assert response["Content-Type"] == "text/event-stream"
assert response["x-vercel-ai-data-stream"] == "v1"
assert response["X-Accel-Buffering"] == "no"
content = b"".join(response.streaming_content).decode()
assert "success" in content
mock_stream.assert_called_once()
@pytest.mark.parametrize(
"setting_to_disable", ["AI_FEATURE_ENABLED", "AI_FEATURE_BLOCKNOTE_ENABLED"]
)
def test_api_documents_ai_proxy_ai_feature_disabled(settings, setting_to_disable):
"""When AI_FEATURE_ENABLED is False, the endpoint returns 400."""
setattr(settings, setting_to_disable, False)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="public", link_role="editor")
response = client.post(
f"/api/v1.0/documents/{document.id!s}/ai-proxy/",
b"{}",
content_type="application/json",
)
assert response.status_code == 400
assert response.json() == ["AI feature is not enabled."]
@override_settings(AI_DOCUMENT_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
@patch("core.services.ai_services.AIService.stream")
def test_api_documents_ai_proxy_throttling_document(mock_stream):
"""
Throttling per document should be triggered on the AI proxy endpoint.
For full throttle class test see: `test_api_utils_ai_document_rate_throttles`
"""
client = APIClient()
document = factories.DocumentFactory(link_reach="public", link_role="editor")
mock_stream.return_value = iter(["data: ok\n"])
url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/"
for _ in range(3):
mock_stream.return_value = iter(["data: ok\n"])
user = factories.UserFactory()
client.force_login(user)
response = client.post(
url,
b"{}",
content_type="application/json",
)
assert response.status_code == 200
user = factories.UserFactory()
client.force_login(user)
response = client.post(
url,
b"{}",
content_type="application/json",
)
assert response.status_code == 429
assert response.json() == {
"detail": "Request was throttled. Expected available in 60 seconds."
}
@override_settings(AI_USER_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
@patch("core.services.ai_services.AIService.stream")
def test_api_documents_ai_proxy_throttling_user(mock_stream):
"""
Throttling per user should be triggered on the AI proxy endpoint.
For full throttle class test see: `test_api_utils_ai_user_rate_throttles`
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
for _ in range(3):
mock_stream.return_value = iter(["data: ok\n"])
document = factories.DocumentFactory(link_reach="public", link_role="editor")
url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/"
response = client.post(
url,
b"{}",
content_type="application/json",
)
assert response.status_code == 200
document = factories.DocumentFactory(link_reach="public", link_role="editor")
url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/"
response = client.post(
url,
b"{}",
content_type="application/json",
)
assert response.status_code == 429
assert response.json() == {
"detail": "Request was throttled. Expected available in 60 seconds."
}
@patch("core.services.ai_services.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()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="public", link_role="editor")
mock_stream.return_value = iter(["data: part1\n", "data: part2\n", "data: part3\n"])
url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/"
response = client.post(
url,
b"{}",
content_type="application/json",
)
assert response.status_code == 200
assert response["Content-Type"] == "text/event-stream"
assert response["x-vercel-ai-data-stream"] == "v1"
assert response["X-Accel-Buffering"] == "no"
chunks = list(response.streaming_content)
assert len(chunks) == 3
def test_api_documents_ai_proxy_invalid_payload():
"""AI Proxy should return a 400 if the payload is invalid."""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, "owner")])
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/ai-proxy/",
b'{"foo": "bar", "trigger": "submit-message"}',
content_type="application/json",
)
assert response.status_code == 400
assert response.json() == {"detail": "Invalid submitted payload"}
@@ -318,3 +318,424 @@ def test_api_documents_duplicate_reader_non_root_document():
assert duplicated_document.is_root()
assert duplicated_document.accesses.count() == 1
assert duplicated_document.accesses.get(user=user).role == "owner"
def test_api_documents_duplicate_with_descendants_simple():
"""
Duplicating a document with descendants flag should recursively duplicate all children.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Create document tree
root = factories.DocumentFactory(
users=[(user, "owner")],
title="Root Document",
)
child1 = factories.DocumentFactory(
parent=root,
title="Child 1",
)
child2 = factories.DocumentFactory(
parent=root,
title="Child 2",
)
initial_count = models.Document.objects.count()
assert initial_count == 3
# Duplicate with descendants
response = client.post(
f"/api/v1.0/documents/{root.id!s}/duplicate/",
{"with_descendants": True},
format="json",
)
assert response.status_code == 201
duplicated_root = models.Document.objects.get(id=response.json()["id"])
# Check that all documents were duplicated (6 total: 3 original + 3 duplicated)
assert models.Document.objects.count() == 6
# Check root duplication
assert duplicated_root.title == "Copy of Root Document"
assert duplicated_root.creator == user
assert duplicated_root.duplicated_from == root
assert duplicated_root.get_children().count() == 2
# Check children duplication
duplicated_children = duplicated_root.get_children().order_by("title")
assert duplicated_children.count() == 2
duplicated_child1 = duplicated_children.first()
assert duplicated_child1.title == "Copy of Child 1"
assert duplicated_child1.creator == user
assert duplicated_child1.duplicated_from == child1
assert duplicated_child1.get_parent() == duplicated_root
duplicated_child2 = duplicated_children.last()
assert duplicated_child2.title == "Copy of Child 2"
assert duplicated_child2.creator == user
assert duplicated_child2.duplicated_from == child2
assert duplicated_child2.get_parent() == duplicated_root
def test_api_documents_duplicate_with_descendants_multi_level():
"""
Duplicating should recursively handle multiple levels of nesting.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
root = factories.DocumentFactory(
users=[(user, "owner")],
title="Level 0",
)
child = factories.DocumentFactory(
parent=root,
title="Level 1",
)
grandchild = factories.DocumentFactory(
parent=child,
title="Level 2",
)
great_grandchild = factories.DocumentFactory(
parent=grandchild,
title="Level 3",
)
initial_count = models.Document.objects.count()
assert initial_count == 4
# Duplicate with descendants
response = client.post(
f"/api/v1.0/documents/{root.id!s}/duplicate/",
{"with_descendants": True},
format="json",
)
assert response.status_code == 201
duplicated_root = models.Document.objects.get(id=response.json()["id"])
# Check that all documents were duplicated
assert models.Document.objects.count() == 8
# Verify the tree structure
assert duplicated_root.depth == root.depth
dup_children = duplicated_root.get_children()
assert dup_children.count() == 1
dup_child = dup_children.first()
assert dup_child.title == "Copy of Level 1"
assert dup_child.duplicated_from == child
dup_grandchildren = dup_child.get_children()
assert dup_grandchildren.count() == 1
dup_grandchild = dup_grandchildren.first()
assert dup_grandchild.title == "Copy of Level 2"
assert dup_grandchild.duplicated_from == grandchild
dup_great_grandchildren = dup_grandchild.get_children()
assert dup_great_grandchildren.count() == 1
dup_great_grandchild = dup_great_grandchildren.first()
assert dup_great_grandchild.title == "Copy of Level 3"
assert dup_great_grandchild.duplicated_from == great_grandchild
def test_api_documents_duplicate_with_descendants_and_attachments():
"""
Duplicating with descendants should properly handle attachments in all children.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Create documents with attachments
root_id = uuid.uuid4()
child_id = uuid.uuid4()
image_key_root, image_url_root = get_image_refs(root_id)
image_key_child, image_url_child = get_image_refs(child_id)
# Create root document with attachment
ydoc = pycrdt.Doc()
fragment = pycrdt.XmlFragment(
[
pycrdt.XmlElement("img", {"src": image_url_root}),
]
)
ydoc["document-store"] = fragment
update = ydoc.get_update()
root_content = base64.b64encode(update).decode("utf-8")
root = factories.DocumentFactory(
id=root_id,
users=[(user, "owner")],
title="Root with Image",
content=root_content,
attachments=[image_key_root],
)
# Create child with different attachment
ydoc_child = pycrdt.Doc()
fragment_child = pycrdt.XmlFragment(
[
pycrdt.XmlElement("img", {"src": image_url_child}),
]
)
ydoc_child["document-store"] = fragment_child
update_child = ydoc_child.get_update()
child_content = base64.b64encode(update_child).decode("utf-8")
child = factories.DocumentFactory(
id=child_id,
parent=root,
title="Child with Image",
content=child_content,
attachments=[image_key_child],
)
# Duplicate with descendants
response = client.post(
f"/api/v1.0/documents/{root.id!s}/duplicate/",
{"with_descendants": True},
format="json",
)
assert response.status_code == 201
duplicated_root = models.Document.objects.get(id=response.json()["id"])
# Check root attachments
assert duplicated_root.attachments == [image_key_root]
assert duplicated_root.content == root_content
# Check child attachments
dup_children = duplicated_root.get_children()
assert dup_children.count() == 1
dup_child = dup_children.first()
assert dup_child.attachments == [image_key_child]
assert dup_child.content == child_content
def test_api_documents_duplicate_with_descendants_and_accesses():
"""
Duplicating with descendants and accesses should propagate accesses to all children.
"""
user = factories.UserFactory()
other_user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Create document tree with accesses
root = factories.DocumentFactory(
users=[(user, "owner"), (other_user, "editor")],
title="Root",
)
child = factories.DocumentFactory(
parent=root,
title="Child",
)
factories.UserDocumentAccessFactory(document=child, user=other_user, role="reader")
# Duplicate with descendants and accesses
response = client.post(
f"/api/v1.0/documents/{root.id!s}/duplicate/",
{"with_descendants": True, "with_accesses": True},
format="json",
)
assert response.status_code == 201
duplicated_root = models.Document.objects.get(id=response.json()["id"])
# Check root accesses (should be duplicated)
root_accesses = duplicated_root.accesses.order_by("user_id")
assert root_accesses.count() == 2
assert root_accesses.get(user=user).role == "owner"
assert root_accesses.get(user=other_user).role == "editor"
# Check child accesses (should be duplicated)
dup_children = duplicated_root.get_children()
dup_child = dup_children.first()
child_accesses = dup_child.accesses.order_by("user_id")
assert child_accesses.count() == 1
assert child_accesses.get(user=other_user).role == "reader"
@pytest.mark.parametrize("role", ["editor", "reader"])
def test_api_documents_duplicate_with_descendants_non_root_document_becomes_root(role):
"""
When duplicating a non-root document with descendants as a reader/editor,
it should become a root document and still duplicate its children.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
parent = factories.DocumentFactory(users=[(user, "owner")])
child = factories.DocumentFactory(
parent=parent,
users=[(user, role)],
title="Sub Document",
)
grandchild = factories.DocumentFactory(
parent=child,
title="Grandchild",
)
assert child.is_child_of(parent)
# Duplicate the child (non-root) with descendants
response = client.post(
f"/api/v1.0/documents/{child.id!s}/duplicate/",
{"with_descendants": True},
format="json",
)
assert response.status_code == 201
duplicated_child = models.Document.objects.get(id=response.json()["id"])
assert duplicated_child.title == "Copy of Sub Document"
dup_grandchildren = duplicated_child.get_children()
assert dup_grandchildren.count() == 1
dup_grandchild = dup_grandchildren.first()
assert dup_grandchild.title == "Copy of Grandchild"
assert dup_grandchild.duplicated_from == grandchild
def test_api_documents_duplicate_without_descendants_should_not_duplicate_children():
"""
When with_descendants is not set or False, children should not be duplicated.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Create document tree
root = factories.DocumentFactory(
users=[(user, "owner")],
title="Root",
)
child = factories.DocumentFactory(
parent=root,
title="Child",
)
initial_count = models.Document.objects.count()
assert initial_count == 2
# Duplicate without descendants (default behavior)
response = client.post(
f"/api/v1.0/documents/{root.id!s}/duplicate/",
format="json",
)
assert response.status_code == 201
duplicated_root = models.Document.objects.get(id=response.json()["id"])
# Only root should be duplicated, not children
assert models.Document.objects.count() == 3
assert duplicated_root.get_children().count() == 0
def test_api_documents_duplicate_with_descendants_preserves_link_configuration():
"""
Duplicating with descendants should preserve link configuration (link_reach, link_role)
for all children when with_accesses is True.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Create document tree with specific link configurations
root = factories.DocumentFactory(
users=[(user, "owner")],
title="Root",
link_reach="public",
link_role="reader",
)
child = factories.DocumentFactory(
parent=root,
title="Child",
link_reach="restricted",
link_role="editor",
)
# Duplicate with descendants and accesses
response = client.post(
f"/api/v1.0/documents/{root.id!s}/duplicate/",
{"with_descendants": True, "with_accesses": True},
format="json",
)
assert response.status_code == 201
duplicated_root = models.Document.objects.get(id=response.json()["id"])
# Check root link configuration
assert duplicated_root.link_reach == root.link_reach
assert duplicated_root.link_role == root.link_role
# Check child link configuration
dup_children = duplicated_root.get_children()
dup_child = dup_children.first()
assert dup_child.link_reach == child.link_reach
assert dup_child.link_role == child.link_role
def test_api_documents_duplicate_with_descendants_complex_tree():
"""
Test duplication of a complex tree structure with multiple branches.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Create a complex tree:
# root
# / \
# c1 c2
# / \ \
# gc1 gc2 gc3
root = factories.DocumentFactory(
users=[(user, "owner")],
title="Root",
)
child1 = factories.DocumentFactory(parent=root, title="Child 1")
child2 = factories.DocumentFactory(parent=root, title="Child 2")
_grandchild1 = factories.DocumentFactory(parent=child1, title="GrandChild 1")
_grandchild2 = factories.DocumentFactory(parent=child1, title="GrandChild 2")
_grandchild3 = factories.DocumentFactory(parent=child2, title="GrandChild 3")
initial_count = models.Document.objects.count()
assert initial_count == 6
# Duplicate with descendants
response = client.post(
f"/api/v1.0/documents/{root.id!s}/duplicate/",
{"with_descendants": True},
format="json",
)
assert response.status_code == 201
duplicated_root = models.Document.objects.get(id=response.json()["id"])
# All documents should be duplicated
assert models.Document.objects.count() == 12
# Check structure is preserved
dup_children = duplicated_root.get_children().order_by("title")
assert dup_children.count() == 2
dup_child1 = dup_children.first()
assert dup_child1.title == "Copy of Child 1"
dup_grandchildren1 = dup_child1.get_children().order_by("title")
assert dup_grandchildren1.count() == 2
assert dup_grandchildren1.first().title == "Copy of GrandChild 1"
assert dup_grandchildren1.last().title == "Copy of GrandChild 2"
dup_child2 = dup_children.last()
assert dup_child2.title == "Copy of Child 2"
dup_grandchildren2 = dup_child2.get_children()
assert dup_grandchildren2.count() == 1
assert dup_grandchildren2.first().title == "Copy of GrandChild 3"
@@ -29,6 +29,7 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"abilities": {
"accesses_manage": False,
"accesses_view": False,
"ai_proxy": False,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": document.link_role == "editor",
@@ -107,6 +108,7 @@ def test_api_documents_retrieve_anonymous_public_parent():
"abilities": {
"accesses_manage": False,
"accesses_view": False,
"ai_proxy": False,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": grand_parent.link_role == "editor",
@@ -215,6 +217,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"abilities": {
"accesses_manage": False,
"accesses_view": False,
"ai_proxy": document.link_role == "editor",
"ai_transform": document.link_role == "editor",
"ai_translate": document.link_role == "editor",
"attachment_upload": document.link_role == "editor",
@@ -300,6 +303,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
"abilities": {
"accesses_manage": False,
"accesses_view": False,
"ai_proxy": grand_parent.link_role == "editor",
"ai_transform": grand_parent.link_role == "editor",
"ai_translate": grand_parent.link_role == "editor",
"attachment_upload": grand_parent.link_role == "editor",
@@ -498,6 +502,7 @@ def test_api_documents_retrieve_authenticated_related_parent():
"abilities": {
"accesses_manage": access.role in ["administrator", "owner"],
"accesses_view": True,
"ai_proxy": access.role not in ["reader", "commenter"],
"ai_transform": access.role not in ["reader", "commenter"],
"ai_translate": access.role not in ["reader", "commenter"],
"attachment_upload": access.role not in ["reader", "commenter"],
@@ -1057,3 +1062,48 @@ 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."]
@@ -72,6 +72,7 @@ def test_api_documents_trashbin_format():
"abilities": {
"accesses_manage": False,
"accesses_view": False,
"ai_proxy": False,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
@@ -19,7 +19,11 @@ pytestmark = pytest.mark.django_db
@override_settings(
AI_BOT={"name": "Test Bot", "color": "#000000"},
AI_FEATURE_ENABLED=False,
AI_FEATURE_BLOCKNOTE_ENABLED=False,
AI_FEATURE_LEGACY_ENABLED=False,
API_USERS_SEARCH_QUERY_MIN_LENGTH=6,
COLLABORATION_WS_URL="http://testcollab/",
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY=True,
CRISP_WEBSITE_ID="123",
@@ -43,7 +47,11 @@ def test_api_config(is_authenticated):
response = client.get("/api/v1.0/config/")
assert response.status_code == HTTP_200_OK
assert response.json() == {
"AI_BOT": {"name": "Test Bot", "color": "#000000"},
"AI_FEATURE_ENABLED": False,
"AI_FEATURE_BLOCKNOTE_ENABLED": False,
"AI_FEATURE_LEGACY_ENABLED": False,
"API_USERS_SEARCH_QUERY_MIN_LENGTH": 6,
"COLLABORATION_WS_URL": "http://testcollab/",
"COLLABORATION_WS_NOT_CONNECTED_READY_ONLY": True,
"CONVERSION_FILE_EXTENSIONS_ALLOWED": [".docx", ".md"],
@@ -53,6 +61,7 @@ def test_api_config(is_authenticated):
"FRONTEND_CSS_URL": "http://testcss/",
"FRONTEND_HOMEPAGE_FEATURE_ENABLED": True,
"FRONTEND_JS_URL": "http://testjs/",
"FRONTEND_SILENT_LOGIN_ENABLED": False,
"FRONTEND_THEME": "test-theme",
"LANGUAGES": [
["en-us", "English"],
@@ -0,0 +1,85 @@
"""
Unit tests for the ReconciliationConfirmView API view.
"""
import uuid
from django.conf import settings
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
def test_reconciliation_confirm_view_sets_active_checked():
"""GETting the active confirmation endpoint should set active_email_checked."""
user = factories.UserFactory(email="user.confirm1@example.com")
other = factories.UserFactory(email="user.confirm2@example.com")
rec = models.UserReconciliation.objects.create(
active_email=user.email,
inactive_email=other.email,
active_user=user,
inactive_user=other,
active_email_checked=False,
inactive_email_checked=False,
status="ready",
)
client = APIClient()
conf_id = rec.active_email_confirmation_id
url = f"/api/{settings.API_VERSION}/user-reconciliations/active/{conf_id}/"
resp = client.get(url)
assert resp.status_code == 200
assert resp.json() == {"detail": "Confirmation received"}
rec.refresh_from_db()
assert rec.active_email_checked is True
def test_reconciliation_confirm_view_sets_inactive_checked():
"""GETting the inactive confirmation endpoint should set inactive_email_checked."""
user = factories.UserFactory(email="user.confirm3@example.com")
other = factories.UserFactory(email="user.confirm4@example.com")
rec = models.UserReconciliation.objects.create(
active_email=user.email,
inactive_email=other.email,
active_user=user,
inactive_user=other,
active_email_checked=False,
inactive_email_checked=False,
status="ready",
)
client = APIClient()
conf_id = rec.inactive_email_confirmation_id
url = f"/api/{settings.API_VERSION}/user-reconciliations/inactive/{conf_id}/"
resp = client.get(url)
assert resp.status_code == 200
assert resp.json() == {"detail": "Confirmation received"}
rec.refresh_from_db()
assert rec.inactive_email_checked is True
def test_reconciliation_confirm_view_invalid_user_type_returns_400():
"""GETting with an invalid user_type should return 400."""
client = APIClient()
# Use a valid uuid format but invalid user_type
url = f"/api/{settings.API_VERSION}/user-reconciliations/other/{uuid.uuid4()}/"
resp = client.get(url)
assert resp.status_code == 400
assert resp.json() == {"detail": "Invalid user_type"}
def test_reconciliation_confirm_view_not_found_returns_404():
"""GETting with a non-existing confirmation_id should return 404."""
client = APIClient()
url = f"/api/{settings.API_VERSION}/user-reconciliations/active/{uuid.uuid4()}/"
resp = client.get(url)
assert resp.status_code == 404
assert resp.json() == {"detail": "Reconciliation entry not found"}
+83 -23
View File
@@ -2,6 +2,8 @@
Test users API endpoints in the impress core app.
"""
from django.utils import timezone
import pytest
from rest_framework.test import APIClient
@@ -121,12 +123,12 @@ def test_api_users_list_query_full_name():
Authenticated users should be able to list users and filter by full name.
Only results with a Trigram similarity greater than 0.2 with the query should be returned.
"""
user = factories.UserFactory()
user = factories.UserFactory(email="user@example.com")
client = APIClient()
client.force_login(user)
dave = factories.UserFactory(email="contact@work.com", full_name="David Bowman")
dave = factories.UserFactory(email="contact@example.com", full_name="David Bowman")
response = client.get(
"/api/v1.0/users/?q=David",
@@ -166,13 +168,13 @@ def test_api_users_list_query_accented_full_name():
Authenticated users should be able to list users and filter by full name with accents.
Only results with a Trigram similarity greater than 0.2 with the query should be returned.
"""
user = factories.UserFactory()
user = factories.UserFactory(email="user@example.com")
client = APIClient()
client.force_login(user)
fred = factories.UserFactory(
email="contact@work.com", full_name="Frédérique Lefèvre"
email="contact@example.com", full_name="Frédérique Lefèvre"
)
response = client.get("/api/v1.0/users/?q=Frédérique")
@@ -201,12 +203,82 @@ def test_api_users_list_query_accented_full_name():
assert users == []
def test_api_users_list_sorted_by_closest_match():
"""
Authenticated users should be able to list users and the results should be
sorted by closest match to the query.
Sorting criteria are :
- Shared documents with the user (most recent first)
- Same full email domain (example.gouv.fr)
Addresses that match neither criteria should be excluded from the results.
Case in point: the logged-in user has recently shared documents
with pierre.dupont@beta.gouv.fr and less recently with pierre.durand@impots.gouv.fr.
Other users named Pierre also exist:
- pierre.thomas@example.com
- pierre.petit@anct.gouv.fr
- pierre.robert@culture.gouv.fr
The search results should be ordered as follows:
# Shared with first
- pierre.dupond@beta.gouv.fr # Most recent first
- pierre.durand@impots.gouv.fr
# Same full domain second
- pierre.petit@anct.gouv.fr
"""
user = factories.UserFactory(
email="martin.bernard@anct.gouv.fr", full_name="Martin Bernard"
)
client = APIClient()
client.force_login(user)
pierre_1 = factories.UserFactory(email="pierre.dupont@beta.gouv.fr")
pierre_2 = factories.UserFactory(email="pierre.durand@impots.gouv.fr")
_pierre_3 = factories.UserFactory(email="pierre.thomas@example.com")
pierre_4 = factories.UserFactory(email="pierre.petit@anct.gouv.fr")
_pierre_5 = factories.UserFactory(email="pierre.robert@culture.gouv.fr")
document_1 = factories.DocumentFactory(creator=user)
document_2 = factories.DocumentFactory(creator=user)
factories.UserDocumentAccessFactory(user=user, document=document_1)
factories.UserDocumentAccessFactory(user=user, document=document_2)
now = timezone.now()
last_week = now - timezone.timedelta(days=7)
last_month = now - timezone.timedelta(days=30)
# The factory cannot set the created_at directly, so we force it after creation
p1_d1 = factories.UserDocumentAccessFactory(user=pierre_1, document=document_1)
p1_d1.created_at = last_week
p1_d1.save()
p2_d2 = factories.UserDocumentAccessFactory(user=pierre_2, document=document_2)
p2_d2.created_at = last_month
p2_d2.save()
response = client.get("/api/v1.0/users/?q=Pierre")
assert response.status_code == 200
user_ids = [user["email"] for user in response.json()]
assert user_ids == [
str(pierre_1.email),
str(pierre_2.email),
str(pierre_4.email),
]
def test_api_users_list_limit(settings):
"""
Authenticated users should be able to list users and the number of results
should be limited to 10.
should be limited to API_USERS_LIST_LIMIT (by default 5).
"""
user = factories.UserFactory()
user = factories.UserFactory(email="user@example.com")
client = APIClient()
client.force_login(user)
@@ -309,28 +381,16 @@ def test_api_users_list_query_email_exclude_doc_user():
def test_api_users_list_query_short_queries():
"""
Queries shorter than 5 characters should return an empty result set.
If API_USERS_SEARCH_QUERY_MIN_LENGTH is not set, the default minimum length should be 3.
"""
user = factories.UserFactory(email="paul@example.com", full_name="Paul")
client = APIClient()
client.force_login(user)
factories.UserFactory(email="john.doe@example.com")
factories.UserFactory(email="john.lennon@example.com")
factories.UserFactory(email="john.doe@example.com", full_name="John Doe")
factories.UserFactory(email="john.lennon@example.com", full_name="John Lennon")
response = client.get("/api/v1.0/users/?q=jo")
assert response.status_code == 400
assert response.json() == {
"q": ["Ensure this value has at least 5 characters (it has 2)."]
}
response = client.get("/api/v1.0/users/?q=john")
assert response.status_code == 400
assert response.json() == {
"q": ["Ensure this value has at least 5 characters (it has 4)."]
}
response = client.get("/api/v1.0/users/?q=john.")
response = client.get("/api/v1.0/users/?q=joh")
assert response.status_code == 200
assert len(response.json()) == 2
@@ -356,7 +416,7 @@ def test_api_users_list_query_long_queries():
def test_api_users_list_query_inactive():
"""Inactive users should not be listed."""
user = factories.UserFactory()
user = factories.UserFactory(email="user@example.com")
client = APIClient()
client.force_login(user)
@@ -155,6 +155,7 @@ def test_models_documents_get_abilities_forbidden(
expected_abilities = {
"accesses_manage": False,
"accesses_view": False,
"ai_proxy": False,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
@@ -220,6 +221,7 @@ def test_models_documents_get_abilities_reader(
expected_abilities = {
"accesses_manage": False,
"accesses_view": False,
"ai_proxy": False,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
@@ -290,6 +292,7 @@ def test_models_documents_get_abilities_commenter(
expected_abilities = {
"accesses_manage": False,
"accesses_view": False,
"ai_proxy": False,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
@@ -357,6 +360,7 @@ def test_models_documents_get_abilities_editor(
expected_abilities = {
"accesses_manage": False,
"accesses_view": False,
"ai_proxy": is_authenticated,
"ai_transform": is_authenticated,
"ai_translate": is_authenticated,
"attachment_upload": True,
@@ -413,6 +417,7 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
expected_abilities = {
"accesses_manage": True,
"accesses_view": True,
"ai_proxy": True,
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
@@ -455,6 +460,7 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
assert document.get_abilities(user) == {
"accesses_manage": False,
"accesses_view": False,
"ai_proxy": False,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
@@ -501,6 +507,7 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
expected_abilities = {
"accesses_manage": True,
"accesses_view": True,
"ai_proxy": True,
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
@@ -557,6 +564,7 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
expected_abilities = {
"accesses_manage": False,
"accesses_view": True,
"ai_proxy": True,
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
@@ -620,6 +628,7 @@ def test_models_documents_get_abilities_reader_user(
"accesses_view": True,
# If you get your editor rights from the link role and not your access role
# You should not access AI if it's restricted to users with specific access
"ai_proxy": access_from_link and ai_access_setting != "restricted",
"ai_transform": access_from_link and ai_access_setting != "restricted",
"ai_translate": access_from_link and ai_access_setting != "restricted",
"attachment_upload": access_from_link,
@@ -686,6 +695,7 @@ def test_models_documents_get_abilities_commenter_user(
"accesses_view": True,
# If you get your editor rights from the link role and not your access role
# You should not access AI if it's restricted to users with specific access
"ai_proxy": access_from_link and ai_access_setting != "restricted",
"ai_transform": access_from_link and ai_access_setting != "restricted",
"ai_translate": access_from_link and ai_access_setting != "restricted",
"attachment_upload": access_from_link,
@@ -747,6 +757,7 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
assert abilities == {
"accesses_manage": False,
"accesses_view": True,
"ai_proxy": False,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
@@ -878,6 +889,7 @@ def test_models_document_get_abilities_ai_access_authenticated(is_authenticated,
document = factories.DocumentFactory(link_reach=reach, link_role="editor")
abilities = document.get_abilities(user)
assert abilities["ai_proxy"] is True
assert abilities["ai_transform"] is True
assert abilities["ai_translate"] is True
@@ -897,6 +909,7 @@ def test_models_document_get_abilities_ai_access_public(is_authenticated, reach)
document = factories.DocumentFactory(link_reach=reach, link_role="editor")
abilities = document.get_abilities(user)
assert abilities["ai_proxy"] == is_authenticated
assert abilities["ai_transform"] == is_authenticated
assert abilities["ai_translate"] == is_authenticated
@@ -1021,7 +1034,10 @@ def test_models_documents__email_invitation__success():
f"Test Sender (sender@example.com) invited you with the role &quot;editor&quot; "
f"on the following document: {document.title}" in email_content
)
assert f"docs/{document.id}/" in email_content
assert (
f"docs/{document.id}/?utm_source=docssharelink&amp;utm_campaign={document.id}"
in email_content
)
@pytest.mark.parametrize(
@@ -1051,10 +1067,18 @@ def test_models_documents__email_invitation__url_app_param(email_url_app):
# Determine expected domain
if email_url_app:
assert f"https://test-example.com/docs/{document.id}/" in email_content
expected_url = (
f"https://test-example.com/docs/{document.id}/"
f"?utm_source=docssharelink&amp;utm_campaign={document.id}"
)
assert expected_url in email_content
else:
# Default Site domain is example.com
assert f"example.com/docs/{document.id}/" in email_content
expected_url = (
f"example.com/docs/{document.id}/"
f"?utm_source=docssharelink&amp;utm_campaign={document.id}"
)
assert expected_url in email_content
def test_models_documents__email_invitation__success_empty_title():
@@ -1085,7 +1109,10 @@ def test_models_documents__email_invitation__success_empty_title():
"Test Sender (sender@example.com) invited you with the role &quot;editor&quot; "
"on the following document: Untitled Document" in email_content
)
assert f"docs/{document.id}/" in email_content
assert (
f"docs/{document.id}/?utm_source=docssharelink&amp;utm_campaign={document.id}"
in email_content
)
def test_models_documents__email_invitation__success_fr():
@@ -1120,7 +1147,10 @@ def test_models_documents__email_invitation__success_fr():
f"Test Sender2 (sender2@example.com) vous a invité avec le rôle &quot;propriétaire&quot; "
f"sur le document suivant : {document.title}" in email_content
)
assert f"docs/{document.id}/" in email_content
assert (
f"docs/{document.id}/?utm_source=docssharelink&amp;utm_campaign={document.id}"
in email_content
)
@mock.patch(
@@ -0,0 +1,669 @@
"""
Unit tests for the UserReconciliationCsvImport model
"""
import uuid
from pathlib import Path
from django.core import mail
from django.core.files.base import ContentFile
import pytest
from core import factories, models
from core.admin import process_reconciliation
from core.tasks.user_reconciliation import user_reconciliation_csv_import_job
pytestmark = pytest.mark.django_db
@pytest.fixture(name="import_example_csv_basic")
def fixture_import_example_csv_basic():
"""
Import an example CSV file for user reconciliation
and return the created import object.
"""
# Create users referenced in the CSV
for i in range(40, 50):
factories.UserFactory(email=f"user.test{i}@example.com")
example_csv_path = Path(__file__).parent / "data/example_reconciliation_basic.csv"
with open(example_csv_path, "rb") as f:
csv_file = ContentFile(f.read(), name="example_reconciliation_basic.csv")
csv_import = models.UserReconciliationCsvImport(file=csv_file)
csv_import.save()
return csv_import
@pytest.fixture(name="import_example_csv_grist_form")
def fixture_import_example_csv_grist_form():
"""
Import an example CSV file for user reconciliation
and return the created import object.
"""
# Create users referenced in the CSV
for i in range(10, 40):
factories.UserFactory(email=f"user.test{i}@example.com")
example_csv_path = (
Path(__file__).parent / "data/example_reconciliation_grist_form.csv"
)
with open(example_csv_path, "rb") as f:
csv_file = ContentFile(f.read(), name="example_reconciliation_grist_form.csv")
csv_import = models.UserReconciliationCsvImport(file=csv_file)
csv_import.save()
return csv_import
def test_user_reconciliation_csv_import_entry_is_created(import_example_csv_basic):
"""Test that a UserReconciliationCsvImport entry is created correctly."""
assert import_example_csv_basic.status == "pending"
assert import_example_csv_basic.file.name.endswith(
"example_reconciliation_basic.csv"
)
def test_user_reconciliation_csv_import_entry_is_created_grist_form(
import_example_csv_grist_form,
):
"""Test that a UserReconciliationCsvImport entry is created correctly."""
assert import_example_csv_grist_form.status == "pending"
assert import_example_csv_grist_form.file.name.endswith(
"example_reconciliation_grist_form.csv"
)
def test_incorrect_csv_format_handling():
"""Test that an incorrectly formatted CSV file is handled gracefully."""
example_csv_path = (
Path(__file__).parent / "data/example_reconciliation_missing_column.csv"
)
with open(example_csv_path, "rb") as f:
csv_file = ContentFile(
f.read(), name="example_reconciliation_missing_column.csv"
)
csv_import = models.UserReconciliationCsvImport(file=csv_file)
csv_import.save()
assert csv_import.status == "pending"
user_reconciliation_csv_import_job(csv_import.id)
csv_import.refresh_from_db()
assert (
"CSV is missing mandatory columns: active_email, inactive_email, id"
in csv_import.logs
)
assert csv_import.status == "error"
def test_incorrect_email_format_handling():
"""Test that an incorrectly formatted CSV file is handled gracefully."""
example_csv_path = Path(__file__).parent / "data/example_reconciliation_error.csv"
with open(example_csv_path, "rb") as f:
csv_file = ContentFile(f.read(), name="example_reconciliation_error.csv")
csv_import = models.UserReconciliationCsvImport(file=csv_file)
csv_import.save()
assert csv_import.status == "pending"
user_reconciliation_csv_import_job(csv_import.id)
csv_import.refresh_from_db()
assert "Invalid inactive email address on row 40" in csv_import.logs
assert csv_import.status == "done"
# pylint: disable-next=no-member
assert len(mail.outbox) == 1
# pylint: disable-next=no-member
email = mail.outbox[0]
assert email.to == ["user.test40@example.com"]
email_content = " ".join(email.body.split())
assert "Reconciliation of your Docs accounts not completed" in email_content
def test_incorrect_csv_data_handling_grist_form():
"""Test that a CSV file with incorrect data is handled gracefully."""
example_csv_path = (
Path(__file__).parent / "data/example_reconciliation_grist_form_error.csv"
)
with open(example_csv_path, "rb") as f:
csv_file = ContentFile(
f.read(), name="example_reconciliation_grist_form_error.csv"
)
csv_import = models.UserReconciliationCsvImport(file=csv_file)
csv_import.save()
assert csv_import.status == "pending"
user_reconciliation_csv_import_job(csv_import.id)
csv_import.refresh_from_db()
assert (
"user.test20@example.com set as both active and inactive email"
in csv_import.logs
)
assert csv_import.status == "done"
def test_job_creates_reconciliation_entries(import_example_csv_basic):
"""Test that the CSV import job creates UserReconciliation entries."""
assert import_example_csv_basic.status == "pending"
user_reconciliation_csv_import_job(import_example_csv_basic.id)
# Verify the job status changed
import_example_csv_basic.refresh_from_db()
assert import_example_csv_basic.status == "done"
assert "Import completed successfully." in import_example_csv_basic.logs
assert "6 rows processed." in import_example_csv_basic.logs
assert "5 reconciliation entries created." in import_example_csv_basic.logs
# Verify reconciliation entries were created
reconciliations = models.UserReconciliation.objects.all()
assert reconciliations.count() == 5
def test_job_does_not_create_duplicated_reconciliation_entries(
import_example_csv_basic,
):
"""Test that the CSV import job doesn't create UserReconciliation entries
for source unique IDs that have already been processed."""
_already_created_entry = models.UserReconciliation.objects.create(
active_email="user.test40@example.com",
inactive_email="user.test41@example.com",
active_email_checked=0,
inactive_email_checked=0,
status="pending",
source_unique_id=1,
)
assert import_example_csv_basic.status == "pending"
user_reconciliation_csv_import_job(import_example_csv_basic.id)
# Verify the job status changed
import_example_csv_basic.refresh_from_db()
assert import_example_csv_basic.status == "done"
assert "Import completed successfully." in import_example_csv_basic.logs
assert "6 rows processed." in import_example_csv_basic.logs
assert "4 reconciliation entries created." in import_example_csv_basic.logs
assert "1 rows were already processed." in import_example_csv_basic.logs
# Verify the correct number of reconciliation entries were created
reconciliations = models.UserReconciliation.objects.all()
assert reconciliations.count() == 5
def test_job_creates_reconciliation_entries_grist_form(import_example_csv_grist_form):
"""Test that the CSV import job creates UserReconciliation entries."""
assert import_example_csv_grist_form.status == "pending"
user_reconciliation_csv_import_job(import_example_csv_grist_form.id)
# Verify the job status changed
import_example_csv_grist_form.refresh_from_db()
assert "Import completed successfully" in import_example_csv_grist_form.logs
assert import_example_csv_grist_form.status == "done"
# Verify reconciliation entries were created
reconciliations = models.UserReconciliation.objects.all()
assert reconciliations.count() == 9
def test_csv_import_reconciliation_data_is_correct(import_example_csv_basic):
"""Test that the data in created UserReconciliation entries matches the CSV."""
user_reconciliation_csv_import_job(import_example_csv_basic.id)
reconciliations = models.UserReconciliation.objects.order_by("created_at")
first_entry = reconciliations.first()
assert first_entry.active_email == "user.test40@example.com"
assert first_entry.inactive_email == "user.test41@example.com"
assert first_entry.active_email_checked is False
assert first_entry.inactive_email_checked is False
for rec in reconciliations:
assert rec.status == "ready"
@pytest.fixture(name="user_reconciliation_users_and_docs")
def fixture_user_reconciliation_users_and_docs():
"""Fixture to create two users with overlapping document accesses
for reconciliation tests."""
user_1 = factories.UserFactory(email="user.test1@example.com")
user_2 = factories.UserFactory(email="user.test2@example.com")
# Create 10 distinct document accesses for each user
userdocs_u1 = [
factories.UserDocumentAccessFactory(user=user_1, role="editor")
for _ in range(10)
]
userdocs_u2 = [
factories.UserDocumentAccessFactory(user=user_2, role="editor")
for _ in range(10)
]
# Make the first 3 documents of each list shared with the other user
# with a lower role
for ud in userdocs_u1[0:3]:
factories.UserDocumentAccessFactory(
user=user_2, document=ud.document, role="reader"
)
for ud in userdocs_u2[0:3]:
factories.UserDocumentAccessFactory(
user=user_1, document=ud.document, role="reader"
)
# Make the next 3 documents of each list shared with the other user
# with a higher role
for ud in userdocs_u1[3:6]:
factories.UserDocumentAccessFactory(
user=user_2, document=ud.document, role="owner"
)
for ud in userdocs_u2[3:6]:
factories.UserDocumentAccessFactory(
user=user_1, document=ud.document, role="owner"
)
return (user_1, user_2, userdocs_u1, userdocs_u2)
def test_user_reconciliation_is_created(user_reconciliation_users_and_docs):
"""Test that a UserReconciliation entry can be created and saved."""
user_1, user_2, _userdocs_u1, _userdocs_u2 = user_reconciliation_users_and_docs
rec = models.UserReconciliation.objects.create(
active_email=user_1.email,
inactive_email=user_2.email,
active_email_checked=False,
inactive_email_checked=True,
active_email_confirmation_id=uuid.uuid4(),
inactive_email_confirmation_id=uuid.uuid4(),
status="pending",
)
rec.save()
assert rec.status == "ready"
def test_user_reconciliation_verification_emails_are_sent(
user_reconciliation_users_and_docs,
):
"""Test that both UserReconciliation verification emails are sent."""
user_1, user_2, _userdocs_u1, _userdocs_u2 = user_reconciliation_users_and_docs
rec = models.UserReconciliation.objects.create(
active_email=user_1.email,
inactive_email=user_2.email,
active_email_checked=False,
inactive_email_checked=False,
active_email_confirmation_id=uuid.uuid4(),
inactive_email_confirmation_id=uuid.uuid4(),
status="pending",
)
rec.save()
# pylint: disable-next=no-member
assert len(mail.outbox) == 2
# pylint: disable-next=no-member
email_1 = mail.outbox[0]
assert email_1.to == [user_1.email]
email_1_content = " ".join(email_1.body.split())
assert (
"You have requested a reconciliation of your user accounts on Docs."
in email_1_content
)
active_email_confirmation_id = rec.active_email_confirmation_id
inactive_email_confirmation_id = rec.inactive_email_confirmation_id
assert (
f"user-reconciliations/active/{active_email_confirmation_id}/"
in email_1_content
)
# pylint: disable-next=no-member
email_2 = mail.outbox[1]
assert email_2.to == [user_2.email]
email_2_content = " ".join(email_2.body.split())
assert (
"You have requested a reconciliation of your user accounts on Docs."
in email_2_content
)
assert (
f"user-reconciliations/inactive/{inactive_email_confirmation_id}/"
in email_2_content
)
def test_user_reconciliation_only_starts_if_checks_are_made(
user_reconciliation_users_and_docs,
):
"""Test that the admin action does not process entries
unless both email checks are confirmed.
"""
user_1, user_2, _userdocs_u1, _userdocs_u2 = user_reconciliation_users_and_docs
# Create a reconciliation entry where only one email has been checked
rec = models.UserReconciliation.objects.create(
active_email=user_1.email,
inactive_email=user_2.email,
active_email_checked=True,
inactive_email_checked=False,
status="pending",
)
rec.save()
# Capture counts before running admin action
accesses_before_active = models.DocumentAccess.objects.filter(user=user_1).count()
accesses_before_inactive = models.DocumentAccess.objects.filter(user=user_2).count()
users_active_before = (user_1.is_active, user_2.is_active)
# Call the admin action with the queryset containing our single rec
qs = models.UserReconciliation.objects.filter(id=rec.id)
process_reconciliation(None, None, qs)
# Reload from DB and assert nothing was processed (checks prevent processing)
rec.refresh_from_db()
user_1.refresh_from_db()
user_2.refresh_from_db()
assert rec.status == "ready"
assert (
models.DocumentAccess.objects.filter(user=user_1).count()
== accesses_before_active
)
assert (
models.DocumentAccess.objects.filter(user=user_2).count()
== accesses_before_inactive
)
assert (user_1.is_active, user_2.is_active) == users_active_before
def test_process_reconciliation_updates_accesses(
user_reconciliation_users_and_docs,
):
"""Test that accesses are consolidated on the active user."""
user_1, user_2, userdocs_u1, userdocs_u2 = user_reconciliation_users_and_docs
u1_2 = userdocs_u1[2]
u1_5 = userdocs_u1[5]
u2doc1 = userdocs_u2[1].document
u2doc5 = userdocs_u2[5].document
rec = models.UserReconciliation.objects.create(
active_email=user_1.email,
inactive_email=user_2.email,
active_user=user_1,
inactive_user=user_2,
active_email_checked=True,
inactive_email_checked=True,
status="ready",
)
qs = models.UserReconciliation.objects.filter(id=rec.id)
process_reconciliation(None, None, qs)
rec.refresh_from_db()
user_1.refresh_from_db()
user_2.refresh_from_db()
u1_2.refresh_from_db(
from_queryset=models.DocumentAccess.objects.select_for_update()
)
u1_5.refresh_from_db(
from_queryset=models.DocumentAccess.objects.select_for_update()
)
# After processing, inactive user should have no accesses
# and active user should have one access per union document
# with the highest role
assert rec.status == "done"
assert "Requested update for 10 DocumentAccess items" in rec.logs
assert "and deletion for 12 DocumentAccess items" in rec.logs
assert models.DocumentAccess.objects.filter(user=user_2).count() == 0
assert models.DocumentAccess.objects.filter(user=user_1).count() == 20
assert u1_2.role == "editor"
assert u1_5.role == "owner"
assert (
models.DocumentAccess.objects.filter(user=user_1, document=u2doc1).first().role
== "editor"
)
assert (
models.DocumentAccess.objects.filter(user=user_1, document=u2doc5).first().role
== "owner"
)
assert user_1.is_active is True
assert user_2.is_active is False
# pylint: disable-next=no-member
assert len(mail.outbox) == 1
# pylint: disable-next=no-member
email = mail.outbox[0]
assert email.to == [user_1.email]
email_content = " ".join(email.body.split())
assert "Your accounts have been merged" in email_content
def test_process_reconciliation_updates_linktraces(
user_reconciliation_users_and_docs,
):
"""Test that linktraces are consolidated on the active user."""
user_1, user_2, userdocs_u1, userdocs_u2 = user_reconciliation_users_and_docs
u1_2 = userdocs_u1[2]
u1_5 = userdocs_u1[5]
doc_both = u1_2.document
models.LinkTrace.objects.create(document=doc_both, user=user_1)
models.LinkTrace.objects.create(document=doc_both, user=user_2)
doc_inactive_only = userdocs_u2[4].document
models.LinkTrace.objects.create(
document=doc_inactive_only, user=user_2, is_masked=True
)
doc_active_only = userdocs_u1[4].document
models.LinkTrace.objects.create(document=doc_active_only, user=user_1)
rec = models.UserReconciliation.objects.create(
active_email=user_1.email,
inactive_email=user_2.email,
active_user=user_1,
inactive_user=user_2,
active_email_checked=True,
inactive_email_checked=True,
status="ready",
)
qs = models.UserReconciliation.objects.filter(id=rec.id)
process_reconciliation(None, None, qs)
rec.refresh_from_db()
user_1.refresh_from_db()
user_2.refresh_from_db()
u1_2.refresh_from_db(
from_queryset=models.DocumentAccess.objects.select_for_update()
)
u1_5.refresh_from_db(
from_queryset=models.DocumentAccess.objects.select_for_update()
)
# Inactive user should have no linktraces
assert models.LinkTrace.objects.filter(user=user_2).count() == 0
# doc_both should have a single LinkTrace owned by the active user
assert (
models.LinkTrace.objects.filter(user=user_1, document=doc_both).exists() is True
)
assert models.LinkTrace.objects.filter(user=user_1, document=doc_both).count() == 1
assert (
models.LinkTrace.objects.filter(user=user_2, document=doc_both).exists()
is False
)
# doc_inactive_only should now be linked to active user and preserve is_masked
lt = models.LinkTrace.objects.filter(
user=user_1, document=doc_inactive_only
).first()
assert lt is not None
assert lt.is_masked is True
# doc_active_only should still belong to active user
assert models.LinkTrace.objects.filter(
user=user_1, document=doc_active_only
).exists()
def test_process_reconciliation_updates_threads_comments_reactions(
user_reconciliation_users_and_docs,
):
"""Test that threads, comments and reactions are transferred/deduplicated
on reconciliation."""
user_1, user_2, _userdocs_u1, userdocs_u2 = user_reconciliation_users_and_docs
# Use a document from the inactive user's set
document = userdocs_u2[0].document
# Thread and comment created by inactive user -> should be moved to active
thread = factories.ThreadFactory(document=document, creator=user_2)
comment = factories.CommentFactory(thread=thread, user=user_2)
# Reaction where only inactive user reacted -> should be moved to active user
reaction_inactive_only = factories.ReactionFactory(comment=comment, users=[user_2])
# Reaction where both users reacted -> inactive user's participation should be removed
thread2 = factories.ThreadFactory(document=document, creator=user_1)
comment2 = factories.CommentFactory(thread=thread2, user=user_1)
reaction_both = factories.ReactionFactory(comment=comment2, users=[user_1, user_2])
# Reaction where only active user reacted -> unchanged
thread3 = factories.ThreadFactory(document=document, creator=user_1)
comment3 = factories.CommentFactory(thread=thread3, user=user_1)
reaction_active_only = factories.ReactionFactory(comment=comment3, users=[user_1])
rec = models.UserReconciliation.objects.create(
active_email=user_1.email,
inactive_email=user_2.email,
active_user=user_1,
inactive_user=user_2,
active_email_checked=True,
inactive_email_checked=True,
status="ready",
)
qs = models.UserReconciliation.objects.filter(id=rec.id)
process_reconciliation(None, None, qs)
# Refresh objects
thread.refresh_from_db()
comment.refresh_from_db()
reaction_inactive_only.refresh_from_db()
reaction_both.refresh_from_db()
reaction_active_only.refresh_from_db()
# Thread and comment creator should now be the active user
assert thread.creator == user_1
assert comment.user == user_1
# reaction_inactive_only: inactive user's participation should be removed and
# active user's participation added
reaction_inactive_only.refresh_from_db()
assert not reaction_inactive_only.users.filter(pk=user_2.pk).exists()
assert reaction_inactive_only.users.filter(pk=user_1.pk).exists()
# reaction_both: should end up with only active user's participation
assert reaction_both.users.filter(pk=user_2.pk).exists() is False
assert reaction_both.users.filter(pk=user_1.pk).exists() is True
# reaction_active_only should still have active user's participation
assert reaction_active_only.users.filter(pk=user_1.pk).exists()
def test_process_reconciliation_updates_favorites(
user_reconciliation_users_and_docs,
):
"""Test that favorites are consolidated on the active user."""
user_1, user_2, userdocs_u1, userdocs_u2 = user_reconciliation_users_and_docs
u1_2 = userdocs_u1[2]
u1_5 = userdocs_u1[5]
doc_both = u1_2.document
models.DocumentFavorite.objects.create(document=doc_both, user=user_1)
models.DocumentFavorite.objects.create(document=doc_both, user=user_2)
doc_inactive_only = userdocs_u2[4].document
models.DocumentFavorite.objects.create(document=doc_inactive_only, user=user_2)
doc_active_only = userdocs_u1[4].document
models.DocumentFavorite.objects.create(document=doc_active_only, user=user_1)
rec = models.UserReconciliation.objects.create(
active_email=user_1.email,
inactive_email=user_2.email,
active_user=user_1,
inactive_user=user_2,
active_email_checked=True,
inactive_email_checked=True,
status="ready",
)
qs = models.UserReconciliation.objects.filter(id=rec.id)
process_reconciliation(None, None, qs)
rec.refresh_from_db()
user_1.refresh_from_db()
user_2.refresh_from_db()
u1_2.refresh_from_db(
from_queryset=models.DocumentAccess.objects.select_for_update()
)
u1_5.refresh_from_db(
from_queryset=models.DocumentAccess.objects.select_for_update()
)
# Inactive user should have no document favorites
assert models.DocumentFavorite.objects.filter(user=user_2).count() == 0
# doc_both should have a single DocumentFavorite owned by the active user
assert (
models.DocumentFavorite.objects.filter(user=user_1, document=doc_both).exists()
is True
)
assert (
models.DocumentFavorite.objects.filter(user=user_1, document=doc_both).count()
== 1
)
assert (
models.DocumentFavorite.objects.filter(user=user_2, document=doc_both).exists()
is False
)
# doc_inactive_only should now be linked to active user
assert (
models.DocumentFavorite.objects.filter(
user=user_2, document=doc_inactive_only
).count()
== 0
)
assert models.DocumentFavorite.objects.filter(
user=user_1, document=doc_inactive_only
).exists()
# doc_active_only should still belong to active user
assert models.DocumentFavorite.objects.filter(
user=user_1, document=doc_active_only
).exists()
+217 -21
View File
@@ -2,9 +2,11 @@
Unit tests for the User model
"""
from unittest import mock
import uuid
from unittest.mock import patch
from django.core.exceptions import ValidationError
from django.test.utils import override_settings
import pytest
@@ -26,26 +28,6 @@ def test_models_users_id_unique():
factories.UserFactory(id=user.id)
def test_models_users_send_mail_main_existing():
"""The "email_user' method should send mail to the user's email address."""
user = factories.UserFactory()
with mock.patch("django.core.mail.send_mail") as mock_send:
user.email_user("my subject", "my message")
mock_send.assert_called_once_with("my subject", "my message", None, [user.email])
def test_models_users_send_mail_main_missing():
"""The "email_user' method should fail if the user has no email address."""
user = factories.UserFactory(email=None)
with pytest.raises(ValueError) as excinfo:
user.email_user("my subject", "my message")
assert str(excinfo.value) == "User has no email address."
@pytest.mark.parametrize(
"sub,is_valid",
[
@@ -96,3 +78,217 @@ def test_modes_users_convert_valid_invitations():
id=invitation_other_document.id
).exists()
assert models.Invitation.objects.filter(id=other_email_invitation.id).exists()
@override_settings(USER_ONBOARDING_DOCUMENTS=[])
def test_models_users_handle_onboarding_documents_access_empty_setting():
"""
When USER_ONBOARDING_DOCUMENTS is empty, no accesses should be created.
"""
user = factories.UserFactory()
assert models.DocumentAccess.objects.filter(user=user).count() == 0
def test_models_users_handle_onboarding_documents_access_with_single_document():
"""
When USER_ONBOARDING_DOCUMENTS has a valid document ID,
an access should be created for the new user with the READER role.
The document should be pinned as a favorite for the user.
"""
document = factories.DocumentFactory()
with override_settings(USER_ONBOARDING_DOCUMENTS=[str(document.id)]):
user = factories.UserFactory()
assert (
models.DocumentAccess.objects.filter(user=user, document=document).count() == 1
)
access = models.DocumentAccess.objects.get(user=user, document=document)
assert access.role == models.RoleChoices.READER
user_favorites = models.DocumentFavorite.objects.filter(user=user)
assert user_favorites.count() == 1
assert user_favorites.filter(document=document).exists()
def test_models_users_handle_onboarding_documents_access_with_multiple_documents():
"""
When USER_ONBOARDING_DOCUMENTS has multiple valid document IDs,
accesses should be created for all documents.
All accesses should have the READER role.
All documents should be pinned as favorites for the user.
"""
document1 = factories.DocumentFactory(title="Document 1")
document2 = factories.DocumentFactory(title="Document 2")
document3 = factories.DocumentFactory(title="Document 3")
with override_settings(
USER_ONBOARDING_DOCUMENTS=[
str(document1.id),
str(document2.id),
str(document3.id),
]
):
user = factories.UserFactory()
user_accesses = models.DocumentAccess.objects.filter(user=user)
assert user_accesses.count() == 3
assert models.DocumentAccess.objects.filter(user=user, document=document1).exists()
assert models.DocumentAccess.objects.filter(user=user, document=document2).exists()
assert models.DocumentAccess.objects.filter(user=user, document=document3).exists()
for access in user_accesses:
assert access.role == models.RoleChoices.READER
user_favorites = models.DocumentFavorite.objects.filter(user=user)
assert user_favorites.count() == 3
assert user_favorites.filter(document=document1).exists()
assert user_favorites.filter(document=document2).exists()
assert user_favorites.filter(document=document3).exists()
def test_models_users_handle_onboarding_documents_access_with_invalid_document_id():
"""
When USER_ONBOARDING_DOCUMENTS has an invalid document ID,
it should be skipped and logged, but not raise an exception.
"""
invalid_id = uuid.uuid4()
with override_settings(USER_ONBOARDING_DOCUMENTS=[str(invalid_id)]):
with patch("core.models.logger") as mock_logger:
user = factories.UserFactory()
mock_logger.warning.assert_called_once()
call_args = mock_logger.warning.call_args
assert "Onboarding document with id" in call_args[0][0]
assert models.DocumentAccess.objects.filter(user=user).count() == 0
def test_models_users_handle_onboarding_documents_access_duplicate_prevention():
"""
If the same document is listed multiple times in USER_ONBOARDING_DOCUMENTS,
it should only create one access (or handle duplicates gracefully).
"""
document = factories.DocumentFactory()
with override_settings(
USER_ONBOARDING_DOCUMENTS=[str(document.id), str(document.id)]
):
user = factories.UserFactory()
user_accesses = models.DocumentAccess.objects.filter(user=user, document=document)
assert user_accesses.count() >= 1
@override_settings(USER_ONBOARDING_SANDBOX_DOCUMENT=None)
def test_models_users_duplicate_onboarding_sandbox_document_no_setting():
"""
When USER_ONBOARDING_SANDBOX_DOCUMENT is not set, no sandbox document should be created.
"""
user = factories.UserFactory()
assert (
models.Document.objects.filter(creator=user, title__icontains="Sandbox").count()
== 0
)
initial_accesses = models.DocumentAccess.objects.filter(user=user).count()
assert initial_accesses == 0
def test_models_users_duplicate_onboarding_sandbox_document_creates_sandbox():
"""
When USER_ONBOARDING_SANDBOX_DOCUMENT is set with a valid template document,
a new sandbox document should be created for the user with OWNER access.
"""
template_document = factories.DocumentFactory(title="Getting started with Docs")
with override_settings(USER_ONBOARDING_SANDBOX_DOCUMENT=str(template_document.id)):
user = factories.UserFactory()
sandbox_docs = models.Document.objects.filter(
creator=user, title="Getting started with Docs"
)
assert sandbox_docs.count() == 1
sandbox_doc = sandbox_docs.first()
assert sandbox_doc.creator == user
assert sandbox_doc.duplicated_from == template_document
access = models.DocumentAccess.objects.get(user=user, document=sandbox_doc)
assert access.role == models.RoleChoices.OWNER
def test_models_users_duplicate_onboarding_sandbox_document_with_invalid_template_id():
"""
When USER_ONBOARDING_SANDBOX_DOCUMENT has an invalid document ID,
it should be skipped and logged, but not raise an exception.
"""
invalid_id = uuid.uuid4()
with override_settings(USER_ONBOARDING_SANDBOX_DOCUMENT=str(invalid_id)):
with patch("core.models.logger") as mock_logger:
user = factories.UserFactory()
mock_logger.warning.assert_called_once()
call_args = mock_logger.warning.call_args
assert "Onboarding sandbox document with id" in call_args[0][0]
sandbox_docs = models.Document.objects.filter(creator=user)
assert sandbox_docs.count() == 0
def test_models_users_duplicate_onboarding_sandbox_document_creates_unique_sandbox_per_user():
"""
Each new user should get their own independent sandbox document.
"""
template_document = factories.DocumentFactory(title="Getting started with Docs")
with override_settings(USER_ONBOARDING_SANDBOX_DOCUMENT=str(template_document.id)):
user1 = factories.UserFactory()
user2 = factories.UserFactory()
sandbox_docs_user1 = models.Document.objects.filter(
creator=user1, title="Getting started with Docs"
)
sandbox_docs_user2 = models.Document.objects.filter(
creator=user2, title="Getting started with Docs"
)
assert sandbox_docs_user1.count() == 1
assert sandbox_docs_user2.count() == 1
assert sandbox_docs_user1.first().id != sandbox_docs_user2.first().id
def test_models_users_duplicate_onboarding_sandbox_document_integration_with_other_methods():
"""
Verify that sandbox creation works alongside other onboarding methods.
"""
template_document = factories.DocumentFactory(title="Getting started with Docs")
onboarding_doc = factories.DocumentFactory(title="Onboarding Document")
with override_settings(
USER_ONBOARDING_SANDBOX_DOCUMENT=str(template_document.id),
USER_ONBOARDING_DOCUMENTS=[str(onboarding_doc.id)],
):
user = factories.UserFactory()
sandbox_doc = models.Document.objects.filter(
creator=user, title="Getting started with Docs"
).first()
user_accesses = models.DocumentAccess.objects.filter(user=user)
assert user_accesses.count() == 2
sandbox_access = user_accesses.get(document=sandbox_doc)
onboarding_access = user_accesses.get(document=onboarding_doc)
assert sandbox_access.role == models.RoleChoices.OWNER
assert onboarding_access.role == models.RoleChoices.READER
@@ -1,7 +1,9 @@
"""
Test ai API endpoints in the impress core app.
Test AI services in the impress core app.
"""
# pylint: disable=protected-access
from collections.abc import AsyncIterator
from unittest.mock import MagicMock, patch
from django.core.exceptions import ImproperlyConfigured
@@ -9,12 +11,33 @@ from django.test.utils import override_settings
import pytest
from openai import OpenAIError
from pydantic_ai.ui.vercel_ai.request_types import TextUIPart, UIMessage
from core.services.ai_services import AIService
from core.services.ai_services import (
BLOCKNOTE_TOOL_STRICT_PROMPT,
AIService,
convert_async_generator_to_sync,
)
pytestmark = pytest.mark.django_db
@pytest.fixture(autouse=True)
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.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
# -- AIService.__init__ --
@pytest.mark.parametrize(
"setting_name, setting_value",
[
@@ -23,22 +46,25 @@ pytestmark = pytest.mark.django_db
("AI_MODEL", None),
],
)
def test_api_ai_setting_missing(setting_name, setting_value):
def test_services_ai_setting_missing(setting_name, setting_value, settings):
"""Setting should be set"""
setattr(settings, setting_name, setting_value)
with override_settings(**{setting_name: setting_value}):
with pytest.raises(
ImproperlyConfigured,
match="AI configuration not set",
):
AIService()
with pytest.raises(
ImproperlyConfigured,
match="AI configuration not set",
):
AIService()
# -- AIService.transform --
@override_settings(
AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="test-model"
)
@patch("openai.resources.chat.completions.Completions.create")
def test_api_ai__client_error(mock_create):
def test_services_ai_client_error(mock_create):
"""Fail when the client raises an error"""
mock_create.side_effect = OpenAIError("Mocked client error")
@@ -54,7 +80,7 @@ def test_api_ai__client_error(mock_create):
AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="test-model"
)
@patch("openai.resources.chat.completions.Completions.create")
def test_api_ai__client_invalid_response(mock_create):
def test_services_ai_client_invalid_response(mock_create):
"""Fail when the client response is invalid"""
mock_create.return_value = MagicMock(
@@ -72,7 +98,7 @@ def test_api_ai__client_invalid_response(mock_create):
AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="test-model"
)
@patch("openai.resources.chat.completions.Completions.create")
def test_api_ai__success(mock_create):
def test_services_ai_success(mock_create):
"""The AI request should work as expect when called with valid arguments."""
mock_create.return_value = MagicMock(
@@ -82,3 +108,483 @@ def test_api_ai__success(mock_create):
response = AIService().transform("hello", "prompt")
assert response == {"answer": "Salut"}
# -- AIService.translate --
@patch("openai.resources.chat.completions.Completions.create")
def test_services_ai_translate_success(mock_create):
"""Translate should call the AI API with the correct language prompt."""
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content="Bonjour"))]
)
response = AIService().translate("<p>Hello</p>", "fr")
assert response == {"answer": "Bonjour"}
call_args = mock_create.call_args
system_content = call_args[1]["messages"][0]["content"]
assert "French" in system_content or "fr" in system_content
@patch("openai.resources.chat.completions.Completions.create")
def test_services_ai_translate_unknown_language(mock_create):
"""Translate with an unknown language code should use the code as-is."""
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content="Translated"))]
)
response = AIService().translate("<p>Hello</p>", "xx-unknown")
assert response == {"answer": "Translated"}
call_args = mock_create.call_args
system_content = call_args[1]["messages"][0]["content"]
assert "xx-unknown" in system_content
# -- convert_async_generator_to_sync --
def test_convert_async_generator_to_sync_basic():
"""Should convert an async generator yielding items to a sync iterator."""
async def async_gen():
for item in ["hello", "world", "!"]:
yield item
result = list(convert_async_generator_to_sync(async_gen()))
assert result == ["hello", "world", "!"]
def test_convert_async_generator_to_sync_empty():
"""Should handle an empty async generator."""
async def async_gen():
return
yield
result = list(convert_async_generator_to_sync(async_gen()))
assert not result
def test_convert_async_generator_to_sync_exception():
"""Should propagate exceptions from the async generator."""
async def async_gen():
yield "first"
raise ValueError("async error")
sync_iter = convert_async_generator_to_sync(async_gen())
assert next(sync_iter) == "first"
with pytest.raises(ValueError, match="async error"):
next(sync_iter)
# -- AIService.inject_document_state_messages --
def test_inject_document_state_messages_no_metadata():
"""Messages without documentState metadata should pass through unchanged."""
messages = [
UIMessage(role="user", id="msg-1", parts=[TextUIPart(text="Hello")]),
]
result = AIService.inject_document_state_messages(messages)
assert len(result) == 1
assert result[0].id == "msg-1"
def test_inject_document_state_messages_with_selection():
"""A user message with documentState and selection should get an
assistant context message prepended."""
messages = [
UIMessage(
role="user",
id="msg-1",
parts=[TextUIPart(text="Fix this")],
metadata={
"documentState": {
"selection": {"start": 0, "end": 5},
"selectedBlocks": [{"type": "paragraph", "content": "Hello"}],
"blocks": [
{"type": "paragraph", "content": "Hello"},
{"type": "paragraph", "content": "World"},
],
}
},
),
]
result = AIService.inject_document_state_messages(messages)
assert len(result) == 2
# First message should be the injected assistant context
assert result[0].role == "assistant"
assert result[0].id == "assistant-document-state-msg-1"
assert len(result[0].parts) == 4
assert "selection" in result[0].parts[0].text.lower()
# Second message should be the original user message
assert result[1].id == "msg-1"
def test_inject_document_state_messages_without_selection():
"""A user message with documentState but no selection should describe
the full document context."""
messages = [
UIMessage(
role="user",
id="msg-1",
parts=[TextUIPart(text="Summarize")],
metadata={
"documentState": {
"selection": None,
"blocks": [
{"type": "paragraph", "content": "Hello"},
],
"isEmptyDocument": False,
}
},
),
]
result = AIService.inject_document_state_messages(messages)
assert len(result) == 2
assistant_msg = result[0]
assert assistant_msg.role == "assistant"
assert len(assistant_msg.parts) == 2
assert "no active selection" in assistant_msg.parts[0].text.lower()
assert "prefer updating" in assistant_msg.parts[0].text.lower()
def test_inject_document_state_messages_empty_document():
"""When the document is empty, the injected message should instruct
updating the empty block first."""
messages = [
UIMessage(
role="user",
id="msg-1",
parts=[TextUIPart(text="Write something")],
metadata={
"documentState": {
"selection": None,
"blocks": [{"type": "paragraph", "content": ""}],
"isEmptyDocument": True,
}
},
),
]
result = AIService.inject_document_state_messages(messages)
assert len(result) == 2
assistant_msg = result[0]
assert "update the empty block" in assistant_msg.parts[0].text.lower()
def test_inject_document_state_messages_mixed():
"""Only user messages with documentState get assistant context;
other messages pass through unchanged."""
messages = [
UIMessage(
role="assistant",
id="msg-0",
parts=[TextUIPart(text="Previous response")],
),
UIMessage(
role="user",
id="msg-1",
parts=[TextUIPart(text="Hello")],
),
UIMessage(
role="user",
id="msg-2",
parts=[TextUIPart(text="Fix this")],
metadata={
"documentState": {
"selection": {"start": 0, "end": 5},
"selectedBlocks": [{"type": "paragraph", "content": "Hello"}],
"blocks": [{"type": "paragraph", "content": "Hello"}],
}
},
),
]
result = AIService.inject_document_state_messages(messages)
# 3 original + 1 injected assistant message before msg-2
assert len(result) == 4
assert result[0].id == "msg-0"
assert result[1].id == "msg-1"
assert result[2].role == "assistant"
assert result[2].id == "assistant-document-state-msg-2"
assert result[3].id == "msg-2"
# -- AIService.tool_definitions_to_toolset --
def test_tool_definitions_to_toolset():
"""Should convert frontend tool definitions to an ExternalToolset."""
tool_definitions = {
"applyOperations": {
"description": "Apply operations to the document",
"inputSchema": {
"type": "object",
"properties": {
"operations": {"type": "array"},
},
},
"outputSchema": {"type": "object"},
},
"insertBlocks": {
"description": "Insert blocks",
"inputSchema": {"type": "object"},
},
}
toolset = AIService.tool_definitions_to_toolset(tool_definitions)
# The ExternalToolset wraps ToolDefinition objects
assert toolset is not None
# Access internal tool definitions
tool_defs = toolset.tool_defs
assert len(tool_defs) == 2
names = {td.name for td in tool_defs}
assert names == {"applyOperations", "insertBlocks"}
for td in tool_defs:
assert td.kind == "external"
if td.name == "applyOperations":
assert td.description == "Apply operations to the document"
assert td.metadata == {"output_schema": {"type": "object"}}
def test_tool_definitions_to_toolset_missing_fields():
"""Should handle tool definitions with missing optional fields."""
tool_definitions = {
"myTool": {},
}
toolset = AIService.tool_definitions_to_toolset(tool_definitions)
tool_defs = toolset.tool_defs
assert len(tool_defs) == 1
assert tool_defs[0].name == "myTool"
assert tool_defs[0].description == ""
assert tool_defs[0].parameters_json_schema == {}
assert tool_defs[0].metadata == {"output_schema": None}
# -- AIService.stream --
@patch.object(AIService, "_build_async_stream")
def test_services_ai_stream_sync_mode(mock_build, monkeypatch):
"""In sync mode, stream() should return a sync iterator."""
async def mock_async_gen():
yield "chunk1"
yield "chunk2"
mock_build.return_value = mock_async_gen()
monkeypatch.setenv("PYTHON_SERVER_MODE", "sync")
service = AIService()
request = MagicMock()
result = service.stream(request)
# Should be a regular (sync) iterator, not async
assert not isinstance(result, AsyncIterator)
assert list(result) == ["chunk1", "chunk2"]
mock_build.assert_called_once_with(request)
@patch.object(AIService, "_build_async_stream")
def test_services_ai_stream_async_mode(mock_build, monkeypatch):
"""In async mode, stream() should return the async iterator directly."""
async def mock_async_gen():
yield "chunk1"
yield "chunk2"
mock_async_iter = mock_async_gen()
mock_build.return_value = mock_async_iter
monkeypatch.setenv("PYTHON_SERVER_MODE", "async")
service = AIService()
request = MagicMock()
result = service.stream(request)
assert result is mock_async_iter
mock_build.assert_called_once_with(request)
@patch.object(AIService, "_build_async_stream")
def test_services_ai_stream_defaults_to_sync(mock_build, monkeypatch):
"""When PYTHON_SERVER_MODE is not set, stream() should default to sync."""
async def mock_async_gen():
yield "data"
mock_build.return_value = mock_async_gen()
monkeypatch.delenv("PYTHON_SERVER_MODE", raising=False)
service = AIService()
request = MagicMock()
result = service.stream(request)
# Default should be sync mode
assert not isinstance(result, AsyncIterator)
assert list(result) == ["data"]
# -- AIService._build_async_stream --
@patch("core.services.ai_services.VercelAIAdapter")
def test_services_ai_build_async_stream(mock_adapter_cls):
"""_build_async_stream should build the pydantic-ai streaming pipeline."""
async def mock_encode():
yield "event-data"
mock_run_input = MagicMock()
mock_run_input.model_extra = None
mock_run_input.messages = []
mock_adapter_cls.build_run_input.return_value = mock_run_input
mock_adapter_instance = MagicMock()
mock_adapter_instance.run_stream.return_value = MagicMock()
mock_adapter_instance.encode_stream.return_value = mock_encode()
mock_adapter_cls.return_value = mock_adapter_instance
service = AIService()
request = MagicMock()
request.META = {"HTTP_ACCEPT": "text/event-stream"}
request.raw_body = b'{"messages": []}'
result = service._build_async_stream(request)
assert isinstance(result, AsyncIterator)
mock_adapter_cls.build_run_input.assert_called_once_with(b'{"messages": []}')
mock_adapter_instance.run_stream.assert_called_once()
mock_adapter_instance.encode_stream.assert_called_once()
@patch("core.services.ai_services.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."""
async def mock_encode():
yield "event-data"
mock_run_input = MagicMock()
mock_run_input.model_extra = {
"toolDefinitions": {
"myTool": {
"description": "A tool",
"inputSchema": {"type": "object"},
}
}
}
mock_run_input.messages = []
mock_adapter_cls.build_run_input.return_value = mock_run_input
mock_adapter_instance = MagicMock()
mock_adapter_instance.run_stream.return_value = MagicMock()
mock_adapter_instance.encode_stream.return_value = mock_encode()
mock_adapter_cls.return_value = mock_adapter_instance
service = AIService()
request = MagicMock()
request.META = {}
request.raw_body = b"{}"
service._build_async_stream(request)
# run_stream should have been called with a toolset
call_kwargs = mock_adapter_instance.run_stream.call_args[1]
assert call_kwargs["toolsets"] is not None
assert len(call_kwargs["toolsets"]) == 1
@patch("core.services.ai_services.VercelAIAdapter")
def test_services_ai_build_async_stream_with_tool_definitions_required_system_prompt(
mock_adapter_cls,
):
"""The presence of the applyDocumentOperations tool must force the addition
of a system prompt"""
async def mock_encode():
yield "event-data"
mock_run_input = MagicMock()
mock_run_input.model_extra = {
"toolDefinitions": {
"applyDocumentOperations": {
"description": "A tool",
"inputSchema": {"type": "object"},
}
}
}
mock_run_input.messages = []
mock_adapter_cls.build_run_input.return_value = mock_run_input
mock_adapter_instance = MagicMock()
mock_adapter_instance.run_stream.return_value = MagicMock()
mock_adapter_instance.encode_stream.return_value = mock_encode()
mock_adapter_cls.return_value = mock_adapter_instance
service = AIService()
request = MagicMock()
request.META = {}
request.raw_body = b"{}"
service._build_async_stream(request)
# run_stream should have been called with a toolset
call_kwargs = mock_adapter_instance.run_stream.call_args[1]
assert call_kwargs["toolsets"] is not None
assert len(call_kwargs["toolsets"]) == 1
assert len(mock_run_input.messages) == 1
assert mock_run_input.messages[0].id == "system-force-tool-usage"
assert mock_run_input.messages[0].role == "system"
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")
def test_services_ai_build_async_stream_langfuse_enabled(
mock_adapter_cls, mock_agent_cls, settings
):
"""When LANGFUSE_PUBLIC_KEY is set, instrument should be enabled."""
settings.LANGFUSE_PUBLIC_KEY = "pk-test-123"
async def mock_encode():
yield "data"
mock_run_input = MagicMock()
mock_run_input.model_extra = None
mock_run_input.messages = []
mock_adapter_cls.build_run_input.return_value = mock_run_input
mock_adapter_instance = MagicMock()
mock_adapter_instance.run_stream.return_value = MagicMock()
mock_adapter_instance.encode_stream.return_value = mock_encode()
mock_adapter_cls.return_value = mock_adapter_instance
service = AIService()
request = MagicMock()
request.META = {}
request.raw_body = b"{}"
service._build_async_stream(request)
mock_agent_cls.instrument_all.assert_called_once()
# Agent should be created with instrument=True
mock_agent_cls.assert_called_once()
assert mock_agent_cls.call_args[1]["instrument"] is True
+107 -2
View File
@@ -3,9 +3,14 @@
import base64
import uuid
import pycrdt
from django.core.cache import cache
from core import utils
import pycrdt
import pytest
from core import factories, utils
pytestmark = pytest.mark.django_db
# This base64 string is an example of what is saved in the database.
# This base64 is generated from the blocknote editor, it contains
@@ -100,3 +105,103 @@ def test_utils_get_ancestor_to_descendants_map_multiple_paths():
"000100020005": {"000100020005"},
"00010003": {"00010003"},
}
def test_utils_users_sharing_documents_with_cache_miss():
"""Test cache miss: should query database and cache result."""
user1 = factories.UserFactory()
user2 = factories.UserFactory()
user3 = factories.UserFactory()
doc1 = factories.DocumentFactory()
doc2 = factories.DocumentFactory()
factories.UserDocumentAccessFactory(user=user1, document=doc1)
factories.UserDocumentAccessFactory(user=user2, document=doc1)
factories.UserDocumentAccessFactory(user=user3, document=doc2)
cache_key = utils.get_users_sharing_documents_with_cache_key(user1)
cache.delete(cache_key)
result = utils.users_sharing_documents_with(user1)
assert user2.id in result
cached_data = cache.get(cache_key)
assert cached_data == result
def test_utils_users_sharing_documents_with_cache_hit():
"""Test cache hit: should return cached data without querying database."""
user1 = factories.UserFactory()
user2 = factories.UserFactory()
doc1 = factories.DocumentFactory()
factories.UserDocumentAccessFactory(user=user1, document=doc1)
factories.UserDocumentAccessFactory(user=user2, document=doc1)
cache_key = utils.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)
assert result == test_cached_data
def test_utils_users_sharing_documents_with_cache_invalidation_on_create():
"""Test that cache is invalidated when a DocumentAccess is created."""
# Create test data
user1 = factories.UserFactory()
user2 = factories.UserFactory()
doc1 = factories.DocumentFactory()
# Pre-populate cache
cache_key = utils.get_users_sharing_documents_with_cache_key(user1)
cache.set(cache_key, {}, 86400)
# Verify cache exists
assert cache.get(cache_key) is not None
# Create new DocumentAccess
factories.UserDocumentAccessFactory(user=user2, document=doc1)
# Cache should still exist (only created for user2 who was added)
# But if we create access for user1 being shared with, cache should be cleared
cache.set(cache_key, {"test": "data"}, 86400)
factories.UserDocumentAccessFactory(user=user1, document=doc1)
# Cache for user1 should be invalidated (cleared)
assert cache.get(cache_key) is None
def test_utils_users_sharing_documents_with_cache_invalidation_on_delete():
"""Test that cache is invalidated when a DocumentAccess is deleted."""
user1 = factories.UserFactory()
user2 = factories.UserFactory()
doc1 = factories.DocumentFactory()
doc_access = factories.UserDocumentAccessFactory(user=user1, document=doc1)
cache_key = utils.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
doc_access.delete()
assert cache.get(cache_key) is None
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.delete(cache_key)
result = utils.users_sharing_documents_with(user1)
assert result == {}
cached_data = cache.get(cache_key)
assert cached_data == {}
@@ -0,0 +1,62 @@
"""Tests for utils.users_sharing_documents_with function."""
from django.utils import timezone
import pytest
from core import factories, utils
pytestmark = pytest.mark.django_db
def test_utils_users_sharing_documents_with():
"""Test users_sharing_documents_with function."""
user = factories.UserFactory(
email="martin.bernard@anct.gouv.fr", full_name="Martin Bernard"
)
pierre_1 = factories.UserFactory(
email="pierre.dupont@beta.gouv.fr", full_name="Pierre Dupont"
)
pierre_2 = factories.UserFactory(
email="pierre.durand@impots.gouv.fr", full_name="Pierre Durand"
)
now = timezone.now()
yesterday = now - timezone.timedelta(days=1)
last_week = now - timezone.timedelta(days=7)
last_month = now - timezone.timedelta(days=30)
document_1 = factories.DocumentFactory(creator=user)
document_2 = factories.DocumentFactory(creator=user)
document_3 = factories.DocumentFactory(creator=user)
factories.UserDocumentAccessFactory(user=user, document=document_1)
factories.UserDocumentAccessFactory(user=user, document=document_2)
factories.UserDocumentAccessFactory(user=user, document=document_3)
# The factory cannot set the created_at directly, so we force it after creation
doc_1_pierre_1 = factories.UserDocumentAccessFactory(
user=pierre_1, document=document_1, created_at=last_week
)
doc_1_pierre_1.created_at = last_week
doc_1_pierre_1.save()
doc_2_pierre_2 = factories.UserDocumentAccessFactory(
user=pierre_2, document=document_2
)
doc_2_pierre_2.created_at = last_month
doc_2_pierre_2.save()
doc_3_pierre_2 = factories.UserDocumentAccessFactory(
user=pierre_2, document=document_3
)
doc_3_pierre_2.created_at = yesterday
doc_3_pierre_2.save()
shared_map = utils.users_sharing_documents_with(user)
assert shared_map == {
pierre_1.id: last_week,
pierre_2.id: yesterday,
}
+4
View File
@@ -59,6 +59,10 @@ urlpatterns = [
r"^documents/(?P<resource_id>[0-9a-z-]*)/threads/(?P<thread_id>[0-9a-z-]*)/",
include(thread_related_router.urls),
),
path(
"user-reconciliations/<str:user_type>/<uuid:confirmation_id>/",
viewsets.ReconciliationConfirmView.as_view(),
),
]
),
),
+52 -1
View File
@@ -1,13 +1,21 @@
"""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
from core import enums, models
logger = logging.getLogger(__name__)
def get_ancestor_to_descendants_map(paths, steplen):
@@ -96,3 +104,46 @@ def extract_attachments(content):
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
+18
View File
@@ -0,0 +1,18 @@
"""
ASGI config for impress project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/dev/howto/deployment/asgi/
"""
import os
from configurations.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "impress.settings")
os.environ.setdefault("DJANGO_CONFIGURATION", "Development")
os.environ.setdefault("PYTHON_SERVER_MODE", "async")
application = get_asgi_application()
@@ -3,8 +3,8 @@
"default": {
"logo": {
"src": "/assets/icon-docs.svg",
"width": "54px",
"alt": "Docs Logo",
"style": { "width": "54px", "height": "auto" },
"withTitle": true
},
"externalLinks": [
@@ -125,5 +125,38 @@
}
}
}
},
"home": {
"with-proconnect": false,
"icon-banner": {
"src": "/assets/icon-docs.svg",
"style": {
"width": "64px",
"height": "auto"
},
"alt": ""
}
},
"header": {
"logo": {},
"icon": {
"src": "/assets/icon-docs.svg",
"style": {
"width": "32px",
"height": "auto"
},
"alt": "",
"withTitle": true
}
},
"favicon": {
"light": {
"href": "/assets/favicon-light.png",
"type": "image/png"
},
"dark": {
"href": "/assets/favicon-dark.png",
"type": "image/png"
}
}
}
+86 -11
View File
@@ -169,6 +169,11 @@ class Base(Configuration):
environ_name="AWS_STORAGE_BUCKET_NAME",
environ_prefix=None,
)
AWS_S3_SIGNATURE_VERSION = values.Value(
"s3v4",
environ_name="AWS_S3_SIGNATURE_VERSION",
environ_prefix=None,
)
# Document images
DOCUMENT_IMAGE_MAX_SIZE = values.IntegerValue(
@@ -321,6 +326,7 @@ class Base(Configuration):
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"core.middleware.ForceSessionMiddleware",
"core.middleware.SaveRawBodyMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"dockerflow.django.middleware.DockerflowMiddleware",
"csp.middleware.CSPMiddleware",
@@ -507,7 +513,9 @@ class Base(Configuration):
FRONTEND_JS_URL = values.Value(
None, environ_name="FRONTEND_JS_URL", environ_prefix=None
)
FRONTEND_SILENT_LOGIN_ENABLED = values.BooleanValue(
default=False, environ_name="FRONTEND_SILENT_LOGIN_ENABLED", environ_prefix=None
)
THEME_CUSTOMIZATION_FILE_PATH = values.Value(
os.path.join(BASE_DIR, "impress/configuration/theme/default.json"),
environ_name="THEME_CUSTOMIZATION_FILE_PATH",
@@ -549,6 +557,16 @@ class Base(Configuration):
SESSION_COOKIE_NAME = "docs_sessionid"
# OIDC - Authorization Code Flow
OIDC_AUTHENTICATE_CLASS = values.Value(
"lasuite.oidc_login.views.OIDCAuthenticationRequestView",
environ_name="OIDC_AUTHENTICATE_CLASS",
environ_prefix=None,
)
OIDC_CALLBACK_CLASS = values.Value(
"lasuite.oidc_login.views.OIDCAuthenticationCallbackView",
environ_name="OIDC_CALLBACK_CLASS",
environ_prefix=None,
)
OIDC_CREATE_USER = values.BooleanValue(
default=True,
environ_name="OIDC_CREATE_USER",
@@ -670,24 +688,48 @@ class Base(Configuration):
default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None
)
# AI service
AI_FEATURE_ENABLED = values.BooleanValue(
default=False, environ_name="AI_FEATURE_ENABLED", 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)
AI_MODEL = values.Value(None, environ_name="AI_MODEL", environ_prefix=None)
# AI settings
AI_ALLOW_REACH_FROM = values.Value(
choices=("public", "authenticated", "restricted"),
default="authenticated",
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)
AI_BOT = values.DictValue(
default={
"name": _("Docs AI"),
"color": "#8bc6ff",
},
environ_name="AI_BOT",
environ_prefix=None,
)
AI_DOCUMENT_RATE_THROTTLE_RATES = {
"minute": 5,
"hour": 100,
"day": 500,
}
# Master settings to enable AI features, if you set it to False,
# all AI features will be disabled even if the other settings are enabled.
AI_FEATURE_ENABLED = values.BooleanValue(
default=False, environ_name="AI_FEATURE_ENABLED", environ_prefix=None
)
# Far better UI but more flaky for the moment
# ⚠️ AGPL license, be sure to comply with the Blocknote license
# if you enable it (https://www.blocknotejs.org/)
AI_FEATURE_BLOCKNOTE_ENABLED = values.BooleanValue(
default=False, environ_name="AI_FEATURE_BLOCKNOTE_ENABLED", environ_prefix=None
)
# UI with less features but more stable
# MIT friendly license, you can enable it without worrying about the license
AI_FEATURE_LEGACY_ENABLED = values.BooleanValue(
default=True, environ_name="AI_FEATURE_LEGACY_ENABLED", environ_prefix=None
)
AI_MODEL = values.Value(None, environ_name="AI_MODEL", environ_prefix=None)
AI_VERCEL_SDK_VERSION = values.IntegerValue(
6, environ_name="AI_VERCEL_SDK_VERSION", environ_prefix=None
)
AI_USER_RATE_THROTTLE_RATES = {
"minute": 3,
"hour": 50,
@@ -719,7 +761,7 @@ class Base(Configuration):
# Imported file settings
CONVERSION_FILE_MAX_SIZE = values.IntegerValue(
20 * MB, # 10MB
20 * MB,
environ_name="CONVERSION_FILE_MAX_SIZE",
environ_prefix=None,
)
@@ -825,6 +867,11 @@ class Base(Configuration):
environ_name="API_USERS_LIST_LIMIT",
environ_prefix=None,
)
API_USERS_SEARCH_QUERY_MIN_LENGTH = values.PositiveIntegerValue(
default=3,
environ_name="API_USERS_SEARCH_QUERY_MIN_LENGTH",
environ_prefix=None,
)
# Content Security Policy
# See https://content-security-policy.com/ for more information.
@@ -858,6 +905,16 @@ class Base(Configuration):
),
}
# User accounts management
USER_RECONCILIATION_FORM_URL = values.Value(
None, environ_name="USER_RECONCILIATION_FORM_URL", environ_prefix=None
)
USER_ONBOARDING_DOCUMENTS = values.ListValue(
[], environ_name="USER_ONBOARDING_DOCUMENTS", environ_prefix=None
)
USER_ONBOARDING_SANDBOX_DOCUMENT = values.Value(
None, environ_name="USER_ONBOARDING_SANDBOX_DOCUMENT", environ_prefix=None
)
# Marketing and communication settings
SIGNUP_NEW_USER_TO_MARKETING_EMAIL = values.BooleanValue(
False,
@@ -1071,6 +1128,7 @@ class Production(Base):
# Modern browsers require to have the `secure` attribute on cookies with `Samesite=none`
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SECURE = True
SESSION_CACHE_ALIAS = "session"
# Privacy
SECURE_REFERRER_POLICY = "same-origin"
@@ -1078,11 +1136,12 @@ class Production(Base):
# Conversion API: Always verify SSL in production
CONVERSION_API_SECURE = True
# Cache
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": values.Value(
"redis://redis:6379/1",
"redis://redis:6379/0",
environ_name="REDIS_URL",
environ_prefix=None,
),
@@ -1096,10 +1155,26 @@ class Production(Base):
},
"KEY_PREFIX": values.Value(
"docs",
environ_name="CACHES_KEY_PREFIX",
environ_name="CACHES_DEFAULT_KEY_PREFIX",
environ_prefix=None,
),
},
"session": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": values.Value(
"redis://redis:6379/0",
environ_name="REDIS_URL",
environ_prefix=None,
),
"TIMEOUT": values.IntegerValue(
30, # timeout in seconds
environ_name="CACHES_SESSION_TIMEOUT",
environ_prefix=None,
),
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
},
},
}
+1
View File
@@ -13,5 +13,6 @@ from configurations.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "impress.settings")
os.environ.setdefault("DJANGO_CONFIGURATION", "Development")
os.environ.setdefault("PYTHON_SERVER_MODE", "sync")
application = get_wsgi_application()
+181 -120
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"POT-Creation-Date: 2026-02-26 09:13+0000\n"
"PO-Revision-Date: 2026-02-27 08:24\n"
"Last-Translator: \n"
"Language-Team: Breton\n"
"Language: br_FR\n"
@@ -17,69 +17,65 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:36 core/admin.py:36
#: build/lib/core/admin.py:30 core/admin.py:30
msgid "Personal info"
msgstr "Titouroù personel"
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
#: core/admin.py:161
msgid "Permissions"
msgstr "Aotreoù"
#: build/lib/core/admin.py:61 core/admin.py:61
#: build/lib/core/admin.py:55 core/admin.py:55
msgid "Important dates"
msgstr "Deiziadoù a-bouez"
#: build/lib/core/admin.py:147 core/admin.py:147
#: build/lib/core/admin.py:112 core/admin.py:112
msgid "Import job created and queued."
msgstr ""
#: build/lib/core/admin.py:116 core/admin.py:116
msgid "Process selected user reconciliations"
msgstr ""
#: build/lib/core/admin.py:171 core/admin.py:171
msgid "Tree structure"
msgstr "Gwezennadur"
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
#: build/lib/core/api/filters.py:48 core/api/filters.py:48
msgid "Title"
msgstr "Titl"
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
#: build/lib/core/api/filters.py:62 core/api/filters.py:62
msgid "Creator is me"
msgstr "Me eo an aozer"
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
#: build/lib/core/api/filters.py:65 core/api/filters.py:65
msgid "Masked"
msgstr "Kuzhet"
#: build/lib/core/api/filters.py:67 core/api/filters.py:67
#: build/lib/core/api/filters.py:68 core/api/filters.py:68
msgid "Favorite"
msgstr "Sinedoù"
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
#: build/lib/core/api/serializers.py:513 core/api/serializers.py:513
msgid "A new document was created on your behalf!"
msgstr "Ur restr nevez a zo bet krouet ganeoc'h!"
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
#: build/lib/core/api/serializers.py:517 core/api/serializers.py:517
msgid "You have been granted ownership of a new document:"
msgstr "C'hwi zo bet disklaeriet perc'henn ur restr nevez:"
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
#: build/lib/core/api/serializers.py:553 core/api/serializers.py:553
msgid "This field is required."
msgstr "Ar vaezienn-mañ a zo rekis."
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#: build/lib/core/api/serializers.py:564 core/api/serializers.py:564
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
msgid "Body"
msgstr "Korf"
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
msgid "Body type"
msgstr "Doare korf"
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
msgid "Format"
msgstr "Stumm"
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#: build/lib/core/api/viewsets.py:1278 core/api/viewsets.py:1278
#, python-brace-format
msgid "copy of {title}"
msgstr "eilenn {title}"
@@ -243,224 +239,289 @@ msgstr "implijer"
msgid "users"
msgstr "implijerien"
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
#: build/lib/core/models.py:360 core/models.py:360
msgid "Active email address"
msgstr ""
#: build/lib/core/models.py:361 core/models.py:361
msgid "Email address to deactivate"
msgstr ""
#: build/lib/core/models.py:388 core/models.py:388
msgid "Unique ID in the source file"
msgstr ""
#: build/lib/core/models.py:394 build/lib/core/models.py:692 core/models.py:394
#: core/models.py:692
msgid "Pending"
msgstr ""
#: build/lib/core/models.py:395 core/models.py:395
msgid "Ready"
msgstr ""
#: build/lib/core/models.py:396 build/lib/core/models.py:694 core/models.py:396
#: core/models.py:694
msgid "Done"
msgstr ""
#: build/lib/core/models.py:397 build/lib/core/models.py:695 core/models.py:397
#: core/models.py:695
msgid "Error"
msgstr ""
#: build/lib/core/models.py:405 core/models.py:405
msgid "user reconciliation"
msgstr ""
#: build/lib/core/models.py:406 core/models.py:406
msgid "user reconciliations"
msgstr ""
#: build/lib/core/models.py:644 core/models.py:644
msgid "You have requested a reconciliation of your user accounts on Docs.\n"
" To confirm that you are the one who initiated the request\n"
" and that this email belongs to you:"
msgstr ""
#: build/lib/core/models.py:650 core/models.py:650
msgid "Confirm by clicking the link to start the reconciliation"
msgstr ""
#: build/lib/core/models.py:655 build/lib/core/models.py:761 core/models.py:655
#: core/models.py:761
msgid "Click here"
msgstr ""
#: build/lib/core/models.py:656 core/models.py:656
msgid "Confirm"
msgstr ""
#: build/lib/core/models.py:667 core/models.py:667
msgid "Your reconciliation request has been processed.\n"
" New documents are likely associated with your account:"
msgstr ""
#: build/lib/core/models.py:672 core/models.py:672
msgid "Your accounts have been merged"
msgstr ""
#: build/lib/core/models.py:677 core/models.py:677
msgid "Click here to see"
msgstr ""
#: build/lib/core/models.py:678 core/models.py:678
msgid "See my documents"
msgstr ""
#: build/lib/core/models.py:688 core/models.py:688
msgid "CSV file"
msgstr ""
#: build/lib/core/models.py:693 core/models.py:693
msgid "Running"
msgstr ""
#: build/lib/core/models.py:703 core/models.py:703
msgid "user reconciliation CSV import"
msgstr ""
#: build/lib/core/models.py:704 core/models.py:704
msgid "user reconciliation CSV imports"
msgstr ""
#: build/lib/core/models.py:748 core/models.py:748
#, python-brace-format
msgid "Your request for reconciliation was unsuccessful.\n"
" Reconciliation failed for the following email addresses:\n"
" {recipient_email}, {other_email}.\n"
" Please check for typos.\n"
" You can submit another request with the valid email addresses."
msgstr ""
#: build/lib/core/models.py:756 core/models.py:756
msgid "Reconciliation of your Docs accounts not completed"
msgstr ""
#: build/lib/core/models.py:762 core/models.py:762
msgid "Make a new request"
msgstr ""
#: build/lib/core/models.py:861 core/models.py:861
msgid "title"
msgstr "titl"
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:862 core/models.py:862
msgid "excerpt"
msgstr "bomm"
#: build/lib/core/models.py:411 core/models.py:411
#: build/lib/core/models.py:911 core/models.py:911
msgid "Document"
msgstr "Restr"
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:912 core/models.py:912
msgid "Documents"
msgstr "Restroù"
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
#: build/lib/core/models.py:924 build/lib/core/models.py:1328
#: core/models.py:924 core/models.py:1328
msgid "Untitled Document"
msgstr "Restr hep titl"
#: build/lib/core/models.py:862 core/models.py:862
#: build/lib/core/models.py:1329 core/models.py:1329
msgid "Open"
msgstr "Digeriñ"
#: build/lib/core/models.py:1364 core/models.py:1364
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} en deus rannet ur restr ganeoc'h!"
#: build/lib/core/models.py:866 core/models.py:866
#: build/lib/core/models.py:1368 core/models.py:1368
#, 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:872 core/models.py:872
#: build/lib/core/models.py:1374 core/models.py:1374
#, 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:973 core/models.py:973
#: build/lib/core/models.py:1475 core/models.py:1475
msgid "Document/user link trace"
msgstr "Roud liamm ar restr/an implijer"
#: build/lib/core/models.py:974 core/models.py:974
#: build/lib/core/models.py:1476 core/models.py:1476
msgid "Document/user link traces"
msgstr "Roudoù liamm ar restr/an implijer"
#: build/lib/core/models.py:980 core/models.py:980
#: build/lib/core/models.py:1482 core/models.py:1482
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:1003 core/models.py:1003
#: build/lib/core/models.py:1505 core/models.py:1505
msgid "Document favorite"
msgstr "Restr muiañ-karet"
#: build/lib/core/models.py:1004 core/models.py:1004
#: build/lib/core/models.py:1506 core/models.py:1506
msgid "Document favorites"
msgstr "Restroù muiañ-karet"
#: build/lib/core/models.py:1010 core/models.py:1010
#: build/lib/core/models.py:1512 core/models.py:1512
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:1032 core/models.py:1032
#: build/lib/core/models.py:1534 core/models.py:1534
msgid "Document/user relation"
msgstr "Liamm restr/implijer"
#: build/lib/core/models.py:1033 core/models.py:1033
#: build/lib/core/models.py:1535 core/models.py:1535
msgid "Document/user relations"
msgstr "Liammoù restr/implijer"
#: build/lib/core/models.py:1039 core/models.py:1039
#: build/lib/core/models.py:1541 core/models.py:1541
msgid "This user is already in this document."
msgstr "An implijer-mañ a zo dija er restr-mañ."
#: build/lib/core/models.py:1045 core/models.py:1045
#: build/lib/core/models.py:1547 core/models.py:1547
msgid "This team is already in this document."
msgstr "Ar skipailh-mañ a zo dija en restr-mañ."
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
#: build/lib/core/models.py:1553 core/models.py:1553
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:1202 core/models.py:1202
#: build/lib/core/models.py:1704 core/models.py:1704
msgid "Document ask for access"
msgstr "Goulenn tizhout ar restr"
#: build/lib/core/models.py:1203 core/models.py:1203
#: build/lib/core/models.py:1705 core/models.py:1705
msgid "Document ask for accesses"
msgstr "Goulennoù tizhout ar restr"
#: build/lib/core/models.py:1209 core/models.py:1209
#: build/lib/core/models.py:1711 core/models.py:1711
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:1266 core/models.py:1266
#: build/lib/core/models.py:1768 core/models.py:1768
#, 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:1270 core/models.py:1270
#: build/lib/core/models.py:1772 core/models.py:1772
#, 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:1276 core/models.py:1276
#: build/lib/core/models.py:1778 core/models.py:1778
#, 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:1318 core/models.py:1318
#: build/lib/core/models.py:1820 core/models.py:1820
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1319 core/models.py:1319
#: build/lib/core/models.py:1821 core/models.py:1821
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
#: build/lib/core/models.py:1824 build/lib/core/models.py:1876
#: core/models.py:1824 core/models.py:1876
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1369 core/models.py:1369
#: build/lib/core/models.py:1871 core/models.py:1871
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1370 core/models.py:1370
#: build/lib/core/models.py:1872 core/models.py:1872
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1419 core/models.py:1419
#: build/lib/core/models.py:1921 core/models.py:1921
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1423 core/models.py:1423
#: build/lib/core/models.py:1925 core/models.py:1925
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1424 core/models.py:1424
#: build/lib/core/models.py:1926 core/models.py:1926
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr "deskrivadur"
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr "kod"
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr "publik"
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "Whether this template is public for anyone to use."
msgstr "M'eo foran ar patrom-mañ hag implijus gant n'eus forzh piv."
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr "Patrom"
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr "Patromoù"
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr "Liamm patrom/implijer"
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr "Liammoù patrom/implijer"
#: build/lib/core/models.py:1508 core/models.py:1508
msgid "This user is already in this template."
msgstr "An implijer-mañ a zo dija er patrom-mañ."
#: build/lib/core/models.py:1514 core/models.py:1514
msgid "This team is already in this template."
msgstr "Ar skipailh-mañ a zo dija er patrom-mañ."
#: build/lib/core/models.py:1591 core/models.py:1591
#: build/lib/core/models.py:1936 core/models.py:1936
msgid "email address"
msgstr "postel"
#: build/lib/core/models.py:1610 core/models.py:1610
#: build/lib/core/models.py:1955 core/models.py:1955
msgid "Document invitation"
msgstr "Pedadenn d'ur restr"
#: build/lib/core/models.py:1611 core/models.py:1611
#: build/lib/core/models.py:1956 core/models.py:1956
msgid "Document invitations"
msgstr "Pedadennoù d'ur restr"
#: build/lib/core/models.py:1631 core/models.py:1631
#: build/lib/core/models.py:1976 core/models.py:1976
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:702 impress/settings.py:702
msgid "Docs AI"
msgstr ""
#: core/templates/mail/html/template.html:153
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr "Logo ar postel"
#: core/templates/mail/html/template.html:200
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr "Digeriñ"
#: core/templates/mail/html/template.html:217
#: core/templates/mail/html/template.html:219
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr " Docs, hoc'h ostilh nevez ret-holl evit aozañ, rannañ ha kenlabourat war ar restr e skipailh. "
#: core/templates/mail/html/template.html:224
#: core/templates/mail/html/template.html:226
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
+181 -120
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"POT-Creation-Date: 2026-02-26 09:13+0000\n"
"PO-Revision-Date: 2026-02-27 08:24\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de_DE\n"
@@ -17,69 +17,65 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:36 core/admin.py:36
#: build/lib/core/admin.py:30 core/admin.py:30
msgid "Personal info"
msgstr "Persönliche Daten"
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
#: core/admin.py:161
msgid "Permissions"
msgstr "Berechtigungen"
#: build/lib/core/admin.py:61 core/admin.py:61
#: build/lib/core/admin.py:55 core/admin.py:55
msgid "Important dates"
msgstr "Wichtige Daten"
#: build/lib/core/admin.py:147 core/admin.py:147
#: build/lib/core/admin.py:112 core/admin.py:112
msgid "Import job created and queued."
msgstr ""
#: build/lib/core/admin.py:116 core/admin.py:116
msgid "Process selected user reconciliations"
msgstr ""
#: build/lib/core/admin.py:171 core/admin.py:171
msgid "Tree structure"
msgstr "Baumstruktur"
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
#: build/lib/core/api/filters.py:48 core/api/filters.py:48
msgid "Title"
msgstr "Titel"
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
#: build/lib/core/api/filters.py:62 core/api/filters.py:62
msgid "Creator is me"
msgstr "Ersteller bin ich"
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
#: build/lib/core/api/filters.py:65 core/api/filters.py:65
msgid "Masked"
msgstr ""
#: build/lib/core/api/filters.py:67 core/api/filters.py:67
#: build/lib/core/api/filters.py:68 core/api/filters.py:68
msgid "Favorite"
msgstr "Favorit"
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
#: build/lib/core/api/serializers.py:513 core/api/serializers.py:513
msgid "A new document was created on your behalf!"
msgstr "Ein neues Dokument wurde in Ihrem Namen erstellt!"
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
#: build/lib/core/api/serializers.py:517 core/api/serializers.py:517
msgid "You have been granted ownership of a new document:"
msgstr "Sie sind Besitzer eines neuen Dokuments:"
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
#: build/lib/core/api/serializers.py:553 core/api/serializers.py:553
msgid "This field is required."
msgstr ""
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#: build/lib/core/api/serializers.py:564 core/api/serializers.py:564
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
msgid "Body"
msgstr "Inhalt"
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
msgid "Body type"
msgstr "Typ"
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
msgid "Format"
msgstr "Format"
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#: build/lib/core/api/viewsets.py:1278 core/api/viewsets.py:1278
#, python-brace-format
msgid "copy of {title}"
msgstr "Kopie von {title}"
@@ -243,224 +239,289 @@ msgstr "Benutzer"
msgid "users"
msgstr "Benutzer"
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
#: build/lib/core/models.py:360 core/models.py:360
msgid "Active email address"
msgstr ""
#: build/lib/core/models.py:361 core/models.py:361
msgid "Email address to deactivate"
msgstr ""
#: build/lib/core/models.py:388 core/models.py:388
msgid "Unique ID in the source file"
msgstr ""
#: build/lib/core/models.py:394 build/lib/core/models.py:692 core/models.py:394
#: core/models.py:692
msgid "Pending"
msgstr ""
#: build/lib/core/models.py:395 core/models.py:395
msgid "Ready"
msgstr ""
#: build/lib/core/models.py:396 build/lib/core/models.py:694 core/models.py:396
#: core/models.py:694
msgid "Done"
msgstr ""
#: build/lib/core/models.py:397 build/lib/core/models.py:695 core/models.py:397
#: core/models.py:695
msgid "Error"
msgstr ""
#: build/lib/core/models.py:405 core/models.py:405
msgid "user reconciliation"
msgstr ""
#: build/lib/core/models.py:406 core/models.py:406
msgid "user reconciliations"
msgstr ""
#: build/lib/core/models.py:644 core/models.py:644
msgid "You have requested a reconciliation of your user accounts on Docs.\n"
" To confirm that you are the one who initiated the request\n"
" and that this email belongs to you:"
msgstr ""
#: build/lib/core/models.py:650 core/models.py:650
msgid "Confirm by clicking the link to start the reconciliation"
msgstr ""
#: build/lib/core/models.py:655 build/lib/core/models.py:761 core/models.py:655
#: core/models.py:761
msgid "Click here"
msgstr ""
#: build/lib/core/models.py:656 core/models.py:656
msgid "Confirm"
msgstr ""
#: build/lib/core/models.py:667 core/models.py:667
msgid "Your reconciliation request has been processed.\n"
" New documents are likely associated with your account:"
msgstr ""
#: build/lib/core/models.py:672 core/models.py:672
msgid "Your accounts have been merged"
msgstr ""
#: build/lib/core/models.py:677 core/models.py:677
msgid "Click here to see"
msgstr ""
#: build/lib/core/models.py:678 core/models.py:678
msgid "See my documents"
msgstr ""
#: build/lib/core/models.py:688 core/models.py:688
msgid "CSV file"
msgstr ""
#: build/lib/core/models.py:693 core/models.py:693
msgid "Running"
msgstr ""
#: build/lib/core/models.py:703 core/models.py:703
msgid "user reconciliation CSV import"
msgstr ""
#: build/lib/core/models.py:704 core/models.py:704
msgid "user reconciliation CSV imports"
msgstr ""
#: build/lib/core/models.py:748 core/models.py:748
#, python-brace-format
msgid "Your request for reconciliation was unsuccessful.\n"
" Reconciliation failed for the following email addresses:\n"
" {recipient_email}, {other_email}.\n"
" Please check for typos.\n"
" You can submit another request with the valid email addresses."
msgstr ""
#: build/lib/core/models.py:756 core/models.py:756
msgid "Reconciliation of your Docs accounts not completed"
msgstr ""
#: build/lib/core/models.py:762 core/models.py:762
msgid "Make a new request"
msgstr ""
#: build/lib/core/models.py:861 core/models.py:861
msgid "title"
msgstr "Titel"
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:862 core/models.py:862
msgid "excerpt"
msgstr "Auszug"
#: build/lib/core/models.py:411 core/models.py:411
#: build/lib/core/models.py:911 core/models.py:911
msgid "Document"
msgstr "Dokument"
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:912 core/models.py:912
msgid "Documents"
msgstr "Dokumente"
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
#: build/lib/core/models.py:924 build/lib/core/models.py:1328
#: core/models.py:924 core/models.py:1328
msgid "Untitled Document"
msgstr "Unbenanntes Dokument"
#: build/lib/core/models.py:862 core/models.py:862
#: build/lib/core/models.py:1329 core/models.py:1329
msgid "Open"
msgstr "Öffnen"
#: build/lib/core/models.py:1364 core/models.py:1364
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} hat ein Dokument mit Ihnen geteilt!"
#: build/lib/core/models.py:866 core/models.py:866
#: build/lib/core/models.py:1368 core/models.py:1368
#, 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:872 core/models.py:872
#: build/lib/core/models.py:1374 core/models.py:1374
#, 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:973 core/models.py:973
#: build/lib/core/models.py:1475 core/models.py:1475
msgid "Document/user link trace"
msgstr "Dokument/Benutzer Linkverfolgung"
#: build/lib/core/models.py:974 core/models.py:974
#: build/lib/core/models.py:1476 core/models.py:1476
msgid "Document/user link traces"
msgstr "Dokument/Benutzer Linkverfolgung"
#: build/lib/core/models.py:980 core/models.py:980
#: build/lib/core/models.py:1482 core/models.py:1482
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:1003 core/models.py:1003
#: build/lib/core/models.py:1505 core/models.py:1505
msgid "Document favorite"
msgstr "Dokumentenfavorit"
#: build/lib/core/models.py:1004 core/models.py:1004
#: build/lib/core/models.py:1506 core/models.py:1506
msgid "Document favorites"
msgstr "Dokumentfavoriten"
#: build/lib/core/models.py:1010 core/models.py:1010
#: build/lib/core/models.py:1512 core/models.py:1512
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:1032 core/models.py:1032
#: build/lib/core/models.py:1534 core/models.py:1534
msgid "Document/user relation"
msgstr "Dokument/Benutzerbeziehung"
#: build/lib/core/models.py:1033 core/models.py:1033
#: build/lib/core/models.py:1535 core/models.py:1535
msgid "Document/user relations"
msgstr "Dokument/Benutzerbeziehungen"
#: build/lib/core/models.py:1039 core/models.py:1039
#: build/lib/core/models.py:1541 core/models.py:1541
msgid "This user is already in this document."
msgstr "Dieser Benutzer befindet sich bereits in diesem Dokument."
#: build/lib/core/models.py:1045 core/models.py:1045
#: build/lib/core/models.py:1547 core/models.py:1547
msgid "This team is already in this document."
msgstr "Dieses Team befindet sich bereits in diesem Dokument."
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
#: build/lib/core/models.py:1553 core/models.py:1553
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:1202 core/models.py:1202
#: build/lib/core/models.py:1704 core/models.py:1704
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1203 core/models.py:1203
#: build/lib/core/models.py:1705 core/models.py:1705
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1209 core/models.py:1209
#: build/lib/core/models.py:1711 core/models.py:1711
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1266 core/models.py:1266
#: build/lib/core/models.py:1768 core/models.py:1768
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1270 core/models.py:1270
#: build/lib/core/models.py:1772 core/models.py:1772
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1276 core/models.py:1276
#: build/lib/core/models.py:1778 core/models.py:1778
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1318 core/models.py:1318
#: build/lib/core/models.py:1820 core/models.py:1820
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1319 core/models.py:1319
#: build/lib/core/models.py:1821 core/models.py:1821
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
#: build/lib/core/models.py:1824 build/lib/core/models.py:1876
#: core/models.py:1824 core/models.py:1876
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1369 core/models.py:1369
#: build/lib/core/models.py:1871 core/models.py:1871
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1370 core/models.py:1370
#: build/lib/core/models.py:1872 core/models.py:1872
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1419 core/models.py:1419
#: build/lib/core/models.py:1921 core/models.py:1921
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1423 core/models.py:1423
#: build/lib/core/models.py:1925 core/models.py:1925
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1424 core/models.py:1424
#: build/lib/core/models.py:1926 core/models.py:1926
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr "Beschreibung"
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr "Code"
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr "CSS"
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr "öffentlich"
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "Whether this template is public for anyone to use."
msgstr "Ob diese Vorlage für jedermann öffentlich ist."
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr "Vorlage"
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr "Vorlagen"
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr "Vorlage/Benutzer-Beziehung"
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr "Vorlage/Benutzerbeziehungen"
#: build/lib/core/models.py:1508 core/models.py:1508
msgid "This user is already in this template."
msgstr "Dieser Benutzer ist bereits in dieser Vorlage."
#: build/lib/core/models.py:1514 core/models.py:1514
msgid "This team is already in this template."
msgstr "Dieses Team ist bereits in diesem Template."
#: build/lib/core/models.py:1591 core/models.py:1591
#: build/lib/core/models.py:1936 core/models.py:1936
msgid "email address"
msgstr "E-Mail-Adresse"
#: build/lib/core/models.py:1610 core/models.py:1610
#: build/lib/core/models.py:1955 core/models.py:1955
msgid "Document invitation"
msgstr "Einladung zum Dokument"
#: build/lib/core/models.py:1611 core/models.py:1611
#: build/lib/core/models.py:1956 core/models.py:1956
msgid "Document invitations"
msgstr "Dokumenteinladungen"
#: build/lib/core/models.py:1631 core/models.py:1631
#: build/lib/core/models.py:1976 core/models.py:1976
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:702 impress/settings.py:702
msgid "Docs AI"
msgstr ""
#: core/templates/mail/html/template.html:153
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr "Logo-E-Mail"
#: core/templates/mail/html/template.html:200
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr "Öffnen"
#: core/templates/mail/html/template.html:217
#: core/templates/mail/html/template.html:219
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr " Docs, Ihr neues unentbehrliches Werkzeug für die Organisation, den Austausch und die Zusammenarbeit in Ihren Dokumenten als Team. "
#: core/templates/mail/html/template.html:224
#: core/templates/mail/html/template.html:226
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
+181 -120
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"POT-Creation-Date: 2026-02-26 09:13+0000\n"
"PO-Revision-Date: 2026-02-27 08:24\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
@@ -17,69 +17,65 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:36 core/admin.py:36
#: build/lib/core/admin.py:30 core/admin.py:30
msgid "Personal info"
msgstr ""
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
#: core/admin.py:161
msgid "Permissions"
msgstr ""
#: build/lib/core/admin.py:61 core/admin.py:61
#: build/lib/core/admin.py:55 core/admin.py:55
msgid "Important dates"
msgstr ""
#: build/lib/core/admin.py:147 core/admin.py:147
#: build/lib/core/admin.py:112 core/admin.py:112
msgid "Import job created and queued."
msgstr ""
#: build/lib/core/admin.py:116 core/admin.py:116
msgid "Process selected user reconciliations"
msgstr ""
#: build/lib/core/admin.py:171 core/admin.py:171
msgid "Tree structure"
msgstr ""
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
#: build/lib/core/api/filters.py:48 core/api/filters.py:48
msgid "Title"
msgstr ""
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
#: build/lib/core/api/filters.py:62 core/api/filters.py:62
msgid "Creator is me"
msgstr ""
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
#: build/lib/core/api/filters.py:65 core/api/filters.py:65
msgid "Masked"
msgstr ""
#: build/lib/core/api/filters.py:67 core/api/filters.py:67
#: build/lib/core/api/filters.py:68 core/api/filters.py:68
msgid "Favorite"
msgstr ""
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
#: build/lib/core/api/serializers.py:513 core/api/serializers.py:513
msgid "A new document was created on your behalf!"
msgstr ""
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
#: build/lib/core/api/serializers.py:517 core/api/serializers.py:517
msgid "You have been granted ownership of a new document:"
msgstr ""
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
#: build/lib/core/api/serializers.py:553 core/api/serializers.py:553
msgid "This field is required."
msgstr ""
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#: build/lib/core/api/serializers.py:564 core/api/serializers.py:564
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
msgid "Body"
msgstr ""
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
msgid "Body type"
msgstr ""
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
msgid "Format"
msgstr ""
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#: build/lib/core/api/viewsets.py:1278 core/api/viewsets.py:1278
#, python-brace-format
msgid "copy of {title}"
msgstr ""
@@ -243,224 +239,289 @@ msgstr ""
msgid "users"
msgstr ""
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
#: build/lib/core/models.py:360 core/models.py:360
msgid "Active email address"
msgstr ""
#: build/lib/core/models.py:361 core/models.py:361
msgid "Email address to deactivate"
msgstr ""
#: build/lib/core/models.py:388 core/models.py:388
msgid "Unique ID in the source file"
msgstr ""
#: build/lib/core/models.py:394 build/lib/core/models.py:692 core/models.py:394
#: core/models.py:692
msgid "Pending"
msgstr ""
#: build/lib/core/models.py:395 core/models.py:395
msgid "Ready"
msgstr ""
#: build/lib/core/models.py:396 build/lib/core/models.py:694 core/models.py:396
#: core/models.py:694
msgid "Done"
msgstr ""
#: build/lib/core/models.py:397 build/lib/core/models.py:695 core/models.py:397
#: core/models.py:695
msgid "Error"
msgstr ""
#: build/lib/core/models.py:405 core/models.py:405
msgid "user reconciliation"
msgstr ""
#: build/lib/core/models.py:406 core/models.py:406
msgid "user reconciliations"
msgstr ""
#: build/lib/core/models.py:644 core/models.py:644
msgid "You have requested a reconciliation of your user accounts on Docs.\n"
" To confirm that you are the one who initiated the request\n"
" and that this email belongs to you:"
msgstr ""
#: build/lib/core/models.py:650 core/models.py:650
msgid "Confirm by clicking the link to start the reconciliation"
msgstr ""
#: build/lib/core/models.py:655 build/lib/core/models.py:761 core/models.py:655
#: core/models.py:761
msgid "Click here"
msgstr ""
#: build/lib/core/models.py:656 core/models.py:656
msgid "Confirm"
msgstr ""
#: build/lib/core/models.py:667 core/models.py:667
msgid "Your reconciliation request has been processed.\n"
" New documents are likely associated with your account:"
msgstr ""
#: build/lib/core/models.py:672 core/models.py:672
msgid "Your accounts have been merged"
msgstr ""
#: build/lib/core/models.py:677 core/models.py:677
msgid "Click here to see"
msgstr ""
#: build/lib/core/models.py:678 core/models.py:678
msgid "See my documents"
msgstr ""
#: build/lib/core/models.py:688 core/models.py:688
msgid "CSV file"
msgstr ""
#: build/lib/core/models.py:693 core/models.py:693
msgid "Running"
msgstr ""
#: build/lib/core/models.py:703 core/models.py:703
msgid "user reconciliation CSV import"
msgstr ""
#: build/lib/core/models.py:704 core/models.py:704
msgid "user reconciliation CSV imports"
msgstr ""
#: build/lib/core/models.py:748 core/models.py:748
#, python-brace-format
msgid "Your request for reconciliation was unsuccessful.\n"
" Reconciliation failed for the following email addresses:\n"
" {recipient_email}, {other_email}.\n"
" Please check for typos.\n"
" You can submit another request with the valid email addresses."
msgstr ""
#: build/lib/core/models.py:756 core/models.py:756
msgid "Reconciliation of your Docs accounts not completed"
msgstr ""
#: build/lib/core/models.py:762 core/models.py:762
msgid "Make a new request"
msgstr ""
#: build/lib/core/models.py:861 core/models.py:861
msgid "title"
msgstr ""
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:862 core/models.py:862
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:411 core/models.py:411
#: build/lib/core/models.py:911 core/models.py:911
msgid "Document"
msgstr ""
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:912 core/models.py:912
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
#: build/lib/core/models.py:924 build/lib/core/models.py:1328
#: core/models.py:924 core/models.py:1328
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:862 core/models.py:862
#: build/lib/core/models.py:1329 core/models.py:1329
msgid "Open"
msgstr ""
#: build/lib/core/models.py:1364 core/models.py:1364
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:866 core/models.py:866
#: build/lib/core/models.py:1368 core/models.py:1368
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:872 core/models.py:872
#: build/lib/core/models.py:1374 core/models.py:1374
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:973 core/models.py:973
#: build/lib/core/models.py:1475 core/models.py:1475
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:974 core/models.py:974
#: build/lib/core/models.py:1476 core/models.py:1476
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:980 core/models.py:980
#: build/lib/core/models.py:1482 core/models.py:1482
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:1003 core/models.py:1003
#: build/lib/core/models.py:1505 core/models.py:1505
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:1004 core/models.py:1004
#: build/lib/core/models.py:1506 core/models.py:1506
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1010 core/models.py:1010
#: build/lib/core/models.py:1512 core/models.py:1512
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1032 core/models.py:1032
#: build/lib/core/models.py:1534 core/models.py:1534
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1033 core/models.py:1033
#: build/lib/core/models.py:1535 core/models.py:1535
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1039 core/models.py:1039
#: build/lib/core/models.py:1541 core/models.py:1541
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1045 core/models.py:1045
#: build/lib/core/models.py:1547 core/models.py:1547
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
#: build/lib/core/models.py:1553 core/models.py:1553
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1202 core/models.py:1202
#: build/lib/core/models.py:1704 core/models.py:1704
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1203 core/models.py:1203
#: build/lib/core/models.py:1705 core/models.py:1705
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1209 core/models.py:1209
#: build/lib/core/models.py:1711 core/models.py:1711
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1266 core/models.py:1266
#: build/lib/core/models.py:1768 core/models.py:1768
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1270 core/models.py:1270
#: build/lib/core/models.py:1772 core/models.py:1772
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1276 core/models.py:1276
#: build/lib/core/models.py:1778 core/models.py:1778
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1318 core/models.py:1318
#: build/lib/core/models.py:1820 core/models.py:1820
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1319 core/models.py:1319
#: build/lib/core/models.py:1821 core/models.py:1821
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
#: build/lib/core/models.py:1824 build/lib/core/models.py:1876
#: core/models.py:1824 core/models.py:1876
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1369 core/models.py:1369
#: build/lib/core/models.py:1871 core/models.py:1871
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1370 core/models.py:1370
#: build/lib/core/models.py:1872 core/models.py:1872
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1419 core/models.py:1419
#: build/lib/core/models.py:1921 core/models.py:1921
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1423 core/models.py:1423
#: build/lib/core/models.py:1925 core/models.py:1925
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1424 core/models.py:1424
#: build/lib/core/models.py:1926 core/models.py:1926
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr ""
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr ""
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr ""
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr ""
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1508 core/models.py:1508
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1514 core/models.py:1514
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1591 core/models.py:1591
#: build/lib/core/models.py:1936 core/models.py:1936
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1610 core/models.py:1610
#: build/lib/core/models.py:1955 core/models.py:1955
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1611 core/models.py:1611
#: build/lib/core/models.py:1956 core/models.py:1956
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1631 core/models.py:1631
#: build/lib/core/models.py:1976 core/models.py:1976
msgid "This email is already associated to a registered user."
msgstr ""
#: build/lib/impress/settings.py:702 impress/settings.py:702
msgid "Docs AI"
msgstr ""
#: core/templates/mail/html/template.html:153
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr ""
#: core/templates/mail/html/template.html:200
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr ""
#: core/templates/mail/html/template.html:217
#: core/templates/mail/html/template.html:219
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr ""
#: core/templates/mail/html/template.html:224
#: core/templates/mail/html/template.html:226
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
+181 -120
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"POT-Creation-Date: 2026-02-26 09:13+0000\n"
"PO-Revision-Date: 2026-02-27 08:24\n"
"Last-Translator: \n"
"Language-Team: Spanish\n"
"Language: es_ES\n"
@@ -17,69 +17,65 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:36 core/admin.py:36
#: build/lib/core/admin.py:30 core/admin.py:30
msgid "Personal info"
msgstr "Información Personal"
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
#: core/admin.py:161
msgid "Permissions"
msgstr "Permisos"
#: build/lib/core/admin.py:61 core/admin.py:61
#: build/lib/core/admin.py:55 core/admin.py:55
msgid "Important dates"
msgstr "Fechas importantes"
#: build/lib/core/admin.py:147 core/admin.py:147
#: build/lib/core/admin.py:112 core/admin.py:112
msgid "Import job created and queued."
msgstr ""
#: build/lib/core/admin.py:116 core/admin.py:116
msgid "Process selected user reconciliations"
msgstr ""
#: build/lib/core/admin.py:171 core/admin.py:171
msgid "Tree structure"
msgstr "Estructura en árbol"
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
#: build/lib/core/api/filters.py:48 core/api/filters.py:48
msgid "Title"
msgstr "Título"
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
#: build/lib/core/api/filters.py:62 core/api/filters.py:62
msgid "Creator is me"
msgstr "Yo soy el creador"
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
#: build/lib/core/api/filters.py:65 core/api/filters.py:65
msgid "Masked"
msgstr ""
#: build/lib/core/api/filters.py:67 core/api/filters.py:67
#: build/lib/core/api/filters.py:68 core/api/filters.py:68
msgid "Favorite"
msgstr "Favorito"
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
#: build/lib/core/api/serializers.py:513 core/api/serializers.py:513
msgid "A new document was created on your behalf!"
msgstr "¡Un nuevo documento se ha creado por ti!"
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
#: build/lib/core/api/serializers.py:517 core/api/serializers.py:517
msgid "You have been granted ownership of a new document:"
msgstr "Se le ha concedido la propiedad de un nuevo documento :"
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
#: build/lib/core/api/serializers.py:553 core/api/serializers.py:553
msgid "This field is required."
msgstr ""
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#: build/lib/core/api/serializers.py:564 core/api/serializers.py:564
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
msgid "Body"
msgstr "Cuerpo"
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
msgid "Body type"
msgstr "Tipo de Cuerpo"
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
msgid "Format"
msgstr "Formato"
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#: build/lib/core/api/viewsets.py:1278 core/api/viewsets.py:1278
#, python-brace-format
msgid "copy of {title}"
msgstr "copia de {title}"
@@ -243,224 +239,289 @@ msgstr "usuario"
msgid "users"
msgstr "usuarios"
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
#: build/lib/core/models.py:360 core/models.py:360
msgid "Active email address"
msgstr ""
#: build/lib/core/models.py:361 core/models.py:361
msgid "Email address to deactivate"
msgstr ""
#: build/lib/core/models.py:388 core/models.py:388
msgid "Unique ID in the source file"
msgstr ""
#: build/lib/core/models.py:394 build/lib/core/models.py:692 core/models.py:394
#: core/models.py:692
msgid "Pending"
msgstr ""
#: build/lib/core/models.py:395 core/models.py:395
msgid "Ready"
msgstr ""
#: build/lib/core/models.py:396 build/lib/core/models.py:694 core/models.py:396
#: core/models.py:694
msgid "Done"
msgstr ""
#: build/lib/core/models.py:397 build/lib/core/models.py:695 core/models.py:397
#: core/models.py:695
msgid "Error"
msgstr ""
#: build/lib/core/models.py:405 core/models.py:405
msgid "user reconciliation"
msgstr ""
#: build/lib/core/models.py:406 core/models.py:406
msgid "user reconciliations"
msgstr ""
#: build/lib/core/models.py:644 core/models.py:644
msgid "You have requested a reconciliation of your user accounts on Docs.\n"
" To confirm that you are the one who initiated the request\n"
" and that this email belongs to you:"
msgstr ""
#: build/lib/core/models.py:650 core/models.py:650
msgid "Confirm by clicking the link to start the reconciliation"
msgstr ""
#: build/lib/core/models.py:655 build/lib/core/models.py:761 core/models.py:655
#: core/models.py:761
msgid "Click here"
msgstr ""
#: build/lib/core/models.py:656 core/models.py:656
msgid "Confirm"
msgstr ""
#: build/lib/core/models.py:667 core/models.py:667
msgid "Your reconciliation request has been processed.\n"
" New documents are likely associated with your account:"
msgstr ""
#: build/lib/core/models.py:672 core/models.py:672
msgid "Your accounts have been merged"
msgstr ""
#: build/lib/core/models.py:677 core/models.py:677
msgid "Click here to see"
msgstr ""
#: build/lib/core/models.py:678 core/models.py:678
msgid "See my documents"
msgstr ""
#: build/lib/core/models.py:688 core/models.py:688
msgid "CSV file"
msgstr ""
#: build/lib/core/models.py:693 core/models.py:693
msgid "Running"
msgstr ""
#: build/lib/core/models.py:703 core/models.py:703
msgid "user reconciliation CSV import"
msgstr ""
#: build/lib/core/models.py:704 core/models.py:704
msgid "user reconciliation CSV imports"
msgstr ""
#: build/lib/core/models.py:748 core/models.py:748
#, python-brace-format
msgid "Your request for reconciliation was unsuccessful.\n"
" Reconciliation failed for the following email addresses:\n"
" {recipient_email}, {other_email}.\n"
" Please check for typos.\n"
" You can submit another request with the valid email addresses."
msgstr ""
#: build/lib/core/models.py:756 core/models.py:756
msgid "Reconciliation of your Docs accounts not completed"
msgstr ""
#: build/lib/core/models.py:762 core/models.py:762
msgid "Make a new request"
msgstr ""
#: build/lib/core/models.py:861 core/models.py:861
msgid "title"
msgstr "título"
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:862 core/models.py:862
msgid "excerpt"
msgstr "resumen"
#: build/lib/core/models.py:411 core/models.py:411
#: build/lib/core/models.py:911 core/models.py:911
msgid "Document"
msgstr "Documento"
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:912 core/models.py:912
msgid "Documents"
msgstr "Documentos"
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
#: build/lib/core/models.py:924 build/lib/core/models.py:1328
#: core/models.py:924 core/models.py:1328
msgid "Untitled Document"
msgstr "Documento sin título"
#: build/lib/core/models.py:862 core/models.py:862
#: build/lib/core/models.py:1329 core/models.py:1329
msgid "Open"
msgstr "Abrir"
#: build/lib/core/models.py:1364 core/models.py:1364
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "¡{name} ha compartido un documento contigo!"
#: build/lib/core/models.py:866 core/models.py:866
#: build/lib/core/models.py:1368 core/models.py:1368
#, 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:872 core/models.py:872
#: build/lib/core/models.py:1374 core/models.py:1374
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} ha compartido un documento contigo: {title}"
#: build/lib/core/models.py:973 core/models.py:973
#: build/lib/core/models.py:1475 core/models.py:1475
msgid "Document/user link trace"
msgstr "Traza del enlace de documento/usuario"
#: build/lib/core/models.py:974 core/models.py:974
#: build/lib/core/models.py:1476 core/models.py:1476
msgid "Document/user link traces"
msgstr "Trazas del enlace de documento/usuario"
#: build/lib/core/models.py:980 core/models.py:980
#: build/lib/core/models.py:1482 core/models.py:1482
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:1003 core/models.py:1003
#: build/lib/core/models.py:1505 core/models.py:1505
msgid "Document favorite"
msgstr "Documento favorito"
#: build/lib/core/models.py:1004 core/models.py:1004
#: build/lib/core/models.py:1506 core/models.py:1506
msgid "Document favorites"
msgstr "Documentos favoritos"
#: build/lib/core/models.py:1010 core/models.py:1010
#: build/lib/core/models.py:1512 core/models.py:1512
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:1032 core/models.py:1032
#: build/lib/core/models.py:1534 core/models.py:1534
msgid "Document/user relation"
msgstr "Relación documento/usuario"
#: build/lib/core/models.py:1033 core/models.py:1033
#: build/lib/core/models.py:1535 core/models.py:1535
msgid "Document/user relations"
msgstr "Relaciones documento/usuario"
#: build/lib/core/models.py:1039 core/models.py:1039
#: build/lib/core/models.py:1541 core/models.py:1541
msgid "This user is already in this document."
msgstr "Este usuario ya forma parte del documento."
#: build/lib/core/models.py:1045 core/models.py:1045
#: build/lib/core/models.py:1547 core/models.py:1547
msgid "This team is already in this document."
msgstr "Este equipo ya forma parte del documento."
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
#: build/lib/core/models.py:1553 core/models.py:1553
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:1202 core/models.py:1202
#: build/lib/core/models.py:1704 core/models.py:1704
msgid "Document ask for access"
msgstr "Solicitud de acceso"
#: build/lib/core/models.py:1203 core/models.py:1203
#: build/lib/core/models.py:1705 core/models.py:1705
msgid "Document ask for accesses"
msgstr "Solicitud de accesos"
#: build/lib/core/models.py:1209 core/models.py:1209
#: build/lib/core/models.py:1711 core/models.py:1711
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:1266 core/models.py:1266
#: build/lib/core/models.py:1768 core/models.py:1768
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "¡{name} desea acceder a un documento!"
#: build/lib/core/models.py:1270 core/models.py:1270
#: build/lib/core/models.py:1772 core/models.py:1772
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} desea acceso al siguiente documento:"
#: build/lib/core/models.py:1276 core/models.py:1276
#: build/lib/core/models.py:1778 core/models.py:1778
#, 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:1318 core/models.py:1318
#: build/lib/core/models.py:1820 core/models.py:1820
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1319 core/models.py:1319
#: build/lib/core/models.py:1821 core/models.py:1821
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
#: build/lib/core/models.py:1824 build/lib/core/models.py:1876
#: core/models.py:1824 core/models.py:1876
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1369 core/models.py:1369
#: build/lib/core/models.py:1871 core/models.py:1871
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1370 core/models.py:1370
#: build/lib/core/models.py:1872 core/models.py:1872
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1419 core/models.py:1419
#: build/lib/core/models.py:1921 core/models.py:1921
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1423 core/models.py:1423
#: build/lib/core/models.py:1925 core/models.py:1925
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1424 core/models.py:1424
#: build/lib/core/models.py:1926 core/models.py:1926
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr "descripción"
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr "código"
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr "público"
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "Whether this template is public for anyone to use."
msgstr "Si esta plantilla es pública para que cualquiera la utilice."
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr "Plantilla"
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr "Plantillas"
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr "Relación plantilla/usuario"
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr "Relaciones plantilla/usuario"
#: build/lib/core/models.py:1508 core/models.py:1508
msgid "This user is already in this template."
msgstr "Este usuario ya forma parte de la plantilla."
#: build/lib/core/models.py:1514 core/models.py:1514
msgid "This team is already in this template."
msgstr "Este equipo ya se encuentra en esta plantilla."
#: build/lib/core/models.py:1591 core/models.py:1591
#: build/lib/core/models.py:1936 core/models.py:1936
msgid "email address"
msgstr "dirección de correo electrónico"
#: build/lib/core/models.py:1610 core/models.py:1610
#: build/lib/core/models.py:1955 core/models.py:1955
msgid "Document invitation"
msgstr "Invitación al documento"
#: build/lib/core/models.py:1611 core/models.py:1611
#: build/lib/core/models.py:1956 core/models.py:1956
msgid "Document invitations"
msgstr "Invitaciones a documentos"
#: build/lib/core/models.py:1631 core/models.py:1631
#: build/lib/core/models.py:1976 core/models.py:1976
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:702 impress/settings.py:702
msgid "Docs AI"
msgstr ""
#: core/templates/mail/html/template.html:153
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr "Logo de correo electrónico"
#: core/templates/mail/html/template.html:200
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr "Abrir"
#: core/templates/mail/html/template.html:217
#: core/templates/mail/html/template.html:219
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr "Docs, su nueva herramienta esencial para organizar, compartir y colaborar en sus documentos como equipo."
#: core/templates/mail/html/template.html:224
#: core/templates/mail/html/template.html:226
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
+188 -120
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"POT-Creation-Date: 2026-02-26 09:13+0000\n"
"PO-Revision-Date: 2026-02-27 08:24\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
@@ -17,69 +17,65 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:36 core/admin.py:36
#: build/lib/core/admin.py:30 core/admin.py:30
msgid "Personal info"
msgstr "Infos Personnelles"
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
#: core/admin.py:161
msgid "Permissions"
msgstr "Permissions"
#: build/lib/core/admin.py:61 core/admin.py:61
#: build/lib/core/admin.py:55 core/admin.py:55
msgid "Important dates"
msgstr "Dates importantes"
#: build/lib/core/admin.py:147 core/admin.py:147
#: build/lib/core/admin.py:112 core/admin.py:112
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
msgid "Process selected user reconciliations"
msgstr "Traiter les rapprochements de l'utilisateur sélectionné"
#: build/lib/core/admin.py:171 core/admin.py:171
msgid "Tree structure"
msgstr "Arborescence"
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
#: build/lib/core/api/filters.py:48 core/api/filters.py:48
msgid "Title"
msgstr "Titre"
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
#: build/lib/core/api/filters.py:62 core/api/filters.py:62
msgid "Creator is me"
msgstr "Je suis l'auteur"
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
#: build/lib/core/api/filters.py:65 core/api/filters.py:65
msgid "Masked"
msgstr "Masqué"
#: build/lib/core/api/filters.py:67 core/api/filters.py:67
#: build/lib/core/api/filters.py:68 core/api/filters.py:68
msgid "Favorite"
msgstr "Favoris"
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
#: build/lib/core/api/serializers.py:513 core/api/serializers.py:513
msgid "A new document was created on your behalf!"
msgstr "Un nouveau document a été créé pour vous !"
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
#: build/lib/core/api/serializers.py:517 core/api/serializers.py:517
msgid "You have been granted ownership of a new document:"
msgstr "Vous avez été déclaré propriétaire d'un nouveau document :"
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
#: build/lib/core/api/serializers.py:553 core/api/serializers.py:553
msgid "This field is required."
msgstr "Ce champ est obligatoire."
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#: build/lib/core/api/serializers.py:564 core/api/serializers.py:564
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr "La portée du lien '%(link_reach)s' n'est pas autorisée en fonction de la configuration du document parent."
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
msgid "Body"
msgstr "Corps"
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
msgid "Body type"
msgstr "Type de corps"
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
msgid "Format"
msgstr "Format"
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#: build/lib/core/api/viewsets.py:1278 core/api/viewsets.py:1278
#, python-brace-format
msgid "copy of {title}"
msgstr "copie de {title}"
@@ -243,224 +239,296 @@ msgstr "utilisateur"
msgid "users"
msgstr "utilisateurs"
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
#: build/lib/core/models.py:360 core/models.py:360
msgid "Active email address"
msgstr "Adresse email active"
#: build/lib/core/models.py:361 core/models.py:361
msgid "Email address to deactivate"
msgstr "Adresse email à désactiver"
#: build/lib/core/models.py:388 core/models.py:388
msgid "Unique ID in the source file"
msgstr "Identifiant unique dans le fichier source"
#: build/lib/core/models.py:394 build/lib/core/models.py:692 core/models.py:394
#: core/models.py:692
msgid "Pending"
msgstr "En attente"
#: build/lib/core/models.py:395 core/models.py:395
msgid "Ready"
msgstr "Prêt"
#: build/lib/core/models.py:396 build/lib/core/models.py:694 core/models.py:396
#: core/models.py:694
msgid "Done"
msgstr "Terminé"
#: build/lib/core/models.py:397 build/lib/core/models.py:695 core/models.py:397
#: core/models.py:695
msgid "Error"
msgstr "Erreur"
#: build/lib/core/models.py:405 core/models.py:405
msgid "user reconciliation"
msgstr "rapprochement de l'utilisateur"
#: build/lib/core/models.py:406 core/models.py:406
msgid "user reconciliations"
msgstr "rapprochements de l'utilisateur"
#: build/lib/core/models.py:644 core/models.py:644
msgid "You have requested a reconciliation of your user accounts on Docs.\n"
" To confirm that you are the one who initiated the request\n"
" and that this email belongs to you:"
msgstr "Vous avez demandé un rapprochement de vos comptes utilisateur sur Docs.\n"
" Pour confirmer que vous êtes bien à l'origine de cette demande\n"
" et que cet e-mail vous appartient :"
#: build/lib/core/models.py:650 core/models.py:650
msgid "Confirm by clicking the link to start the reconciliation"
msgstr "Confirmez en cliquant sur le lien pour commencer le rapprochement"
#: build/lib/core/models.py:655 build/lib/core/models.py:761 core/models.py:655
#: core/models.py:761
msgid "Click here"
msgstr "Cliquez ici"
#: build/lib/core/models.py:656 core/models.py:656
msgid "Confirm"
msgstr "Confirmer"
#: build/lib/core/models.py:667 core/models.py:667
msgid "Your reconciliation request has been processed.\n"
" New documents are likely associated with your account:"
msgstr "Votre demande de rapprochement a été traitée.\n"
" De nouveaux documents sont probablement associés à votre compte :"
#: build/lib/core/models.py:672 core/models.py:672
msgid "Your accounts have been merged"
msgstr "Vos comptes ont été fusionnés"
#: build/lib/core/models.py:677 core/models.py:677
msgid "Click here to see"
msgstr "Cliquez ici pour voir"
#: build/lib/core/models.py:678 core/models.py:678
msgid "See my documents"
msgstr "Voir mes documents"
#: build/lib/core/models.py:688 core/models.py:688
msgid "CSV file"
msgstr "Fichier CSV"
#: build/lib/core/models.py:693 core/models.py:693
msgid "Running"
msgstr "En cours"
#: build/lib/core/models.py:703 core/models.py:703
msgid "user reconciliation CSV import"
msgstr "importation CSV de rapprochement utilisateur"
#: build/lib/core/models.py:704 core/models.py:704
msgid "user reconciliation CSV imports"
msgstr "importations CSV de rapprochement utilisateur"
#: build/lib/core/models.py:748 core/models.py:748
#, python-brace-format
msgid "Your request for reconciliation was unsuccessful.\n"
" Reconciliation failed for the following email addresses:\n"
" {recipient_email}, {other_email}.\n"
" Please check for typos.\n"
" You can submit another request with the valid email addresses."
msgstr "Votre demande de rapprochement n'a pas abouti.\n"
" Le rapprochement a échoué pour les adresses e-mail suivantes :\n"
" {recipient_email}, {other_email}.\n"
" Veuillez vérifier qu'il n'y a pas de fautes de frappe.\n"
" Vous pouvez envoyer une nouvelle demande avec des adresses e-mail valides."
#: build/lib/core/models.py:756 core/models.py:756
msgid "Reconciliation of your Docs accounts not completed"
msgstr "Le rapprochement de vos comptes Docs n'est pas terminé"
#: build/lib/core/models.py:762 core/models.py:762
msgid "Make a new request"
msgstr "Faire une nouvelle demande"
#: build/lib/core/models.py:861 core/models.py:861
msgid "title"
msgstr "titre"
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:862 core/models.py:862
msgid "excerpt"
msgstr "extrait"
#: build/lib/core/models.py:411 core/models.py:411
#: build/lib/core/models.py:911 core/models.py:911
msgid "Document"
msgstr "Document"
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:912 core/models.py:912
msgid "Documents"
msgstr "Documents"
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
#: build/lib/core/models.py:924 build/lib/core/models.py:1328
#: core/models.py:924 core/models.py:1328
msgid "Untitled Document"
msgstr "Document sans titre"
#: build/lib/core/models.py:862 core/models.py:862
#: build/lib/core/models.py:1329 core/models.py:1329
msgid "Open"
msgstr "Ouvrir"
#: build/lib/core/models.py:1364 core/models.py:1364
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} a partagé un document avec vous!"
#: build/lib/core/models.py:866 core/models.py:866
#: build/lib/core/models.py:1368 core/models.py:1368
#, 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:872 core/models.py:872
#: build/lib/core/models.py:1374 core/models.py:1374
#, 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:973 core/models.py:973
#: build/lib/core/models.py:1475 core/models.py:1475
msgid "Document/user link trace"
msgstr "Trace du lien document/utilisateur"
#: build/lib/core/models.py:974 core/models.py:974
#: build/lib/core/models.py:1476 core/models.py:1476
msgid "Document/user link traces"
msgstr "Traces du lien document/utilisateur"
#: build/lib/core/models.py:980 core/models.py:980
#: build/lib/core/models.py:1482 core/models.py:1482
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:1003 core/models.py:1003
#: build/lib/core/models.py:1505 core/models.py:1505
msgid "Document favorite"
msgstr "Document favori"
#: build/lib/core/models.py:1004 core/models.py:1004
#: build/lib/core/models.py:1506 core/models.py:1506
msgid "Document favorites"
msgstr "Documents favoris"
#: build/lib/core/models.py:1010 core/models.py:1010
#: build/lib/core/models.py:1512 core/models.py:1512
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:1032 core/models.py:1032
#: build/lib/core/models.py:1534 core/models.py:1534
msgid "Document/user relation"
msgstr "Relation document/utilisateur"
#: build/lib/core/models.py:1033 core/models.py:1033
#: build/lib/core/models.py:1535 core/models.py:1535
msgid "Document/user relations"
msgstr "Relations document/utilisateur"
#: build/lib/core/models.py:1039 core/models.py:1039
#: build/lib/core/models.py:1541 core/models.py:1541
msgid "This user is already in this document."
msgstr "Cet utilisateur est déjà dans ce document."
#: build/lib/core/models.py:1045 core/models.py:1045
#: build/lib/core/models.py:1547 core/models.py:1547
msgid "This team is already in this document."
msgstr "Cette équipe est déjà dans ce document."
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
#: build/lib/core/models.py:1553 core/models.py:1553
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:1202 core/models.py:1202
#: build/lib/core/models.py:1704 core/models.py:1704
msgid "Document ask for access"
msgstr "Demande d'accès au document"
#: build/lib/core/models.py:1203 core/models.py:1203
#: build/lib/core/models.py:1705 core/models.py:1705
msgid "Document ask for accesses"
msgstr "Demande d'accès au document"
#: build/lib/core/models.py:1209 core/models.py:1209
#: build/lib/core/models.py:1711 core/models.py:1711
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:1266 core/models.py:1266
#: build/lib/core/models.py:1768 core/models.py:1768
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} souhaiterait accéder au document suivant !"
#: build/lib/core/models.py:1270 core/models.py:1270
#: build/lib/core/models.py:1772 core/models.py:1772
#, 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:1276 core/models.py:1276
#: build/lib/core/models.py:1778 core/models.py:1778
#, 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:1318 core/models.py:1318
#: build/lib/core/models.py:1820 core/models.py:1820
msgid "Thread"
msgstr "Conversation"
#: build/lib/core/models.py:1319 core/models.py:1319
#: build/lib/core/models.py:1821 core/models.py:1821
msgid "Threads"
msgstr "Conversations"
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
#: build/lib/core/models.py:1824 build/lib/core/models.py:1876
#: core/models.py:1824 core/models.py:1876
msgid "Anonymous"
msgstr "Anonyme"
#: build/lib/core/models.py:1369 core/models.py:1369
#: build/lib/core/models.py:1871 core/models.py:1871
msgid "Comment"
msgstr "Commentaire"
#: build/lib/core/models.py:1370 core/models.py:1370
#: build/lib/core/models.py:1872 core/models.py:1872
msgid "Comments"
msgstr "Commentaires"
#: build/lib/core/models.py:1419 core/models.py:1419
#: build/lib/core/models.py:1921 core/models.py:1921
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:1423 core/models.py:1423
#: build/lib/core/models.py:1925 core/models.py:1925
msgid "Reaction"
msgstr "Réaction"
#: build/lib/core/models.py:1424 core/models.py:1424
#: build/lib/core/models.py:1926 core/models.py:1926
msgid "Reactions"
msgstr "Réactions"
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr "description"
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr "code"
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr "CSS"
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr "public"
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "Whether this template is public for anyone to use."
msgstr "Si ce modèle est public, utilisable par n'importe qui."
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr "Modèle"
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr "Modèles"
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr "Relation modèle/utilisateur"
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr "Relations modèle/utilisateur"
#: build/lib/core/models.py:1508 core/models.py:1508
msgid "This user is already in this template."
msgstr "Cet utilisateur est déjà dans ce modèle."
#: build/lib/core/models.py:1514 core/models.py:1514
msgid "This team is already in this template."
msgstr "Cette équipe est déjà modèle."
#: build/lib/core/models.py:1591 core/models.py:1591
#: build/lib/core/models.py:1936 core/models.py:1936
msgid "email address"
msgstr "adresse e-mail"
#: build/lib/core/models.py:1610 core/models.py:1610
#: build/lib/core/models.py:1955 core/models.py:1955
msgid "Document invitation"
msgstr "Invitation à un document"
#: build/lib/core/models.py:1611 core/models.py:1611
#: build/lib/core/models.py:1956 core/models.py:1956
msgid "Document invitations"
msgstr "Invitations à un document"
#: build/lib/core/models.py:1631 core/models.py:1631
#: build/lib/core/models.py:1976 core/models.py:1976
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:702 impress/settings.py:702
msgid "Docs AI"
msgstr "Docs IA"
#: core/templates/mail/html/template.html:153
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr "Logo de l'e-mail"
#: core/templates/mail/html/template.html:200
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr "Ouvrir"
#: core/templates/mail/html/template.html:217
#: core/templates/mail/html/template.html:219
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr " Docs, votre nouvel outil incontournable pour organiser, partager et collaborer sur vos documents en équipe. "
#: core/templates/mail/html/template.html:224
#: core/templates/mail/html/template.html:226
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
+181 -120
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"POT-Creation-Date: 2026-02-26 09:13+0000\n"
"PO-Revision-Date: 2026-02-27 08:24\n"
"Last-Translator: \n"
"Language-Team: Italian\n"
"Language: it_IT\n"
@@ -17,69 +17,65 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:36 core/admin.py:36
#: build/lib/core/admin.py:30 core/admin.py:30
msgid "Personal info"
msgstr "Informazioni personali"
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
#: core/admin.py:161
msgid "Permissions"
msgstr "Permessi"
#: build/lib/core/admin.py:61 core/admin.py:61
#: build/lib/core/admin.py:55 core/admin.py:55
msgid "Important dates"
msgstr "Date importanti"
#: build/lib/core/admin.py:147 core/admin.py:147
#: build/lib/core/admin.py:112 core/admin.py:112
msgid "Import job created and queued."
msgstr ""
#: build/lib/core/admin.py:116 core/admin.py:116
msgid "Process selected user reconciliations"
msgstr ""
#: build/lib/core/admin.py:171 core/admin.py:171
msgid "Tree structure"
msgstr "Struttura ad albero"
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
#: build/lib/core/api/filters.py:48 core/api/filters.py:48
msgid "Title"
msgstr "Titolo"
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
#: build/lib/core/api/filters.py:62 core/api/filters.py:62
msgid "Creator is me"
msgstr "Il creatore sono io"
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
#: build/lib/core/api/filters.py:65 core/api/filters.py:65
msgid "Masked"
msgstr ""
#: build/lib/core/api/filters.py:67 core/api/filters.py:67
#: build/lib/core/api/filters.py:68 core/api/filters.py:68
msgid "Favorite"
msgstr "Preferiti"
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
#: build/lib/core/api/serializers.py:513 core/api/serializers.py:513
msgid "A new document was created on your behalf!"
msgstr "Un nuovo documento è stato creato a tuo nome!"
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
#: build/lib/core/api/serializers.py:517 core/api/serializers.py:517
msgid "You have been granted ownership of a new document:"
msgstr "Sei ora proprietario di un nuovo documento:"
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
#: build/lib/core/api/serializers.py:553 core/api/serializers.py:553
msgid "This field is required."
msgstr ""
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#: build/lib/core/api/serializers.py:564 core/api/serializers.py:564
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
msgid "Body"
msgstr "Corpo"
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
msgid "Body type"
msgstr ""
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
msgid "Format"
msgstr "Formato"
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#: build/lib/core/api/viewsets.py:1278 core/api/viewsets.py:1278
#, python-brace-format
msgid "copy of {title}"
msgstr "copia di {title}"
@@ -243,224 +239,289 @@ msgstr "utente"
msgid "users"
msgstr "utenti"
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
#: build/lib/core/models.py:360 core/models.py:360
msgid "Active email address"
msgstr ""
#: build/lib/core/models.py:361 core/models.py:361
msgid "Email address to deactivate"
msgstr ""
#: build/lib/core/models.py:388 core/models.py:388
msgid "Unique ID in the source file"
msgstr ""
#: build/lib/core/models.py:394 build/lib/core/models.py:692 core/models.py:394
#: core/models.py:692
msgid "Pending"
msgstr ""
#: build/lib/core/models.py:395 core/models.py:395
msgid "Ready"
msgstr ""
#: build/lib/core/models.py:396 build/lib/core/models.py:694 core/models.py:396
#: core/models.py:694
msgid "Done"
msgstr ""
#: build/lib/core/models.py:397 build/lib/core/models.py:695 core/models.py:397
#: core/models.py:695
msgid "Error"
msgstr ""
#: build/lib/core/models.py:405 core/models.py:405
msgid "user reconciliation"
msgstr ""
#: build/lib/core/models.py:406 core/models.py:406
msgid "user reconciliations"
msgstr ""
#: build/lib/core/models.py:644 core/models.py:644
msgid "You have requested a reconciliation of your user accounts on Docs.\n"
" To confirm that you are the one who initiated the request\n"
" and that this email belongs to you:"
msgstr ""
#: build/lib/core/models.py:650 core/models.py:650
msgid "Confirm by clicking the link to start the reconciliation"
msgstr ""
#: build/lib/core/models.py:655 build/lib/core/models.py:761 core/models.py:655
#: core/models.py:761
msgid "Click here"
msgstr ""
#: build/lib/core/models.py:656 core/models.py:656
msgid "Confirm"
msgstr ""
#: build/lib/core/models.py:667 core/models.py:667
msgid "Your reconciliation request has been processed.\n"
" New documents are likely associated with your account:"
msgstr ""
#: build/lib/core/models.py:672 core/models.py:672
msgid "Your accounts have been merged"
msgstr ""
#: build/lib/core/models.py:677 core/models.py:677
msgid "Click here to see"
msgstr ""
#: build/lib/core/models.py:678 core/models.py:678
msgid "See my documents"
msgstr ""
#: build/lib/core/models.py:688 core/models.py:688
msgid "CSV file"
msgstr ""
#: build/lib/core/models.py:693 core/models.py:693
msgid "Running"
msgstr ""
#: build/lib/core/models.py:703 core/models.py:703
msgid "user reconciliation CSV import"
msgstr ""
#: build/lib/core/models.py:704 core/models.py:704
msgid "user reconciliation CSV imports"
msgstr ""
#: build/lib/core/models.py:748 core/models.py:748
#, python-brace-format
msgid "Your request for reconciliation was unsuccessful.\n"
" Reconciliation failed for the following email addresses:\n"
" {recipient_email}, {other_email}.\n"
" Please check for typos.\n"
" You can submit another request with the valid email addresses."
msgstr ""
#: build/lib/core/models.py:756 core/models.py:756
msgid "Reconciliation of your Docs accounts not completed"
msgstr ""
#: build/lib/core/models.py:762 core/models.py:762
msgid "Make a new request"
msgstr ""
#: build/lib/core/models.py:861 core/models.py:861
msgid "title"
msgstr "titolo"
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:862 core/models.py:862
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:411 core/models.py:411
#: build/lib/core/models.py:911 core/models.py:911
msgid "Document"
msgstr "Documento"
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:912 core/models.py:912
msgid "Documents"
msgstr "Documenti"
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
#: build/lib/core/models.py:924 build/lib/core/models.py:1328
#: core/models.py:924 core/models.py:1328
msgid "Untitled Document"
msgstr "Documento senza titolo"
#: build/lib/core/models.py:862 core/models.py:862
#: build/lib/core/models.py:1329 core/models.py:1329
msgid "Open"
msgstr "Apri"
#: build/lib/core/models.py:1364 core/models.py:1364
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} ha condiviso un documento con te!"
#: build/lib/core/models.py:866 core/models.py:866
#: build/lib/core/models.py:1368 core/models.py:1368
#, 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:872 core/models.py:872
#: build/lib/core/models.py:1374 core/models.py:1374
#, 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:973 core/models.py:973
#: build/lib/core/models.py:1475 core/models.py:1475
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:974 core/models.py:974
#: build/lib/core/models.py:1476 core/models.py:1476
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:980 core/models.py:980
#: build/lib/core/models.py:1482 core/models.py:1482
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:1003 core/models.py:1003
#: build/lib/core/models.py:1505 core/models.py:1505
msgid "Document favorite"
msgstr "Documento preferito"
#: build/lib/core/models.py:1004 core/models.py:1004
#: build/lib/core/models.py:1506 core/models.py:1506
msgid "Document favorites"
msgstr "Documenti preferiti"
#: build/lib/core/models.py:1010 core/models.py:1010
#: build/lib/core/models.py:1512 core/models.py:1512
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1032 core/models.py:1032
#: build/lib/core/models.py:1534 core/models.py:1534
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1033 core/models.py:1033
#: build/lib/core/models.py:1535 core/models.py:1535
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1039 core/models.py:1039
#: build/lib/core/models.py:1541 core/models.py:1541
msgid "This user is already in this document."
msgstr "Questo utente è già presente in questo documento."
#: build/lib/core/models.py:1045 core/models.py:1045
#: build/lib/core/models.py:1547 core/models.py:1547
msgid "This team is already in this document."
msgstr "Questo team è già presente in questo documento."
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
#: build/lib/core/models.py:1553 core/models.py:1553
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1202 core/models.py:1202
#: build/lib/core/models.py:1704 core/models.py:1704
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1203 core/models.py:1203
#: build/lib/core/models.py:1705 core/models.py:1705
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1209 core/models.py:1209
#: build/lib/core/models.py:1711 core/models.py:1711
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1266 core/models.py:1266
#: build/lib/core/models.py:1768 core/models.py:1768
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1270 core/models.py:1270
#: build/lib/core/models.py:1772 core/models.py:1772
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1276 core/models.py:1276
#: build/lib/core/models.py:1778 core/models.py:1778
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1318 core/models.py:1318
#: build/lib/core/models.py:1820 core/models.py:1820
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1319 core/models.py:1319
#: build/lib/core/models.py:1821 core/models.py:1821
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
#: build/lib/core/models.py:1824 build/lib/core/models.py:1876
#: core/models.py:1824 core/models.py:1876
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1369 core/models.py:1369
#: build/lib/core/models.py:1871 core/models.py:1871
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1370 core/models.py:1370
#: build/lib/core/models.py:1872 core/models.py:1872
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1419 core/models.py:1419
#: build/lib/core/models.py:1921 core/models.py:1921
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1423 core/models.py:1423
#: build/lib/core/models.py:1925 core/models.py:1925
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1424 core/models.py:1424
#: build/lib/core/models.py:1926 core/models.py:1926
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr "descrizione"
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr "code"
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr "pubblico"
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "Whether this template is public for anyone to use."
msgstr "Indica se questo modello è pubblico per chiunque."
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr "Modello"
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr "Modelli"
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1508 core/models.py:1508
msgid "This user is already in this template."
msgstr "Questo utente è già in questo modello."
#: build/lib/core/models.py:1514 core/models.py:1514
msgid "This team is already in this template."
msgstr "Questo team è già in questo modello."
#: build/lib/core/models.py:1591 core/models.py:1591
#: build/lib/core/models.py:1936 core/models.py:1936
msgid "email address"
msgstr "indirizzo e-mail"
#: build/lib/core/models.py:1610 core/models.py:1610
#: build/lib/core/models.py:1955 core/models.py:1955
msgid "Document invitation"
msgstr "Invito al documento"
#: build/lib/core/models.py:1611 core/models.py:1611
#: build/lib/core/models.py:1956 core/models.py:1956
msgid "Document invitations"
msgstr "Inviti al documento"
#: build/lib/core/models.py:1631 core/models.py:1631
#: build/lib/core/models.py:1976 core/models.py:1976
msgid "This email is already associated to a registered user."
msgstr "Questa email è già associata a un utente registrato."
#: build/lib/impress/settings.py:702 impress/settings.py:702
msgid "Docs AI"
msgstr ""
#: core/templates/mail/html/template.html:153
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr "Logo e-mail"
#: core/templates/mail/html/template.html:200
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr "Apri"
#: core/templates/mail/html/template.html:217
#: core/templates/mail/html/template.html:219
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr ""
#: core/templates/mail/html/template.html:224
#: core/templates/mail/html/template.html:226
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
+188 -120
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"POT-Creation-Date: 2026-02-26 09:13+0000\n"
"PO-Revision-Date: 2026-02-27 08:24\n"
"Last-Translator: \n"
"Language-Team: Dutch\n"
"Language: nl_NL\n"
@@ -17,69 +17,65 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:36 core/admin.py:36
#: build/lib/core/admin.py:30 core/admin.py:30
msgid "Personal info"
msgstr "Persoonlijke informatie"
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
#: core/admin.py:161
msgid "Permissions"
msgstr "Machtigingen"
#: build/lib/core/admin.py:61 core/admin.py:61
#: build/lib/core/admin.py:55 core/admin.py:55
msgid "Important dates"
msgstr "Belangrijke data"
#: build/lib/core/admin.py:147 core/admin.py:147
#: build/lib/core/admin.py:112 core/admin.py:112
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
msgid "Process selected user reconciliations"
msgstr "Verwerk geselecteerde gebruikers samenvoeging"
#: build/lib/core/admin.py:171 core/admin.py:171
msgid "Tree structure"
msgstr "Boomstructuur"
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
#: build/lib/core/api/filters.py:48 core/api/filters.py:48
msgid "Title"
msgstr "Titel"
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
#: build/lib/core/api/filters.py:62 core/api/filters.py:62
msgid "Creator is me"
msgstr "Ik ben eigenaar"
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
#: build/lib/core/api/filters.py:65 core/api/filters.py:65
msgid "Masked"
msgstr "Gemaskeerd"
#: build/lib/core/api/filters.py:67 core/api/filters.py:67
#: build/lib/core/api/filters.py:68 core/api/filters.py:68
msgid "Favorite"
msgstr "Favoriet"
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
#: build/lib/core/api/serializers.py:513 core/api/serializers.py:513
msgid "A new document was created on your behalf!"
msgstr "Een nieuw document is namens u gemaakt!"
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
#: build/lib/core/api/serializers.py:517 core/api/serializers.py:517
msgid "You have been granted ownership of a new document:"
msgstr "U heeft eigenaarschap van een nieuw document gekregen:"
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
#: build/lib/core/api/serializers.py:553 core/api/serializers.py:553
msgid "This field is required."
msgstr "Dit veld is verplicht."
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#: build/lib/core/api/serializers.py:564 core/api/serializers.py:564
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr "Link bereik '%(link_reach)s' is niet toegestaan op basis van bovenliggende documentconfiguratie."
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
msgid "Body"
msgstr "Text"
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
msgid "Body type"
msgstr "Text type"
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
msgid "Format"
msgstr "Formaat"
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#: build/lib/core/api/viewsets.py:1278 core/api/viewsets.py:1278
#, python-brace-format
msgid "copy of {title}"
msgstr "kopie van {title}"
@@ -243,224 +239,296 @@ msgstr "gebruiker"
msgid "users"
msgstr "gebruikers"
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
#: build/lib/core/models.py:360 core/models.py:360
msgid "Active email address"
msgstr "Actieve e-mail adres"
#: build/lib/core/models.py:361 core/models.py:361
msgid "Email address to deactivate"
msgstr "E-mailadres om te deactiveren"
#: build/lib/core/models.py:388 core/models.py:388
msgid "Unique ID in the source file"
msgstr "Unieke ID in het bronbestand"
#: build/lib/core/models.py:394 build/lib/core/models.py:692 core/models.py:394
#: core/models.py:692
msgid "Pending"
msgstr "In behandeling"
#: build/lib/core/models.py:395 core/models.py:395
msgid "Ready"
msgstr "Klaar"
#: build/lib/core/models.py:396 build/lib/core/models.py:694 core/models.py:396
#: core/models.py:694
msgid "Done"
msgstr "Klaar"
#: build/lib/core/models.py:397 build/lib/core/models.py:695 core/models.py:397
#: core/models.py:695
msgid "Error"
msgstr "Fout"
#: build/lib/core/models.py:405 core/models.py:405
msgid "user reconciliation"
msgstr "gebruiker samenvoegen"
#: build/lib/core/models.py:406 core/models.py:406
msgid "user reconciliations"
msgstr "gebruikers samenvoegen"
#: build/lib/core/models.py:644 core/models.py:644
msgid "You have requested a reconciliation of your user accounts on Docs.\n"
" To confirm that you are the one who initiated the request\n"
" and that this email belongs to you:"
msgstr "Je hebt gevraagd om een samenvoeging van je gebruikersaccounts op Docs.\n"
" Om te bevestigen dat u degene bent die het verzoek\n"
" heeft geïnitieerd en dat deze e-mail van u is:"
#: build/lib/core/models.py:650 core/models.py:650
msgid "Confirm by clicking the link to start the reconciliation"
msgstr "Bevestig door te klikken op de link om de samenvoeging te starten"
#: build/lib/core/models.py:655 build/lib/core/models.py:761 core/models.py:655
#: core/models.py:761
msgid "Click here"
msgstr "Klik hier"
#: build/lib/core/models.py:656 core/models.py:656
msgid "Confirm"
msgstr "Bevestig"
#: build/lib/core/models.py:667 core/models.py:667
msgid "Your reconciliation request has been processed.\n"
" New documents are likely associated with your account:"
msgstr "Uw samenvoegingsverzoek is verwerkt.\n"
" Nieuwe documenten worden waarschijnlijk geassocieerd met uw account:"
#: build/lib/core/models.py:672 core/models.py:672
msgid "Your accounts have been merged"
msgstr "Je accounts zijn samengevoegd"
#: build/lib/core/models.py:677 core/models.py:677
msgid "Click here to see"
msgstr "Klik hier om te bekijken"
#: build/lib/core/models.py:678 core/models.py:678
msgid "See my documents"
msgstr "Mijn documenten bekijken"
#: build/lib/core/models.py:688 core/models.py:688
msgid "CSV file"
msgstr "CSV bestand"
#: build/lib/core/models.py:693 core/models.py:693
msgid "Running"
msgstr "Bezig"
#: build/lib/core/models.py:703 core/models.py:703
msgid "user reconciliation CSV import"
msgstr "gebruiker samenvoeging CSV import"
#: build/lib/core/models.py:704 core/models.py:704
msgid "user reconciliation CSV imports"
msgstr "gebruiker reconciliation CSV imports"
#: build/lib/core/models.py:748 core/models.py:748
#, python-brace-format
msgid "Your request for reconciliation was unsuccessful.\n"
" Reconciliation failed for the following email addresses:\n"
" {recipient_email}, {other_email}.\n"
" Please check for typos.\n"
" You can submit another request with the valid email addresses."
msgstr "Uw verzoek tot verzoening is mislukt.\n"
" Verzoening mislukt voor de volgende e-mailadressen:\n"
" {recipient_email}, {other_email}.\n"
" Controleer op typefouten.\n"
" U kunt een ander verzoek indienen met de geldige e-mailadressen."
#: build/lib/core/models.py:756 core/models.py:756
msgid "Reconciliation of your Docs accounts not completed"
msgstr "Samenvoeging van je Docs accounts is niet voltooid"
#: build/lib/core/models.py:762 core/models.py:762
msgid "Make a new request"
msgstr "Maak een nieuw verzoek"
#: build/lib/core/models.py:861 core/models.py:861
msgid "title"
msgstr "titel"
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:862 core/models.py:862
msgid "excerpt"
msgstr "uittreksel"
#: build/lib/core/models.py:411 core/models.py:411
#: build/lib/core/models.py:911 core/models.py:911
msgid "Document"
msgstr "Document"
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:912 core/models.py:912
msgid "Documents"
msgstr "Documenten"
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
#: build/lib/core/models.py:924 build/lib/core/models.py:1328
#: core/models.py:924 core/models.py:1328
msgid "Untitled Document"
msgstr "Naamloos Document"
#: build/lib/core/models.py:862 core/models.py:862
#: build/lib/core/models.py:1329 core/models.py:1329
msgid "Open"
msgstr "Open"
#: build/lib/core/models.py:1364 core/models.py:1364
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} heeft een document met u gedeeld!"
#: build/lib/core/models.py:866 core/models.py:866
#: build/lib/core/models.py:1368 core/models.py:1368
#, 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:872 core/models.py:872
#: build/lib/core/models.py:1374 core/models.py:1374
#, 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:973 core/models.py:973
#: build/lib/core/models.py:1475 core/models.py:1475
msgid "Document/user link trace"
msgstr "Document/gebruiker link"
#: build/lib/core/models.py:974 core/models.py:974
#: build/lib/core/models.py:1476 core/models.py:1476
msgid "Document/user link traces"
msgstr "Document/gebruiker link"
#: build/lib/core/models.py:980 core/models.py:980
#: build/lib/core/models.py:1482 core/models.py:1482
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:1003 core/models.py:1003
#: build/lib/core/models.py:1505 core/models.py:1505
msgid "Document favorite"
msgstr "Document favoriet"
#: build/lib/core/models.py:1004 core/models.py:1004
#: build/lib/core/models.py:1506 core/models.py:1506
msgid "Document favorites"
msgstr "Document favorieten"
#: build/lib/core/models.py:1010 core/models.py:1010
#: build/lib/core/models.py:1512 core/models.py:1512
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:1032 core/models.py:1032
#: build/lib/core/models.py:1534 core/models.py:1534
msgid "Document/user relation"
msgstr "Document/gebruiker relatie"
#: build/lib/core/models.py:1033 core/models.py:1033
#: build/lib/core/models.py:1535 core/models.py:1535
msgid "Document/user relations"
msgstr "Document/gebruiker relaties"
#: build/lib/core/models.py:1039 core/models.py:1039
#: build/lib/core/models.py:1541 core/models.py:1541
msgid "This user is already in this document."
msgstr "De gebruiker bestaat al in dit document."
#: build/lib/core/models.py:1045 core/models.py:1045
#: build/lib/core/models.py:1547 core/models.py:1547
msgid "This team is already in this document."
msgstr "Dit team bestaat al in dit document."
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
#: build/lib/core/models.py:1553 core/models.py:1553
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:1202 core/models.py:1202
#: build/lib/core/models.py:1704 core/models.py:1704
msgid "Document ask for access"
msgstr "Document verzoekt om toegang"
#: build/lib/core/models.py:1203 core/models.py:1203
#: build/lib/core/models.py:1705 core/models.py:1705
msgid "Document ask for accesses"
msgstr "Document verzoekt om toegangen"
#: build/lib/core/models.py:1209 core/models.py:1209
#: build/lib/core/models.py:1711 core/models.py:1711
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:1266 core/models.py:1266
#: build/lib/core/models.py:1768 core/models.py:1768
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} verzoekt toegang tot een document!"
#: build/lib/core/models.py:1270 core/models.py:1270
#: build/lib/core/models.py:1772 core/models.py:1772
#, 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:1276 core/models.py:1276
#: build/lib/core/models.py:1778 core/models.py:1778
#, 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:1318 core/models.py:1318
#: build/lib/core/models.py:1820 core/models.py:1820
msgid "Thread"
msgstr "Kanaal"
#: build/lib/core/models.py:1319 core/models.py:1319
#: build/lib/core/models.py:1821 core/models.py:1821
msgid "Threads"
msgstr "Kanalen"
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
#: build/lib/core/models.py:1824 build/lib/core/models.py:1876
#: core/models.py:1824 core/models.py:1876
msgid "Anonymous"
msgstr "Anoniem"
#: build/lib/core/models.py:1369 core/models.py:1369
#: build/lib/core/models.py:1871 core/models.py:1871
msgid "Comment"
msgstr "Reactie"
#: build/lib/core/models.py:1370 core/models.py:1370
#: build/lib/core/models.py:1872 core/models.py:1872
msgid "Comments"
msgstr "Reacties"
#: build/lib/core/models.py:1419 core/models.py:1419
#: build/lib/core/models.py:1921 core/models.py:1921
msgid "This emoji has already been reacted to this comment."
msgstr "Deze emoji is al op deze opmerking gereageerd."
#: build/lib/core/models.py:1423 core/models.py:1423
#: build/lib/core/models.py:1925 core/models.py:1925
msgid "Reaction"
msgstr "Reactie"
#: build/lib/core/models.py:1424 core/models.py:1424
#: build/lib/core/models.py:1926 core/models.py:1926
msgid "Reactions"
msgstr "Reacties"
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr "omschrijving"
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr "code"
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr "publiek"
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "Whether this template is public for anyone to use."
msgstr "Of dit sjabloon door iedereen publiekelijk te gebruiken is."
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr "Sjabloon"
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr "Sjabloon"
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr "Sjabloon/gebruiker relatie"
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr "Sjabloon/gebruiker relaties"
#: build/lib/core/models.py:1508 core/models.py:1508
msgid "This user is already in this template."
msgstr "De gebruiker bestaat al in dit sjabloon."
#: build/lib/core/models.py:1514 core/models.py:1514
msgid "This team is already in this template."
msgstr "Het team bestaat al in dit sjabloon."
#: build/lib/core/models.py:1591 core/models.py:1591
#: build/lib/core/models.py:1936 core/models.py:1936
msgid "email address"
msgstr "e-mailadres"
#: build/lib/core/models.py:1610 core/models.py:1610
#: build/lib/core/models.py:1955 core/models.py:1955
msgid "Document invitation"
msgstr "Document uitnodiging"
#: build/lib/core/models.py:1611 core/models.py:1611
#: build/lib/core/models.py:1956 core/models.py:1956
msgid "Document invitations"
msgstr "Document uitnodigingen"
#: build/lib/core/models.py:1631 core/models.py:1631
#: build/lib/core/models.py:1976 core/models.py:1976
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:702 impress/settings.py:702
msgid "Docs AI"
msgstr ""
#: core/templates/mail/html/template.html:153
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr "Logo email"
#: core/templates/mail/html/template.html:200
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr "Open"
#: core/templates/mail/html/template.html:217
#: core/templates/mail/html/template.html:219
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr " Docs, jouw nieuwe essentiële tool voor het organiseren, delen en collaboreren van documenten als team. "
#: core/templates/mail/html/template.html:224
#: core/templates/mail/html/template.html:226
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
+181 -120
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"POT-Creation-Date: 2026-02-26 09:13+0000\n"
"PO-Revision-Date: 2026-02-27 08:24\n"
"Last-Translator: \n"
"Language-Team: Portuguese\n"
"Language: pt_PT\n"
@@ -17,69 +17,65 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:36 core/admin.py:36
#: build/lib/core/admin.py:30 core/admin.py:30
msgid "Personal info"
msgstr "Informações Pessoais"
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
#: core/admin.py:161
msgid "Permissions"
msgstr "Permissões"
#: build/lib/core/admin.py:61 core/admin.py:61
#: build/lib/core/admin.py:55 core/admin.py:55
msgid "Important dates"
msgstr "Datas importantes"
#: build/lib/core/admin.py:147 core/admin.py:147
#: build/lib/core/admin.py:112 core/admin.py:112
msgid "Import job created and queued."
msgstr ""
#: build/lib/core/admin.py:116 core/admin.py:116
msgid "Process selected user reconciliations"
msgstr ""
#: build/lib/core/admin.py:171 core/admin.py:171
msgid "Tree structure"
msgstr "Estrutura de árvore"
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
#: build/lib/core/api/filters.py:48 core/api/filters.py:48
msgid "Title"
msgstr "Título"
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
#: build/lib/core/api/filters.py:62 core/api/filters.py:62
msgid "Creator is me"
msgstr "Eu sou o criador"
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
#: build/lib/core/api/filters.py:65 core/api/filters.py:65
msgid "Masked"
msgstr ""
#: build/lib/core/api/filters.py:67 core/api/filters.py:67
#: build/lib/core/api/filters.py:68 core/api/filters.py:68
msgid "Favorite"
msgstr "Favorito"
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
#: build/lib/core/api/serializers.py:513 core/api/serializers.py:513
msgid "A new document was created on your behalf!"
msgstr "Um novo documento foi criado em seu nome!"
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
#: build/lib/core/api/serializers.py:517 core/api/serializers.py:517
msgid "You have been granted ownership of a new document:"
msgstr "A propriedade de um novo documento foi concedida a você:"
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
#: build/lib/core/api/serializers.py:553 core/api/serializers.py:553
msgid "This field is required."
msgstr ""
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#: build/lib/core/api/serializers.py:564 core/api/serializers.py:564
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
msgid "Body"
msgstr "Corpo"
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
msgid "Body type"
msgstr "Tipo de corpo"
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
msgid "Format"
msgstr "Formato"
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#: build/lib/core/api/viewsets.py:1278 core/api/viewsets.py:1278
#, python-brace-format
msgid "copy of {title}"
msgstr "cópia de {title}"
@@ -243,224 +239,289 @@ msgstr ""
msgid "users"
msgstr ""
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
#: build/lib/core/models.py:360 core/models.py:360
msgid "Active email address"
msgstr ""
#: build/lib/core/models.py:361 core/models.py:361
msgid "Email address to deactivate"
msgstr ""
#: build/lib/core/models.py:388 core/models.py:388
msgid "Unique ID in the source file"
msgstr ""
#: build/lib/core/models.py:394 build/lib/core/models.py:692 core/models.py:394
#: core/models.py:692
msgid "Pending"
msgstr ""
#: build/lib/core/models.py:395 core/models.py:395
msgid "Ready"
msgstr ""
#: build/lib/core/models.py:396 build/lib/core/models.py:694 core/models.py:396
#: core/models.py:694
msgid "Done"
msgstr ""
#: build/lib/core/models.py:397 build/lib/core/models.py:695 core/models.py:397
#: core/models.py:695
msgid "Error"
msgstr ""
#: build/lib/core/models.py:405 core/models.py:405
msgid "user reconciliation"
msgstr ""
#: build/lib/core/models.py:406 core/models.py:406
msgid "user reconciliations"
msgstr ""
#: build/lib/core/models.py:644 core/models.py:644
msgid "You have requested a reconciliation of your user accounts on Docs.\n"
" To confirm that you are the one who initiated the request\n"
" and that this email belongs to you:"
msgstr ""
#: build/lib/core/models.py:650 core/models.py:650
msgid "Confirm by clicking the link to start the reconciliation"
msgstr ""
#: build/lib/core/models.py:655 build/lib/core/models.py:761 core/models.py:655
#: core/models.py:761
msgid "Click here"
msgstr ""
#: build/lib/core/models.py:656 core/models.py:656
msgid "Confirm"
msgstr ""
#: build/lib/core/models.py:667 core/models.py:667
msgid "Your reconciliation request has been processed.\n"
" New documents are likely associated with your account:"
msgstr ""
#: build/lib/core/models.py:672 core/models.py:672
msgid "Your accounts have been merged"
msgstr ""
#: build/lib/core/models.py:677 core/models.py:677
msgid "Click here to see"
msgstr ""
#: build/lib/core/models.py:678 core/models.py:678
msgid "See my documents"
msgstr ""
#: build/lib/core/models.py:688 core/models.py:688
msgid "CSV file"
msgstr ""
#: build/lib/core/models.py:693 core/models.py:693
msgid "Running"
msgstr ""
#: build/lib/core/models.py:703 core/models.py:703
msgid "user reconciliation CSV import"
msgstr ""
#: build/lib/core/models.py:704 core/models.py:704
msgid "user reconciliation CSV imports"
msgstr ""
#: build/lib/core/models.py:748 core/models.py:748
#, python-brace-format
msgid "Your request for reconciliation was unsuccessful.\n"
" Reconciliation failed for the following email addresses:\n"
" {recipient_email}, {other_email}.\n"
" Please check for typos.\n"
" You can submit another request with the valid email addresses."
msgstr ""
#: build/lib/core/models.py:756 core/models.py:756
msgid "Reconciliation of your Docs accounts not completed"
msgstr ""
#: build/lib/core/models.py:762 core/models.py:762
msgid "Make a new request"
msgstr ""
#: build/lib/core/models.py:861 core/models.py:861
msgid "title"
msgstr ""
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:862 core/models.py:862
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:411 core/models.py:411
#: build/lib/core/models.py:911 core/models.py:911
msgid "Document"
msgstr ""
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:912 core/models.py:912
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
#: build/lib/core/models.py:924 build/lib/core/models.py:1328
#: core/models.py:924 core/models.py:1328
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:862 core/models.py:862
#: build/lib/core/models.py:1329 core/models.py:1329
msgid "Open"
msgstr ""
#: build/lib/core/models.py:1364 core/models.py:1364
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:866 core/models.py:866
#: build/lib/core/models.py:1368 core/models.py:1368
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:872 core/models.py:872
#: build/lib/core/models.py:1374 core/models.py:1374
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:973 core/models.py:973
#: build/lib/core/models.py:1475 core/models.py:1475
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:974 core/models.py:974
#: build/lib/core/models.py:1476 core/models.py:1476
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:980 core/models.py:980
#: build/lib/core/models.py:1482 core/models.py:1482
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:1003 core/models.py:1003
#: build/lib/core/models.py:1505 core/models.py:1505
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:1004 core/models.py:1004
#: build/lib/core/models.py:1506 core/models.py:1506
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1010 core/models.py:1010
#: build/lib/core/models.py:1512 core/models.py:1512
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1032 core/models.py:1032
#: build/lib/core/models.py:1534 core/models.py:1534
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1033 core/models.py:1033
#: build/lib/core/models.py:1535 core/models.py:1535
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1039 core/models.py:1039
#: build/lib/core/models.py:1541 core/models.py:1541
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1045 core/models.py:1045
#: build/lib/core/models.py:1547 core/models.py:1547
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
#: build/lib/core/models.py:1553 core/models.py:1553
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1202 core/models.py:1202
#: build/lib/core/models.py:1704 core/models.py:1704
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1203 core/models.py:1203
#: build/lib/core/models.py:1705 core/models.py:1705
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1209 core/models.py:1209
#: build/lib/core/models.py:1711 core/models.py:1711
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1266 core/models.py:1266
#: build/lib/core/models.py:1768 core/models.py:1768
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1270 core/models.py:1270
#: build/lib/core/models.py:1772 core/models.py:1772
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1276 core/models.py:1276
#: build/lib/core/models.py:1778 core/models.py:1778
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1318 core/models.py:1318
#: build/lib/core/models.py:1820 core/models.py:1820
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1319 core/models.py:1319
#: build/lib/core/models.py:1821 core/models.py:1821
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
#: build/lib/core/models.py:1824 build/lib/core/models.py:1876
#: core/models.py:1824 core/models.py:1876
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1369 core/models.py:1369
#: build/lib/core/models.py:1871 core/models.py:1871
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1370 core/models.py:1370
#: build/lib/core/models.py:1872 core/models.py:1872
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1419 core/models.py:1419
#: build/lib/core/models.py:1921 core/models.py:1921
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1423 core/models.py:1423
#: build/lib/core/models.py:1925 core/models.py:1925
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1424 core/models.py:1424
#: build/lib/core/models.py:1926 core/models.py:1926
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr ""
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr ""
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr ""
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr ""
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1508 core/models.py:1508
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1514 core/models.py:1514
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1591 core/models.py:1591
#: build/lib/core/models.py:1936 core/models.py:1936
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1610 core/models.py:1610
#: build/lib/core/models.py:1955 core/models.py:1955
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1611 core/models.py:1611
#: build/lib/core/models.py:1956 core/models.py:1956
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1631 core/models.py:1631
#: build/lib/core/models.py:1976 core/models.py:1976
msgid "This email is already associated to a registered user."
msgstr ""
#: build/lib/impress/settings.py:702 impress/settings.py:702
msgid "Docs AI"
msgstr ""
#: core/templates/mail/html/template.html:153
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr ""
#: core/templates/mail/html/template.html:200
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr ""
#: core/templates/mail/html/template.html:217
#: core/templates/mail/html/template.html:219
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr ""
#: core/templates/mail/html/template.html:224
#: core/templates/mail/html/template.html:226
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
+188 -120
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"POT-Creation-Date: 2026-02-26 09:13+0000\n"
"PO-Revision-Date: 2026-02-27 08:24\n"
"Last-Translator: \n"
"Language-Team: Russian\n"
"Language: ru_RU\n"
@@ -17,69 +17,65 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:36 core/admin.py:36
#: build/lib/core/admin.py:30 core/admin.py:30
msgid "Personal info"
msgstr "Личная информация"
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
#: core/admin.py:161
msgid "Permissions"
msgstr "Разрешения"
#: build/lib/core/admin.py:61 core/admin.py:61
#: build/lib/core/admin.py:55 core/admin.py:55
msgid "Important dates"
msgstr "Важные даты"
#: build/lib/core/admin.py:147 core/admin.py:147
#: build/lib/core/admin.py:112 core/admin.py:112
msgid "Import job created and queued."
msgstr "Задание по импорту создано и поставлено в очередь."
#: build/lib/core/admin.py:116 core/admin.py:116
msgid "Process selected user reconciliations"
msgstr "Обработка выбранных пользовательских сверок"
#: build/lib/core/admin.py:171 core/admin.py:171
msgid "Tree structure"
msgstr "Древовидная структура"
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
#: build/lib/core/api/filters.py:48 core/api/filters.py:48
msgid "Title"
msgstr "Заголовок"
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
#: build/lib/core/api/filters.py:62 core/api/filters.py:62
msgid "Creator is me"
msgstr "Создатель - я"
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
#: build/lib/core/api/filters.py:65 core/api/filters.py:65
msgid "Masked"
msgstr "Скрытый"
#: build/lib/core/api/filters.py:67 core/api/filters.py:67
#: build/lib/core/api/filters.py:68 core/api/filters.py:68
msgid "Favorite"
msgstr "Избранное"
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
#: build/lib/core/api/serializers.py:513 core/api/serializers.py:513
msgid "A new document was created on your behalf!"
msgstr "Новый документ был создан от вашего имени!"
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
#: build/lib/core/api/serializers.py:517 core/api/serializers.py:517
msgid "You have been granted ownership of a new document:"
msgstr "Вы назначены владельцем для нового документа:"
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
#: build/lib/core/api/serializers.py:553 core/api/serializers.py:553
msgid "This field is required."
msgstr "Это поле обязательное."
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#: build/lib/core/api/serializers.py:564 core/api/serializers.py:564
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr "Доступ по ссылке '%(link_reach)s' запрещён в соответствии с настройками родительского документа."
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
msgid "Body"
msgstr "Текст сообщения"
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
msgid "Body type"
msgstr "Тип сообщения"
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
msgid "Format"
msgstr "Формат"
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#: build/lib/core/api/viewsets.py:1278 core/api/viewsets.py:1278
#, python-brace-format
msgid "copy of {title}"
msgstr "копия {title}"
@@ -243,224 +239,296 @@ msgstr "пользователь"
msgid "users"
msgstr "пользователи"
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
#: build/lib/core/models.py:360 core/models.py:360
msgid "Active email address"
msgstr "Активный адрес электронной почты"
#: build/lib/core/models.py:361 core/models.py:361
msgid "Email address to deactivate"
msgstr "Адрес электронной почты для деактивации"
#: build/lib/core/models.py:388 core/models.py:388
msgid "Unique ID in the source file"
msgstr "Уникальный идентификатор в исходном файле"
#: build/lib/core/models.py:394 build/lib/core/models.py:692 core/models.py:394
#: core/models.py:692
msgid "Pending"
msgstr "В обработке"
#: build/lib/core/models.py:395 core/models.py:395
msgid "Ready"
msgstr "Готово"
#: build/lib/core/models.py:396 build/lib/core/models.py:694 core/models.py:396
#: core/models.py:694
msgid "Done"
msgstr "Выполнено"
#: build/lib/core/models.py:397 build/lib/core/models.py:695 core/models.py:397
#: core/models.py:695
msgid "Error"
msgstr "Ошибка"
#: build/lib/core/models.py:405 core/models.py:405
msgid "user reconciliation"
msgstr "сверка данных пользователя"
#: build/lib/core/models.py:406 core/models.py:406
msgid "user reconciliations"
msgstr "сверки данных пользователя"
#: build/lib/core/models.py:644 core/models.py:644
msgid "You have requested a reconciliation of your user accounts on Docs.\n"
" To confirm that you are the one who initiated the request\n"
" and that this email belongs to you:"
msgstr "Вы запросили сверку учётных записей пользователя в Docs.\n"
" Чтобы подтвердить факт того, что вы являетесь инициатором запроса\n"
" и что этот адрес принадлежит вам:"
#: build/lib/core/models.py:650 core/models.py:650
msgid "Confirm by clicking the link to start the reconciliation"
msgstr "Чтобы начать сверку, подтвердите это, нажав на ссылку"
#: build/lib/core/models.py:655 build/lib/core/models.py:761 core/models.py:655
#: core/models.py:761
msgid "Click here"
msgstr "Нажмите здесь"
#: build/lib/core/models.py:656 core/models.py:656
msgid "Confirm"
msgstr "Подтверждение"
#: build/lib/core/models.py:667 core/models.py:667
msgid "Your reconciliation request has been processed.\n"
" New documents are likely associated with your account:"
msgstr "Ваш запрос на сверку был обработан.\n"
" Новые документы, вероятно, связаны с вашей учётной записью:"
#: build/lib/core/models.py:672 core/models.py:672
msgid "Your accounts have been merged"
msgstr "Ваши учётные записи были объединены"
#: build/lib/core/models.py:677 core/models.py:677
msgid "Click here to see"
msgstr "Нажмите здесь, чтобы просмотреть"
#: build/lib/core/models.py:678 core/models.py:678
msgid "See my documents"
msgstr "Просмотреть мои документы"
#: build/lib/core/models.py:688 core/models.py:688
msgid "CSV file"
msgstr "CSV-файл"
#: build/lib/core/models.py:693 core/models.py:693
msgid "Running"
msgstr "Выполнение"
#: build/lib/core/models.py:703 core/models.py:703
msgid "user reconciliation CSV import"
msgstr "импорт из CSV сверки пользователей"
#: build/lib/core/models.py:704 core/models.py:704
msgid "user reconciliation CSV imports"
msgstr "импорты из CSV сверки пользователями"
#: build/lib/core/models.py:748 core/models.py:748
#, python-brace-format
msgid "Your request for reconciliation was unsuccessful.\n"
" Reconciliation failed for the following email addresses:\n"
" {recipient_email}, {other_email}.\n"
" Please check for typos.\n"
" You can submit another request with the valid email addresses."
msgstr "Ваш запрос на сверку не удался.\n"
" Сверка не удалась для следующих email адресов:\n"
" {recipient_email}, {other_email}.\n"
" Пожалуйста, проверьте, нет ли в них опечаток.\n"
" Вы можете отправить ещё один запрос с действительными адресами электронной почты."
#: build/lib/core/models.py:756 core/models.py:756
msgid "Reconciliation of your Docs accounts not completed"
msgstr "Сверка ваших учётных записей Docs не завершена"
#: build/lib/core/models.py:762 core/models.py:762
msgid "Make a new request"
msgstr "Создать новый запрос"
#: build/lib/core/models.py:861 core/models.py:861
msgid "title"
msgstr "заголовок"
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:862 core/models.py:862
msgid "excerpt"
msgstr "отрывок"
#: build/lib/core/models.py:411 core/models.py:411
#: build/lib/core/models.py:911 core/models.py:911
msgid "Document"
msgstr "Документ"
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:912 core/models.py:912
msgid "Documents"
msgstr "Документы"
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
#: build/lib/core/models.py:924 build/lib/core/models.py:1328
#: core/models.py:924 core/models.py:1328
msgid "Untitled Document"
msgstr "Безымянный документ"
#: build/lib/core/models.py:862 core/models.py:862
#: build/lib/core/models.py:1329 core/models.py:1329
msgid "Open"
msgstr "Открыть"
#: build/lib/core/models.py:1364 core/models.py:1364
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} делится с вами документом!"
#: build/lib/core/models.py:866 core/models.py:866
#: build/lib/core/models.py:1368 core/models.py:1368
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} приглашает вас присоединиться к следующему документу с ролью \"{role}\":"
#: build/lib/core/models.py:872 core/models.py:872
#: build/lib/core/models.py:1374 core/models.py:1374
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} делится с вами документом: {title}"
#: build/lib/core/models.py:973 core/models.py:973
#: build/lib/core/models.py:1475 core/models.py:1475
msgid "Document/user link trace"
msgstr "Трассировка связи документ/пользователь"
#: build/lib/core/models.py:974 core/models.py:974
#: build/lib/core/models.py:1476 core/models.py:1476
msgid "Document/user link traces"
msgstr "Трассировка связей документ/пользователь"
#: build/lib/core/models.py:980 core/models.py:980
#: build/lib/core/models.py:1482 core/models.py:1482
msgid "A link trace already exists for this document/user."
msgstr "Для этого документа/пользователя уже существует трассировка ссылки."
#: build/lib/core/models.py:1003 core/models.py:1003
#: build/lib/core/models.py:1505 core/models.py:1505
msgid "Document favorite"
msgstr "Избранный документ"
#: build/lib/core/models.py:1004 core/models.py:1004
#: build/lib/core/models.py:1506 core/models.py:1506
msgid "Document favorites"
msgstr "Избранные документы"
#: build/lib/core/models.py:1010 core/models.py:1010
#: build/lib/core/models.py:1512 core/models.py:1512
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Этот документ уже помечен как избранный для этого пользователя."
#: build/lib/core/models.py:1032 core/models.py:1032
#: build/lib/core/models.py:1534 core/models.py:1534
msgid "Document/user relation"
msgstr "Отношение документ/пользователь"
#: build/lib/core/models.py:1033 core/models.py:1033
#: build/lib/core/models.py:1535 core/models.py:1535
msgid "Document/user relations"
msgstr "Отношения документ/пользователь"
#: build/lib/core/models.py:1039 core/models.py:1039
#: build/lib/core/models.py:1541 core/models.py:1541
msgid "This user is already in this document."
msgstr "Этот пользователь уже имеет доступ к этому документу."
#: build/lib/core/models.py:1045 core/models.py:1045
#: build/lib/core/models.py:1547 core/models.py:1547
msgid "This team is already in this document."
msgstr "Эта команда уже имеет доступ к этому документу."
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
#: build/lib/core/models.py:1553 core/models.py:1553
msgid "Either user or team must be set, not both."
msgstr "Может быть выбран либо пользователь, либо команда, но не оба варианта сразу."
#: build/lib/core/models.py:1202 core/models.py:1202
#: build/lib/core/models.py:1704 core/models.py:1704
msgid "Document ask for access"
msgstr "Документ запрашивает доступ"
#: build/lib/core/models.py:1203 core/models.py:1203
#: build/lib/core/models.py:1705 core/models.py:1705
msgid "Document ask for accesses"
msgstr "Документ запрашивает доступы"
#: build/lib/core/models.py:1209 core/models.py:1209
#: build/lib/core/models.py:1711 core/models.py:1711
msgid "This user has already asked for access to this document."
msgstr "Этот пользователь уже запросил доступ к этому документу."
#: build/lib/core/models.py:1266 core/models.py:1266
#: build/lib/core/models.py:1768 core/models.py:1768
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} хочет получить доступ к документу!"
#: build/lib/core/models.py:1270 core/models.py:1270
#: build/lib/core/models.py:1772 core/models.py:1772
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} хочет получить доступ к следующему документу:"
#: build/lib/core/models.py:1276 core/models.py:1276
#: build/lib/core/models.py:1778 core/models.py:1778
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} запрашивает доступ к документу: {title}"
#: build/lib/core/models.py:1318 core/models.py:1318
#: build/lib/core/models.py:1820 core/models.py:1820
msgid "Thread"
msgstr "Обсуждение"
#: build/lib/core/models.py:1319 core/models.py:1319
#: build/lib/core/models.py:1821 core/models.py:1821
msgid "Threads"
msgstr "Обсуждения"
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
#: build/lib/core/models.py:1824 build/lib/core/models.py:1876
#: core/models.py:1824 core/models.py:1876
msgid "Anonymous"
msgstr "Аноним"
#: build/lib/core/models.py:1369 core/models.py:1369
#: build/lib/core/models.py:1871 core/models.py:1871
msgid "Comment"
msgstr "Комментарий"
#: build/lib/core/models.py:1370 core/models.py:1370
#: build/lib/core/models.py:1872 core/models.py:1872
msgid "Comments"
msgstr "Комментарии"
#: build/lib/core/models.py:1419 core/models.py:1419
#: build/lib/core/models.py:1921 core/models.py:1921
msgid "This emoji has already been reacted to this comment."
msgstr "Этот эмодзи уже использован в этом комментарии."
#: build/lib/core/models.py:1423 core/models.py:1423
#: build/lib/core/models.py:1925 core/models.py:1925
msgid "Reaction"
msgstr "Реакция"
#: build/lib/core/models.py:1424 core/models.py:1424
#: build/lib/core/models.py:1926 core/models.py:1926
msgid "Reactions"
msgstr "Реакции"
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr "описание"
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr "код"
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr "доступно всем"
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "Whether this template is public for anyone to use."
msgstr "Этот шаблон доступен всем пользователям."
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr "Шаблон"
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr "Шаблоны"
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr "Отношение шаблон/пользователь"
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr "Отношения шаблон/пользователь"
#: build/lib/core/models.py:1508 core/models.py:1508
msgid "This user is already in this template."
msgstr "Этот пользователь уже указан в этом шаблоне."
#: build/lib/core/models.py:1514 core/models.py:1514
msgid "This team is already in this template."
msgstr "Эта команда уже указана в этом шаблоне."
#: build/lib/core/models.py:1591 core/models.py:1591
#: build/lib/core/models.py:1936 core/models.py:1936
msgid "email address"
msgstr "адрес электронной почты"
#: build/lib/core/models.py:1610 core/models.py:1610
#: build/lib/core/models.py:1955 core/models.py:1955
msgid "Document invitation"
msgstr "Приглашение для документа"
#: build/lib/core/models.py:1611 core/models.py:1611
#: build/lib/core/models.py:1956 core/models.py:1956
msgid "Document invitations"
msgstr "Приглашения для документов"
#: build/lib/core/models.py:1631 core/models.py:1631
#: build/lib/core/models.py:1976 core/models.py:1976
msgid "This email is already associated to a registered user."
msgstr "Этот адрес уже связан с зарегистрированным пользователем."
#: build/lib/impress/settings.py:702 impress/settings.py:702
msgid "Docs AI"
msgstr "Docs ИИ"
#: core/templates/mail/html/template.html:153
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr "Логотип email"
#: core/templates/mail/html/template.html:200
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr "Открыть"
#: core/templates/mail/html/template.html:217
#: core/templates/mail/html/template.html:219
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr " Docs, ваш новый инструмент для организации и совместного использования документов в вашей команде. "
#: core/templates/mail/html/template.html:224
#: core/templates/mail/html/template.html:226
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
+181 -120
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"POT-Creation-Date: 2026-02-26 09:13+0000\n"
"PO-Revision-Date: 2026-02-27 08:24\n"
"Last-Translator: \n"
"Language-Team: Slovenian\n"
"Language: sl_SI\n"
@@ -17,69 +17,65 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:36 core/admin.py:36
#: build/lib/core/admin.py:30 core/admin.py:30
msgid "Personal info"
msgstr "Osebni podatki"
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
#: core/admin.py:161
msgid "Permissions"
msgstr "Dovoljenja"
#: build/lib/core/admin.py:61 core/admin.py:61
#: build/lib/core/admin.py:55 core/admin.py:55
msgid "Important dates"
msgstr "Pomembni datumi"
#: build/lib/core/admin.py:147 core/admin.py:147
#: build/lib/core/admin.py:112 core/admin.py:112
msgid "Import job created and queued."
msgstr ""
#: build/lib/core/admin.py:116 core/admin.py:116
msgid "Process selected user reconciliations"
msgstr ""
#: build/lib/core/admin.py:171 core/admin.py:171
msgid "Tree structure"
msgstr "Drevesna struktura"
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
#: build/lib/core/api/filters.py:48 core/api/filters.py:48
msgid "Title"
msgstr "Naslov"
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
#: build/lib/core/api/filters.py:62 core/api/filters.py:62
msgid "Creator is me"
msgstr "Ustvaril sem jaz"
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
#: build/lib/core/api/filters.py:65 core/api/filters.py:65
msgid "Masked"
msgstr ""
#: build/lib/core/api/filters.py:67 core/api/filters.py:67
#: build/lib/core/api/filters.py:68 core/api/filters.py:68
msgid "Favorite"
msgstr "Priljubljena"
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
#: build/lib/core/api/serializers.py:513 core/api/serializers.py:513
msgid "A new document was created on your behalf!"
msgstr "Nov dokument je bil ustvarjen v vašem imenu!"
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
#: build/lib/core/api/serializers.py:517 core/api/serializers.py:517
msgid "You have been granted ownership of a new document:"
msgstr "Dodeljeno vam je bilo lastništvo nad novim dokumentom:"
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
#: build/lib/core/api/serializers.py:553 core/api/serializers.py:553
msgid "This field is required."
msgstr ""
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#: build/lib/core/api/serializers.py:564 core/api/serializers.py:564
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
msgid "Body"
msgstr "Telo"
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
msgid "Body type"
msgstr "Vrsta telesa"
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
msgid "Format"
msgstr "Oblika"
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#: build/lib/core/api/viewsets.py:1278 core/api/viewsets.py:1278
#, python-brace-format
msgid "copy of {title}"
msgstr ""
@@ -243,224 +239,289 @@ msgstr "uporabnik"
msgid "users"
msgstr "uporabniki"
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
#: build/lib/core/models.py:360 core/models.py:360
msgid "Active email address"
msgstr ""
#: build/lib/core/models.py:361 core/models.py:361
msgid "Email address to deactivate"
msgstr ""
#: build/lib/core/models.py:388 core/models.py:388
msgid "Unique ID in the source file"
msgstr ""
#: build/lib/core/models.py:394 build/lib/core/models.py:692 core/models.py:394
#: core/models.py:692
msgid "Pending"
msgstr ""
#: build/lib/core/models.py:395 core/models.py:395
msgid "Ready"
msgstr ""
#: build/lib/core/models.py:396 build/lib/core/models.py:694 core/models.py:396
#: core/models.py:694
msgid "Done"
msgstr ""
#: build/lib/core/models.py:397 build/lib/core/models.py:695 core/models.py:397
#: core/models.py:695
msgid "Error"
msgstr ""
#: build/lib/core/models.py:405 core/models.py:405
msgid "user reconciliation"
msgstr ""
#: build/lib/core/models.py:406 core/models.py:406
msgid "user reconciliations"
msgstr ""
#: build/lib/core/models.py:644 core/models.py:644
msgid "You have requested a reconciliation of your user accounts on Docs.\n"
" To confirm that you are the one who initiated the request\n"
" and that this email belongs to you:"
msgstr ""
#: build/lib/core/models.py:650 core/models.py:650
msgid "Confirm by clicking the link to start the reconciliation"
msgstr ""
#: build/lib/core/models.py:655 build/lib/core/models.py:761 core/models.py:655
#: core/models.py:761
msgid "Click here"
msgstr ""
#: build/lib/core/models.py:656 core/models.py:656
msgid "Confirm"
msgstr ""
#: build/lib/core/models.py:667 core/models.py:667
msgid "Your reconciliation request has been processed.\n"
" New documents are likely associated with your account:"
msgstr ""
#: build/lib/core/models.py:672 core/models.py:672
msgid "Your accounts have been merged"
msgstr ""
#: build/lib/core/models.py:677 core/models.py:677
msgid "Click here to see"
msgstr ""
#: build/lib/core/models.py:678 core/models.py:678
msgid "See my documents"
msgstr ""
#: build/lib/core/models.py:688 core/models.py:688
msgid "CSV file"
msgstr ""
#: build/lib/core/models.py:693 core/models.py:693
msgid "Running"
msgstr ""
#: build/lib/core/models.py:703 core/models.py:703
msgid "user reconciliation CSV import"
msgstr ""
#: build/lib/core/models.py:704 core/models.py:704
msgid "user reconciliation CSV imports"
msgstr ""
#: build/lib/core/models.py:748 core/models.py:748
#, python-brace-format
msgid "Your request for reconciliation was unsuccessful.\n"
" Reconciliation failed for the following email addresses:\n"
" {recipient_email}, {other_email}.\n"
" Please check for typos.\n"
" You can submit another request with the valid email addresses."
msgstr ""
#: build/lib/core/models.py:756 core/models.py:756
msgid "Reconciliation of your Docs accounts not completed"
msgstr ""
#: build/lib/core/models.py:762 core/models.py:762
msgid "Make a new request"
msgstr ""
#: build/lib/core/models.py:861 core/models.py:861
msgid "title"
msgstr "naslov"
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:862 core/models.py:862
msgid "excerpt"
msgstr "odlomek"
#: build/lib/core/models.py:411 core/models.py:411
#: build/lib/core/models.py:911 core/models.py:911
msgid "Document"
msgstr "Dokument"
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:912 core/models.py:912
msgid "Documents"
msgstr "Dokumenti"
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
#: build/lib/core/models.py:924 build/lib/core/models.py:1328
#: core/models.py:924 core/models.py:1328
msgid "Untitled Document"
msgstr "Dokument brez naslova"
#: build/lib/core/models.py:862 core/models.py:862
#: build/lib/core/models.py:1329 core/models.py:1329
msgid "Open"
msgstr "Odpri"
#: build/lib/core/models.py:1364 core/models.py:1364
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} je delil dokument z vami!"
#: build/lib/core/models.py:866 core/models.py:866
#: build/lib/core/models.py:1368 core/models.py:1368
#, 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:872 core/models.py:872
#: build/lib/core/models.py:1374 core/models.py:1374
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} je delil dokument z vami: {title}"
#: build/lib/core/models.py:973 core/models.py:973
#: build/lib/core/models.py:1475 core/models.py:1475
msgid "Document/user link trace"
msgstr "Dokument/sled povezave uporabnika"
#: build/lib/core/models.py:974 core/models.py:974
#: build/lib/core/models.py:1476 core/models.py:1476
msgid "Document/user link traces"
msgstr "Sledi povezav dokumenta/uporabnika"
#: build/lib/core/models.py:980 core/models.py:980
#: build/lib/core/models.py:1482 core/models.py:1482
msgid "A link trace already exists for this document/user."
msgstr "Za ta dokument/uporabnika že obstaja sled povezave."
#: build/lib/core/models.py:1003 core/models.py:1003
#: build/lib/core/models.py:1505 core/models.py:1505
msgid "Document favorite"
msgstr "Priljubljeni dokument"
#: build/lib/core/models.py:1004 core/models.py:1004
#: build/lib/core/models.py:1506 core/models.py:1506
msgid "Document favorites"
msgstr "Priljubljeni dokumenti"
#: build/lib/core/models.py:1010 core/models.py:1010
#: build/lib/core/models.py:1512 core/models.py:1512
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:1032 core/models.py:1032
#: build/lib/core/models.py:1534 core/models.py:1534
msgid "Document/user relation"
msgstr "Odnos dokument/uporabnik"
#: build/lib/core/models.py:1033 core/models.py:1033
#: build/lib/core/models.py:1535 core/models.py:1535
msgid "Document/user relations"
msgstr "Odnosi dokument/uporabnik"
#: build/lib/core/models.py:1039 core/models.py:1039
#: build/lib/core/models.py:1541 core/models.py:1541
msgid "This user is already in this document."
msgstr "Ta uporabnik je že v tem dokumentu."
#: build/lib/core/models.py:1045 core/models.py:1045
#: build/lib/core/models.py:1547 core/models.py:1547
msgid "This team is already in this document."
msgstr "Ta ekipa je že v tem dokumentu."
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
#: build/lib/core/models.py:1553 core/models.py:1553
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:1202 core/models.py:1202
#: build/lib/core/models.py:1704 core/models.py:1704
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1203 core/models.py:1203
#: build/lib/core/models.py:1705 core/models.py:1705
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1209 core/models.py:1209
#: build/lib/core/models.py:1711 core/models.py:1711
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1266 core/models.py:1266
#: build/lib/core/models.py:1768 core/models.py:1768
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1270 core/models.py:1270
#: build/lib/core/models.py:1772 core/models.py:1772
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1276 core/models.py:1276
#: build/lib/core/models.py:1778 core/models.py:1778
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1318 core/models.py:1318
#: build/lib/core/models.py:1820 core/models.py:1820
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1319 core/models.py:1319
#: build/lib/core/models.py:1821 core/models.py:1821
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
#: build/lib/core/models.py:1824 build/lib/core/models.py:1876
#: core/models.py:1824 core/models.py:1876
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1369 core/models.py:1369
#: build/lib/core/models.py:1871 core/models.py:1871
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1370 core/models.py:1370
#: build/lib/core/models.py:1872 core/models.py:1872
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1419 core/models.py:1419
#: build/lib/core/models.py:1921 core/models.py:1921
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1423 core/models.py:1423
#: build/lib/core/models.py:1925 core/models.py:1925
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1424 core/models.py:1424
#: build/lib/core/models.py:1926 core/models.py:1926
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr "opis"
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr "koda"
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr "javno"
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "Whether this template is public for anyone to use."
msgstr "Ali je ta predloga javna za uporabo."
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr "Predloga"
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr "Predloge"
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr "Odnos predloga/uporabnik"
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr "Odnosi med predlogo in uporabnikom"
#: build/lib/core/models.py:1508 core/models.py:1508
msgid "This user is already in this template."
msgstr "Ta uporabnik je že v tej predlogi."
#: build/lib/core/models.py:1514 core/models.py:1514
msgid "This team is already in this template."
msgstr "Ta ekipa je že v tej predlogi."
#: build/lib/core/models.py:1591 core/models.py:1591
#: build/lib/core/models.py:1936 core/models.py:1936
msgid "email address"
msgstr "elektronski naslov"
#: build/lib/core/models.py:1610 core/models.py:1610
#: build/lib/core/models.py:1955 core/models.py:1955
msgid "Document invitation"
msgstr "Vabilo na dokument"
#: build/lib/core/models.py:1611 core/models.py:1611
#: build/lib/core/models.py:1956 core/models.py:1956
msgid "Document invitations"
msgstr "Vabila na dokument"
#: build/lib/core/models.py:1631 core/models.py:1631
#: build/lib/core/models.py:1976 core/models.py:1976
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:702 impress/settings.py:702
msgid "Docs AI"
msgstr ""
#: core/templates/mail/html/template.html:153
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr "E-pošta z logotipom"
#: core/templates/mail/html/template.html:200
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr "Odpri"
#: core/templates/mail/html/template.html:217
#: core/templates/mail/html/template.html:219
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr " Dokumenti, vaše novo bistveno orodje za organiziranje, skupno rabo in skupinsko sodelovanje pri dokumentih. "
#: core/templates/mail/html/template.html:224
#: core/templates/mail/html/template.html:226
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
+181 -120
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"POT-Creation-Date: 2026-02-26 09:13+0000\n"
"PO-Revision-Date: 2026-02-27 08:24\n"
"Last-Translator: \n"
"Language-Team: Swedish\n"
"Language: sv_SE\n"
@@ -17,69 +17,65 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:36 core/admin.py:36
#: build/lib/core/admin.py:30 core/admin.py:30
msgid "Personal info"
msgstr "Personuppgifter"
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
#: core/admin.py:161
msgid "Permissions"
msgstr "Behörigheter"
#: build/lib/core/admin.py:61 core/admin.py:61
#: build/lib/core/admin.py:55 core/admin.py:55
msgid "Important dates"
msgstr "Viktiga datum"
#: build/lib/core/admin.py:147 core/admin.py:147
#: build/lib/core/admin.py:112 core/admin.py:112
msgid "Import job created and queued."
msgstr ""
#: build/lib/core/admin.py:116 core/admin.py:116
msgid "Process selected user reconciliations"
msgstr ""
#: build/lib/core/admin.py:171 core/admin.py:171
msgid "Tree structure"
msgstr ""
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
#: build/lib/core/api/filters.py:48 core/api/filters.py:48
msgid "Title"
msgstr "Titel"
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
#: build/lib/core/api/filters.py:62 core/api/filters.py:62
msgid "Creator is me"
msgstr "Skaparen är jag"
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
#: build/lib/core/api/filters.py:65 core/api/filters.py:65
msgid "Masked"
msgstr ""
#: build/lib/core/api/filters.py:67 core/api/filters.py:67
#: build/lib/core/api/filters.py:68 core/api/filters.py:68
msgid "Favorite"
msgstr "Favoriter"
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
#: build/lib/core/api/serializers.py:513 core/api/serializers.py:513
msgid "A new document was created on your behalf!"
msgstr "Ett nytt dokument skapades åt dig!"
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
#: build/lib/core/api/serializers.py:517 core/api/serializers.py:517
msgid "You have been granted ownership of a new document:"
msgstr "Du har beviljats äganderätt till ett nytt dokument:"
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
#: build/lib/core/api/serializers.py:553 core/api/serializers.py:553
msgid "This field is required."
msgstr ""
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#: build/lib/core/api/serializers.py:564 core/api/serializers.py:564
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
msgid "Body"
msgstr ""
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
msgid "Body type"
msgstr ""
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
msgid "Format"
msgstr "Format"
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#: build/lib/core/api/viewsets.py:1278 core/api/viewsets.py:1278
#, python-brace-format
msgid "copy of {title}"
msgstr ""
@@ -243,224 +239,289 @@ msgstr ""
msgid "users"
msgstr ""
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
#: build/lib/core/models.py:360 core/models.py:360
msgid "Active email address"
msgstr ""
#: build/lib/core/models.py:361 core/models.py:361
msgid "Email address to deactivate"
msgstr ""
#: build/lib/core/models.py:388 core/models.py:388
msgid "Unique ID in the source file"
msgstr ""
#: build/lib/core/models.py:394 build/lib/core/models.py:692 core/models.py:394
#: core/models.py:692
msgid "Pending"
msgstr ""
#: build/lib/core/models.py:395 core/models.py:395
msgid "Ready"
msgstr ""
#: build/lib/core/models.py:396 build/lib/core/models.py:694 core/models.py:396
#: core/models.py:694
msgid "Done"
msgstr ""
#: build/lib/core/models.py:397 build/lib/core/models.py:695 core/models.py:397
#: core/models.py:695
msgid "Error"
msgstr ""
#: build/lib/core/models.py:405 core/models.py:405
msgid "user reconciliation"
msgstr ""
#: build/lib/core/models.py:406 core/models.py:406
msgid "user reconciliations"
msgstr ""
#: build/lib/core/models.py:644 core/models.py:644
msgid "You have requested a reconciliation of your user accounts on Docs.\n"
" To confirm that you are the one who initiated the request\n"
" and that this email belongs to you:"
msgstr ""
#: build/lib/core/models.py:650 core/models.py:650
msgid "Confirm by clicking the link to start the reconciliation"
msgstr ""
#: build/lib/core/models.py:655 build/lib/core/models.py:761 core/models.py:655
#: core/models.py:761
msgid "Click here"
msgstr ""
#: build/lib/core/models.py:656 core/models.py:656
msgid "Confirm"
msgstr ""
#: build/lib/core/models.py:667 core/models.py:667
msgid "Your reconciliation request has been processed.\n"
" New documents are likely associated with your account:"
msgstr ""
#: build/lib/core/models.py:672 core/models.py:672
msgid "Your accounts have been merged"
msgstr ""
#: build/lib/core/models.py:677 core/models.py:677
msgid "Click here to see"
msgstr ""
#: build/lib/core/models.py:678 core/models.py:678
msgid "See my documents"
msgstr ""
#: build/lib/core/models.py:688 core/models.py:688
msgid "CSV file"
msgstr ""
#: build/lib/core/models.py:693 core/models.py:693
msgid "Running"
msgstr ""
#: build/lib/core/models.py:703 core/models.py:703
msgid "user reconciliation CSV import"
msgstr ""
#: build/lib/core/models.py:704 core/models.py:704
msgid "user reconciliation CSV imports"
msgstr ""
#: build/lib/core/models.py:748 core/models.py:748
#, python-brace-format
msgid "Your request for reconciliation was unsuccessful.\n"
" Reconciliation failed for the following email addresses:\n"
" {recipient_email}, {other_email}.\n"
" Please check for typos.\n"
" You can submit another request with the valid email addresses."
msgstr ""
#: build/lib/core/models.py:756 core/models.py:756
msgid "Reconciliation of your Docs accounts not completed"
msgstr ""
#: build/lib/core/models.py:762 core/models.py:762
msgid "Make a new request"
msgstr ""
#: build/lib/core/models.py:861 core/models.py:861
msgid "title"
msgstr ""
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:862 core/models.py:862
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:411 core/models.py:411
#: build/lib/core/models.py:911 core/models.py:911
msgid "Document"
msgstr ""
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:912 core/models.py:912
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
#: build/lib/core/models.py:924 build/lib/core/models.py:1328
#: core/models.py:924 core/models.py:1328
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:862 core/models.py:862
#: build/lib/core/models.py:1329 core/models.py:1329
msgid "Open"
msgstr "Öppna"
#: build/lib/core/models.py:1364 core/models.py:1364
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:866 core/models.py:866
#: build/lib/core/models.py:1368 core/models.py:1368
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:872 core/models.py:872
#: build/lib/core/models.py:1374 core/models.py:1374
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:973 core/models.py:973
#: build/lib/core/models.py:1475 core/models.py:1475
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:974 core/models.py:974
#: build/lib/core/models.py:1476 core/models.py:1476
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:980 core/models.py:980
#: build/lib/core/models.py:1482 core/models.py:1482
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:1003 core/models.py:1003
#: build/lib/core/models.py:1505 core/models.py:1505
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:1004 core/models.py:1004
#: build/lib/core/models.py:1506 core/models.py:1506
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1010 core/models.py:1010
#: build/lib/core/models.py:1512 core/models.py:1512
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1032 core/models.py:1032
#: build/lib/core/models.py:1534 core/models.py:1534
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1033 core/models.py:1033
#: build/lib/core/models.py:1535 core/models.py:1535
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1039 core/models.py:1039
#: build/lib/core/models.py:1541 core/models.py:1541
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1045 core/models.py:1045
#: build/lib/core/models.py:1547 core/models.py:1547
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
#: build/lib/core/models.py:1553 core/models.py:1553
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1202 core/models.py:1202
#: build/lib/core/models.py:1704 core/models.py:1704
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1203 core/models.py:1203
#: build/lib/core/models.py:1705 core/models.py:1705
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1209 core/models.py:1209
#: build/lib/core/models.py:1711 core/models.py:1711
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1266 core/models.py:1266
#: build/lib/core/models.py:1768 core/models.py:1768
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1270 core/models.py:1270
#: build/lib/core/models.py:1772 core/models.py:1772
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1276 core/models.py:1276
#: build/lib/core/models.py:1778 core/models.py:1778
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1318 core/models.py:1318
#: build/lib/core/models.py:1820 core/models.py:1820
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1319 core/models.py:1319
#: build/lib/core/models.py:1821 core/models.py:1821
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
#: build/lib/core/models.py:1824 build/lib/core/models.py:1876
#: core/models.py:1824 core/models.py:1876
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1369 core/models.py:1369
#: build/lib/core/models.py:1871 core/models.py:1871
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1370 core/models.py:1370
#: build/lib/core/models.py:1872 core/models.py:1872
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1419 core/models.py:1419
#: build/lib/core/models.py:1921 core/models.py:1921
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1423 core/models.py:1423
#: build/lib/core/models.py:1925 core/models.py:1925
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1424 core/models.py:1424
#: build/lib/core/models.py:1926 core/models.py:1926
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr ""
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr ""
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr ""
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr ""
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1508 core/models.py:1508
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1514 core/models.py:1514
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1591 core/models.py:1591
#: build/lib/core/models.py:1936 core/models.py:1936
msgid "email address"
msgstr "e-postadress"
#: build/lib/core/models.py:1610 core/models.py:1610
#: build/lib/core/models.py:1955 core/models.py:1955
msgid "Document invitation"
msgstr "Bjud in dokument"
#: build/lib/core/models.py:1611 core/models.py:1611
#: build/lib/core/models.py:1956 core/models.py:1956
msgid "Document invitations"
msgstr "Inbjudningar dokument"
#: build/lib/core/models.py:1631 core/models.py:1631
#: build/lib/core/models.py:1976 core/models.py:1976
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:702 impress/settings.py:702
msgid "Docs AI"
msgstr ""
#: core/templates/mail/html/template.html:153
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr "Logotyp e-post"
#: core/templates/mail/html/template.html:200
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr "Öppna"
#: core/templates/mail/html/template.html:217
#: core/templates/mail/html/template.html:219
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr ""
#: core/templates/mail/html/template.html:224
#: core/templates/mail/html/template.html:226
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
+181 -120
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"POT-Creation-Date: 2026-02-26 09:13+0000\n"
"PO-Revision-Date: 2026-02-27 08:24\n"
"Last-Translator: \n"
"Language-Team: Turkish\n"
"Language: tr_TR\n"
@@ -17,69 +17,65 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:36 core/admin.py:36
#: build/lib/core/admin.py:30 core/admin.py:30
msgid "Personal info"
msgstr ""
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
#: core/admin.py:161
msgid "Permissions"
msgstr ""
#: build/lib/core/admin.py:61 core/admin.py:61
#: build/lib/core/admin.py:55 core/admin.py:55
msgid "Important dates"
msgstr ""
#: build/lib/core/admin.py:147 core/admin.py:147
#: build/lib/core/admin.py:112 core/admin.py:112
msgid "Import job created and queued."
msgstr ""
#: build/lib/core/admin.py:116 core/admin.py:116
msgid "Process selected user reconciliations"
msgstr ""
#: build/lib/core/admin.py:171 core/admin.py:171
msgid "Tree structure"
msgstr ""
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
#: build/lib/core/api/filters.py:48 core/api/filters.py:48
msgid "Title"
msgstr ""
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
#: build/lib/core/api/filters.py:62 core/api/filters.py:62
msgid "Creator is me"
msgstr ""
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
#: build/lib/core/api/filters.py:65 core/api/filters.py:65
msgid "Masked"
msgstr ""
#: build/lib/core/api/filters.py:67 core/api/filters.py:67
#: build/lib/core/api/filters.py:68 core/api/filters.py:68
msgid "Favorite"
msgstr ""
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
#: build/lib/core/api/serializers.py:513 core/api/serializers.py:513
msgid "A new document was created on your behalf!"
msgstr ""
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
#: build/lib/core/api/serializers.py:517 core/api/serializers.py:517
msgid "You have been granted ownership of a new document:"
msgstr ""
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
#: build/lib/core/api/serializers.py:553 core/api/serializers.py:553
msgid "This field is required."
msgstr ""
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#: build/lib/core/api/serializers.py:564 core/api/serializers.py:564
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr ""
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
msgid "Body"
msgstr ""
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
msgid "Body type"
msgstr ""
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
msgid "Format"
msgstr ""
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#: build/lib/core/api/viewsets.py:1278 core/api/viewsets.py:1278
#, python-brace-format
msgid "copy of {title}"
msgstr ""
@@ -243,224 +239,289 @@ msgstr ""
msgid "users"
msgstr ""
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
#: build/lib/core/models.py:360 core/models.py:360
msgid "Active email address"
msgstr ""
#: build/lib/core/models.py:361 core/models.py:361
msgid "Email address to deactivate"
msgstr ""
#: build/lib/core/models.py:388 core/models.py:388
msgid "Unique ID in the source file"
msgstr ""
#: build/lib/core/models.py:394 build/lib/core/models.py:692 core/models.py:394
#: core/models.py:692
msgid "Pending"
msgstr ""
#: build/lib/core/models.py:395 core/models.py:395
msgid "Ready"
msgstr ""
#: build/lib/core/models.py:396 build/lib/core/models.py:694 core/models.py:396
#: core/models.py:694
msgid "Done"
msgstr ""
#: build/lib/core/models.py:397 build/lib/core/models.py:695 core/models.py:397
#: core/models.py:695
msgid "Error"
msgstr ""
#: build/lib/core/models.py:405 core/models.py:405
msgid "user reconciliation"
msgstr ""
#: build/lib/core/models.py:406 core/models.py:406
msgid "user reconciliations"
msgstr ""
#: build/lib/core/models.py:644 core/models.py:644
msgid "You have requested a reconciliation of your user accounts on Docs.\n"
" To confirm that you are the one who initiated the request\n"
" and that this email belongs to you:"
msgstr ""
#: build/lib/core/models.py:650 core/models.py:650
msgid "Confirm by clicking the link to start the reconciliation"
msgstr ""
#: build/lib/core/models.py:655 build/lib/core/models.py:761 core/models.py:655
#: core/models.py:761
msgid "Click here"
msgstr ""
#: build/lib/core/models.py:656 core/models.py:656
msgid "Confirm"
msgstr ""
#: build/lib/core/models.py:667 core/models.py:667
msgid "Your reconciliation request has been processed.\n"
" New documents are likely associated with your account:"
msgstr ""
#: build/lib/core/models.py:672 core/models.py:672
msgid "Your accounts have been merged"
msgstr ""
#: build/lib/core/models.py:677 core/models.py:677
msgid "Click here to see"
msgstr ""
#: build/lib/core/models.py:678 core/models.py:678
msgid "See my documents"
msgstr ""
#: build/lib/core/models.py:688 core/models.py:688
msgid "CSV file"
msgstr ""
#: build/lib/core/models.py:693 core/models.py:693
msgid "Running"
msgstr ""
#: build/lib/core/models.py:703 core/models.py:703
msgid "user reconciliation CSV import"
msgstr ""
#: build/lib/core/models.py:704 core/models.py:704
msgid "user reconciliation CSV imports"
msgstr ""
#: build/lib/core/models.py:748 core/models.py:748
#, python-brace-format
msgid "Your request for reconciliation was unsuccessful.\n"
" Reconciliation failed for the following email addresses:\n"
" {recipient_email}, {other_email}.\n"
" Please check for typos.\n"
" You can submit another request with the valid email addresses."
msgstr ""
#: build/lib/core/models.py:756 core/models.py:756
msgid "Reconciliation of your Docs accounts not completed"
msgstr ""
#: build/lib/core/models.py:762 core/models.py:762
msgid "Make a new request"
msgstr ""
#: build/lib/core/models.py:861 core/models.py:861
msgid "title"
msgstr ""
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:862 core/models.py:862
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:411 core/models.py:411
#: build/lib/core/models.py:911 core/models.py:911
msgid "Document"
msgstr ""
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:912 core/models.py:912
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
#: build/lib/core/models.py:924 build/lib/core/models.py:1328
#: core/models.py:924 core/models.py:1328
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:862 core/models.py:862
#: build/lib/core/models.py:1329 core/models.py:1329
msgid "Open"
msgstr ""
#: build/lib/core/models.py:1364 core/models.py:1364
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:866 core/models.py:866
#: build/lib/core/models.py:1368 core/models.py:1368
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:872 core/models.py:872
#: build/lib/core/models.py:1374 core/models.py:1374
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:973 core/models.py:973
#: build/lib/core/models.py:1475 core/models.py:1475
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:974 core/models.py:974
#: build/lib/core/models.py:1476 core/models.py:1476
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:980 core/models.py:980
#: build/lib/core/models.py:1482 core/models.py:1482
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:1003 core/models.py:1003
#: build/lib/core/models.py:1505 core/models.py:1505
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:1004 core/models.py:1004
#: build/lib/core/models.py:1506 core/models.py:1506
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1010 core/models.py:1010
#: build/lib/core/models.py:1512 core/models.py:1512
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1032 core/models.py:1032
#: build/lib/core/models.py:1534 core/models.py:1534
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1033 core/models.py:1033
#: build/lib/core/models.py:1535 core/models.py:1535
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1039 core/models.py:1039
#: build/lib/core/models.py:1541 core/models.py:1541
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1045 core/models.py:1045
#: build/lib/core/models.py:1547 core/models.py:1547
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
#: build/lib/core/models.py:1553 core/models.py:1553
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1202 core/models.py:1202
#: build/lib/core/models.py:1704 core/models.py:1704
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1203 core/models.py:1203
#: build/lib/core/models.py:1705 core/models.py:1705
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1209 core/models.py:1209
#: build/lib/core/models.py:1711 core/models.py:1711
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1266 core/models.py:1266
#: build/lib/core/models.py:1768 core/models.py:1768
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1270 core/models.py:1270
#: build/lib/core/models.py:1772 core/models.py:1772
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1276 core/models.py:1276
#: build/lib/core/models.py:1778 core/models.py:1778
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1318 core/models.py:1318
#: build/lib/core/models.py:1820 core/models.py:1820
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1319 core/models.py:1319
#: build/lib/core/models.py:1821 core/models.py:1821
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
#: build/lib/core/models.py:1824 build/lib/core/models.py:1876
#: core/models.py:1824 core/models.py:1876
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1369 core/models.py:1369
#: build/lib/core/models.py:1871 core/models.py:1871
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1370 core/models.py:1370
#: build/lib/core/models.py:1872 core/models.py:1872
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1419 core/models.py:1419
#: build/lib/core/models.py:1921 core/models.py:1921
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1423 core/models.py:1423
#: build/lib/core/models.py:1925 core/models.py:1925
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1424 core/models.py:1424
#: build/lib/core/models.py:1926 core/models.py:1926
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr ""
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr ""
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr ""
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr ""
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1508 core/models.py:1508
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1514 core/models.py:1514
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1591 core/models.py:1591
#: build/lib/core/models.py:1936 core/models.py:1936
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1610 core/models.py:1610
#: build/lib/core/models.py:1955 core/models.py:1955
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1611 core/models.py:1611
#: build/lib/core/models.py:1956 core/models.py:1956
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1631 core/models.py:1631
#: build/lib/core/models.py:1976 core/models.py:1976
msgid "This email is already associated to a registered user."
msgstr ""
#: build/lib/impress/settings.py:702 impress/settings.py:702
msgid "Docs AI"
msgstr ""
#: core/templates/mail/html/template.html:153
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr ""
#: core/templates/mail/html/template.html:200
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr ""
#: core/templates/mail/html/template.html:217
#: core/templates/mail/html/template.html:219
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr ""
#: core/templates/mail/html/template.html:224
#: core/templates/mail/html/template.html:226
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
+188 -120
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"POT-Creation-Date: 2026-02-26 09:13+0000\n"
"PO-Revision-Date: 2026-02-27 08:24\n"
"Last-Translator: \n"
"Language-Team: Ukrainian\n"
"Language: uk_UA\n"
@@ -17,69 +17,65 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:36 core/admin.py:36
#: build/lib/core/admin.py:30 core/admin.py:30
msgid "Personal info"
msgstr "Особисті дані"
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
#: core/admin.py:161
msgid "Permissions"
msgstr "Дозволи"
#: build/lib/core/admin.py:61 core/admin.py:61
#: build/lib/core/admin.py:55 core/admin.py:55
msgid "Important dates"
msgstr "Важливі дати"
#: build/lib/core/admin.py:147 core/admin.py:147
#: build/lib/core/admin.py:112 core/admin.py:112
msgid "Import job created and queued."
msgstr "Завдання імпорту створено і поставлено в чергу."
#: build/lib/core/admin.py:116 core/admin.py:116
msgid "Process selected user reconciliations"
msgstr "Обробити обрані узгодження користувача"
#: build/lib/core/admin.py:171 core/admin.py:171
msgid "Tree structure"
msgstr "Ієрархічна структура"
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
#: build/lib/core/api/filters.py:48 core/api/filters.py:48
msgid "Title"
msgstr "Заголовок"
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
#: build/lib/core/api/filters.py:62 core/api/filters.py:62
msgid "Creator is me"
msgstr "Творець — я"
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
#: build/lib/core/api/filters.py:65 core/api/filters.py:65
msgid "Masked"
msgstr "Приховано"
#: build/lib/core/api/filters.py:67 core/api/filters.py:67
#: build/lib/core/api/filters.py:68 core/api/filters.py:68
msgid "Favorite"
msgstr "Обране"
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
#: build/lib/core/api/serializers.py:513 core/api/serializers.py:513
msgid "A new document was created on your behalf!"
msgstr "Новий документ був створений від вашого імені!"
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
#: build/lib/core/api/serializers.py:517 core/api/serializers.py:517
msgid "You have been granted ownership of a new document:"
msgstr "Ви тепер є власником нового документа:"
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
#: build/lib/core/api/serializers.py:553 core/api/serializers.py:553
msgid "This field is required."
msgstr "Це поле є обов’язковим."
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#: build/lib/core/api/serializers.py:564 core/api/serializers.py:564
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr "Доступ до посилання '%(link_reach)s' заборонено на основі конфігурації батьківського документа."
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
msgid "Body"
msgstr "Вміст"
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
msgid "Body type"
msgstr "Тип вмісту"
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
msgid "Format"
msgstr "Формат"
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#: build/lib/core/api/viewsets.py:1278 core/api/viewsets.py:1278
#, python-brace-format
msgid "copy of {title}"
msgstr "копія {title}"
@@ -243,224 +239,296 @@ msgstr "користувач"
msgid "users"
msgstr "користувачі"
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
#: build/lib/core/models.py:360 core/models.py:360
msgid "Active email address"
msgstr "Активна електронна адреса"
#: build/lib/core/models.py:361 core/models.py:361
msgid "Email address to deactivate"
msgstr "Електронна адреса, що буде деактивована"
#: build/lib/core/models.py:388 core/models.py:388
msgid "Unique ID in the source file"
msgstr "Унікальний ідентифікатор у вихідному файлі"
#: build/lib/core/models.py:394 build/lib/core/models.py:692 core/models.py:394
#: core/models.py:692
msgid "Pending"
msgstr "В очікуванні"
#: build/lib/core/models.py:395 core/models.py:395
msgid "Ready"
msgstr "Готово"
#: build/lib/core/models.py:396 build/lib/core/models.py:694 core/models.py:396
#: core/models.py:694
msgid "Done"
msgstr "Виконано"
#: build/lib/core/models.py:397 build/lib/core/models.py:695 core/models.py:397
#: core/models.py:695
msgid "Error"
msgstr "Помилка"
#: build/lib/core/models.py:405 core/models.py:405
msgid "user reconciliation"
msgstr "узгодження користувачів"
#: build/lib/core/models.py:406 core/models.py:406
msgid "user reconciliations"
msgstr "узгодження користувачів"
#: build/lib/core/models.py:644 core/models.py:644
msgid "You have requested a reconciliation of your user accounts on Docs.\n"
" To confirm that you are the one who initiated the request\n"
" and that this email belongs to you:"
msgstr "Ви запросили узгодження своїх облікових записів користувачів у Docs.\n"
" Щоб підтвердити, що саме ви ініціювали запит\n"
" і що ця електронна адреса належить вам:"
#: build/lib/core/models.py:650 core/models.py:650
msgid "Confirm by clicking the link to start the reconciliation"
msgstr "Підтвердіть, натиснувши на посилання, щоб почати узгодження"
#: build/lib/core/models.py:655 build/lib/core/models.py:761 core/models.py:655
#: core/models.py:761
msgid "Click here"
msgstr "Натисніть тут"
#: build/lib/core/models.py:656 core/models.py:656
msgid "Confirm"
msgstr "Підтвердження"
#: build/lib/core/models.py:667 core/models.py:667
msgid "Your reconciliation request has been processed.\n"
" New documents are likely associated with your account:"
msgstr "Ваш запит на узгодження оброблено.\n"
" Нові документи, ймовірно, пов'язані з вашим обліковим записом:"
#: build/lib/core/models.py:672 core/models.py:672
msgid "Your accounts have been merged"
msgstr "Ваші облікові записи були об'єднані"
#: build/lib/core/models.py:677 core/models.py:677
msgid "Click here to see"
msgstr "Натисніть тут, щоб переглянути"
#: build/lib/core/models.py:678 core/models.py:678
msgid "See my documents"
msgstr "Переглянути мої документи"
#: build/lib/core/models.py:688 core/models.py:688
msgid "CSV file"
msgstr "CSV-файл"
#: build/lib/core/models.py:693 core/models.py:693
msgid "Running"
msgstr "Виконується"
#: build/lib/core/models.py:703 core/models.py:703
msgid "user reconciliation CSV import"
msgstr "імпорт CSV для узгодження користувачів"
#: build/lib/core/models.py:704 core/models.py:704
msgid "user reconciliation CSV imports"
msgstr "імпорт CSV для узгодження користувачів"
#: build/lib/core/models.py:748 core/models.py:748
#, python-brace-format
msgid "Your request for reconciliation was unsuccessful.\n"
" Reconciliation failed for the following email addresses:\n"
" {recipient_email}, {other_email}.\n"
" Please check for typos.\n"
" You can submit another request with the valid email addresses."
msgstr "Ваш запит на узгодження не був виконаний.\n"
" Узгодження не вдалося для наступних адрес електронної пошти:\n"
" {recipient_email}, {other_email}.\n"
" Перевірте, чи немає помилок.\n"
" Ви можете надіслати інший запит із дійсними адресами електронної пошти."
#: build/lib/core/models.py:756 core/models.py:756
msgid "Reconciliation of your Docs accounts not completed"
msgstr "Узгодження ваших облікових записів не завершено"
#: build/lib/core/models.py:762 core/models.py:762
msgid "Make a new request"
msgstr "Зробити новий запит"
#: build/lib/core/models.py:861 core/models.py:861
msgid "title"
msgstr "заголовок"
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:862 core/models.py:862
msgid "excerpt"
msgstr "уривок"
#: build/lib/core/models.py:411 core/models.py:411
#: build/lib/core/models.py:911 core/models.py:911
msgid "Document"
msgstr "Документ"
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:912 core/models.py:912
msgid "Documents"
msgstr "Документи"
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
#: build/lib/core/models.py:924 build/lib/core/models.py:1328
#: core/models.py:924 core/models.py:1328
msgid "Untitled Document"
msgstr "Документ без назви"
#: build/lib/core/models.py:862 core/models.py:862
#: build/lib/core/models.py:1329 core/models.py:1329
msgid "Open"
msgstr "Відкрити"
#: build/lib/core/models.py:1364 core/models.py:1364
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} ділиться з вами документом!"
#: build/lib/core/models.py:866 core/models.py:866
#: build/lib/core/models.py:1368 core/models.py:1368
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} запрошує вас для роботи з документом із роллю \"{role}\":"
#: build/lib/core/models.py:872 core/models.py:872
#: build/lib/core/models.py:1374 core/models.py:1374
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} ділиться з вами документом: {title}"
#: build/lib/core/models.py:973 core/models.py:973
#: build/lib/core/models.py:1475 core/models.py:1475
msgid "Document/user link trace"
msgstr "Трасування посилання Документ/користувач"
#: build/lib/core/models.py:974 core/models.py:974
#: build/lib/core/models.py:1476 core/models.py:1476
msgid "Document/user link traces"
msgstr "Трасування посилань Документ/користувач"
#: build/lib/core/models.py:980 core/models.py:980
#: build/lib/core/models.py:1482 core/models.py:1482
msgid "A link trace already exists for this document/user."
msgstr "Відстеження вже існуючих посилань для цього документа/користувача."
#: build/lib/core/models.py:1003 core/models.py:1003
#: build/lib/core/models.py:1505 core/models.py:1505
msgid "Document favorite"
msgstr "Обраний документ"
#: build/lib/core/models.py:1004 core/models.py:1004
#: build/lib/core/models.py:1506 core/models.py:1506
msgid "Document favorites"
msgstr "Обрані документи"
#: build/lib/core/models.py:1010 core/models.py:1010
#: build/lib/core/models.py:1512 core/models.py:1512
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Цей документ вже вказаний як обраний для одного користувача."
#: build/lib/core/models.py:1032 core/models.py:1032
#: build/lib/core/models.py:1534 core/models.py:1534
msgid "Document/user relation"
msgstr "Відносини документ/користувач"
#: build/lib/core/models.py:1033 core/models.py:1033
#: build/lib/core/models.py:1535 core/models.py:1535
msgid "Document/user relations"
msgstr "Відносини документ/користувач"
#: build/lib/core/models.py:1039 core/models.py:1039
#: build/lib/core/models.py:1541 core/models.py:1541
msgid "This user is already in this document."
msgstr "Цей користувач вже має доступ до цього документу."
#: build/lib/core/models.py:1045 core/models.py:1045
#: build/lib/core/models.py:1547 core/models.py:1547
msgid "This team is already in this document."
msgstr "Ця команда вже має доступ до цього документа."
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
#: build/lib/core/models.py:1553 core/models.py:1553
msgid "Either user or team must be set, not both."
msgstr "Вкажіть користувача або команду, а не обох."
#: build/lib/core/models.py:1202 core/models.py:1202
#: build/lib/core/models.py:1704 core/models.py:1704
msgid "Document ask for access"
msgstr "Запит доступу до документа"
#: build/lib/core/models.py:1203 core/models.py:1203
#: build/lib/core/models.py:1705 core/models.py:1705
msgid "Document ask for accesses"
msgstr "Запит доступу для документа"
#: build/lib/core/models.py:1209 core/models.py:1209
#: build/lib/core/models.py:1711 core/models.py:1711
msgid "This user has already asked for access to this document."
msgstr "Цей користувач вже попросив доступ до цього документа."
#: build/lib/core/models.py:1266 core/models.py:1266
#: build/lib/core/models.py:1768 core/models.py:1768
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} хоче отримати доступ до документа!"
#: build/lib/core/models.py:1270 core/models.py:1270
#: build/lib/core/models.py:1772 core/models.py:1772
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} бажає отримати доступ до наступного документа:"
#: build/lib/core/models.py:1276 core/models.py:1276
#: build/lib/core/models.py:1778 core/models.py:1778
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} запитує доступ до документа: {title}"
#: build/lib/core/models.py:1318 core/models.py:1318
#: build/lib/core/models.py:1820 core/models.py:1820
msgid "Thread"
msgstr "Обговорення"
#: build/lib/core/models.py:1319 core/models.py:1319
#: build/lib/core/models.py:1821 core/models.py:1821
msgid "Threads"
msgstr "Обговорення"
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
#: build/lib/core/models.py:1824 build/lib/core/models.py:1876
#: core/models.py:1824 core/models.py:1876
msgid "Anonymous"
msgstr "Анонім"
#: build/lib/core/models.py:1369 core/models.py:1369
#: build/lib/core/models.py:1871 core/models.py:1871
msgid "Comment"
msgstr "Коментар"
#: build/lib/core/models.py:1370 core/models.py:1370
#: build/lib/core/models.py:1872 core/models.py:1872
msgid "Comments"
msgstr "Коментарі"
#: build/lib/core/models.py:1419 core/models.py:1419
#: build/lib/core/models.py:1921 core/models.py:1921
msgid "This emoji has already been reacted to this comment."
msgstr "Цим емодзі вже відреагували на цей коментар."
#: build/lib/core/models.py:1423 core/models.py:1423
#: build/lib/core/models.py:1925 core/models.py:1925
msgid "Reaction"
msgstr "Реакція"
#: build/lib/core/models.py:1424 core/models.py:1424
#: build/lib/core/models.py:1926 core/models.py:1926
msgid "Reactions"
msgstr "Реакції"
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr "опис"
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr "код"
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr "публічне"
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "Whether this template is public for anyone to use."
msgstr "Чи є цей шаблон публічним для будь-кого користувача."
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr "Шаблон"
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr "Шаблони"
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr "Відношення шаблон/користувач"
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr "Відношення шаблон/користувач"
#: build/lib/core/models.py:1508 core/models.py:1508
msgid "This user is already in this template."
msgstr "Цей користувач вже має доступ до цього шаблону."
#: build/lib/core/models.py:1514 core/models.py:1514
msgid "This team is already in this template."
msgstr "Ця команда вже має доступ до цього шаблону."
#: build/lib/core/models.py:1591 core/models.py:1591
#: build/lib/core/models.py:1936 core/models.py:1936
msgid "email address"
msgstr "електронна адреса"
#: build/lib/core/models.py:1610 core/models.py:1610
#: build/lib/core/models.py:1955 core/models.py:1955
msgid "Document invitation"
msgstr "Запрошення до редагування документа"
#: build/lib/core/models.py:1611 core/models.py:1611
#: build/lib/core/models.py:1956 core/models.py:1956
msgid "Document invitations"
msgstr "Запрошення до редагування документів"
#: build/lib/core/models.py:1631 core/models.py:1631
#: build/lib/core/models.py:1976 core/models.py:1976
msgid "This email is already associated to a registered user."
msgstr "Ця електронна пошта вже пов'язана з зареєстрованим користувачем."
#: build/lib/impress/settings.py:702 impress/settings.py:702
msgid "Docs AI"
msgstr "Docs ШІ"
#: core/templates/mail/html/template.html:153
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr "Логотип пошти"
#: core/templates/mail/html/template.html:200
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr "Відкрити"
#: core/templates/mail/html/template.html:217
#: core/templates/mail/html/template.html:219
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr " Docs, ваш новий важливий інструмент для організації, обміну та командної співпраці над вашими документами. "
#: core/templates/mail/html/template.html:224
#: core/templates/mail/html/template.html:226
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
+280 -219
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"POT-Creation-Date: 2026-02-26 09:13+0000\n"
"PO-Revision-Date: 2026-02-27 08:24\n"
"Last-Translator: \n"
"Language-Team: Chinese Simplified\n"
"Language: zh_CN\n"
@@ -17,127 +17,123 @@ msgstr ""
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
#: build/lib/core/admin.py:36 core/admin.py:36
#: build/lib/core/admin.py:30 core/admin.py:30
msgid "Personal info"
msgstr "个人信息"
msgstr "個人資訊"
#: build/lib/core/admin.py:49 build/lib/core/admin.py:137 core/admin.py:49
#: core/admin.py:137
#: build/lib/core/admin.py:43 build/lib/core/admin.py:161 core/admin.py:43
#: core/admin.py:161
msgid "Permissions"
msgstr "限"
msgstr "限"
#: build/lib/core/admin.py:61 core/admin.py:61
#: build/lib/core/admin.py:55 core/admin.py:55
msgid "Important dates"
msgstr "重要日期"
#: build/lib/core/admin.py:147 core/admin.py:147
msgid "Tree structure"
msgstr "树状结构"
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
msgid "Title"
msgstr "标题"
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
msgid "Creator is me"
msgstr "创建者是我"
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
msgid "Masked"
msgstr "已屏蔽"
#: build/lib/core/api/filters.py:67 core/api/filters.py:67
msgid "Favorite"
msgstr "收藏"
#: build/lib/core/api/serializers.py:497 core/api/serializers.py:497
msgid "A new document was created on your behalf!"
msgstr "已为您创建了一份新文档!"
#: build/lib/core/api/serializers.py:501 core/api/serializers.py:501
msgid "You have been granted ownership of a new document:"
msgstr "您已被授予新文档的所有权:"
#: build/lib/core/api/serializers.py:537 core/api/serializers.py:537
msgid "This field is required."
msgstr "必填字段。"
#: build/lib/core/api/serializers.py:548 core/api/serializers.py:548
#, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
#: build/lib/core/admin.py:112 core/admin.py:112
msgid "Import job created and queued."
msgstr ""
#: build/lib/core/api/serializers.py:694 core/api/serializers.py:694
msgid "Body"
msgstr "正文"
#: build/lib/core/admin.py:116 core/admin.py:116
msgid "Process selected user reconciliations"
msgstr ""
#: build/lib/core/api/serializers.py:697 core/api/serializers.py:697
msgid "Body type"
msgstr "正文类型"
#: build/lib/core/admin.py:171 core/admin.py:171
msgid "Tree structure"
msgstr "樹狀結構"
#: build/lib/core/api/serializers.py:703 core/api/serializers.py:703
msgid "Format"
msgstr "格式"
#: build/lib/core/api/filters.py:48 core/api/filters.py:48
msgid "Title"
msgstr "標題"
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#: build/lib/core/api/filters.py:62 core/api/filters.py:62
msgid "Creator is me"
msgstr "建立者是我"
#: build/lib/core/api/filters.py:65 core/api/filters.py:65
msgid "Masked"
msgstr "已隱藏"
#: build/lib/core/api/filters.py:68 core/api/filters.py:68
msgid "Favorite"
msgstr "我的最愛"
#: build/lib/core/api/serializers.py:513 core/api/serializers.py:513
msgid "A new document was created on your behalf!"
msgstr "已代表您建立新文件!"
#: build/lib/core/api/serializers.py:517 core/api/serializers.py:517
msgid "You have been granted ownership of a new document:"
msgstr "您已獲得新文件的所有權:"
#: build/lib/core/api/serializers.py:553 core/api/serializers.py:553
msgid "This field is required."
msgstr "此欄位為必填。"
#: build/lib/core/api/serializers.py:564 core/api/serializers.py:564
#, 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:1278 core/api/viewsets.py:1278
#, python-brace-format
msgid "copy of {title}"
msgstr "{title} 的副本"
#: build/lib/core/apps.py:12 core/apps.py:12
msgid "Impress core application"
msgstr ""
msgstr "Impress 核心應用程式"
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
#: core/choices.py:43
msgid "Reader"
msgstr "阅读者"
msgstr "檢視者"
#: build/lib/core/choices.py:36 build/lib/core/choices.py:44 core/choices.py:36
#: core/choices.py:44
msgid "Commenter"
msgstr ""
msgstr "評論者"
#: build/lib/core/choices.py:37 build/lib/core/choices.py:45 core/choices.py:37
#: core/choices.py:45
msgid "Editor"
msgstr "编辑者"
msgstr "編輯者"
#: build/lib/core/choices.py:46 core/choices.py:46
msgid "Administrator"
msgstr "超级管理"
msgstr "管理"
#: build/lib/core/choices.py:47 core/choices.py:47
msgid "Owner"
msgstr "有者"
msgstr "有者"
#: build/lib/core/choices.py:58 core/choices.py:58
msgid "Restricted"
msgstr "受限"
msgstr "受限"
#: build/lib/core/choices.py:62 core/choices.py:62
msgid "Authenticated"
msgstr "已验证"
msgstr "已驗證"
#: build/lib/core/choices.py:64 core/choices.py:64
msgid "Public"
msgstr "公"
msgstr "公"
#: build/lib/core/enums.py:36 core/enums.py:36
msgid "First child"
msgstr "第一个子项"
msgstr "第一個子項目"
#: build/lib/core/enums.py:37 core/enums.py:37
msgid "Last child"
msgstr "最后一个子项"
msgstr "最後一個子項目"
#: build/lib/core/enums.py:38 core/enums.py:38
msgid "First sibling"
msgstr "第一个同级项"
msgstr "第一個同級項目"
#: build/lib/core/enums.py:39 core/enums.py:39
msgid "Last sibling"
msgstr "最后一个同级项"
msgstr "最後一個同級項目"
#: build/lib/core/enums.py:40 core/enums.py:40
msgid "Left"
@@ -149,31 +145,31 @@ msgstr "右"
#: 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 "记录的主密钥为 UUID"
msgstr "記錄的主鍵(UUID"
#: build/lib/core/models.py:87 core/models.py:87
msgid "created on"
msgstr "创建时间"
msgstr "建立於"
#: build/lib/core/models.py:88 core/models.py:88
msgid "date and time at which a record was created"
msgstr "记录的创建日期和时间"
msgstr "記錄建立的日期與時間"
#: build/lib/core/models.py:93 core/models.py:93
msgid "updated on"
msgstr "更新时间"
msgstr "更新"
#: build/lib/core/models.py:94 core/models.py:94
msgid "date and time at which a record was last updated"
msgstr "记录的最后更新时间"
msgstr "記錄最後更新的日期與時間"
#: build/lib/core/models.py:130 core/models.py:130
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
msgstr "未找到具有 sub 的用户,但该邮箱已关联到一个注册用户。"
msgstr "我們找不到具有 sub 的使用者,但此電子郵件地址已與已註冊使用者關聯。"
#: build/lib/core/models.py:141 core/models.py:141
msgid "sub"
@@ -181,7 +177,7 @@ msgstr "sub"
#: build/lib/core/models.py:142 core/models.py:142
msgid "Required. 255 characters or fewer. ASCII characters only."
msgstr "必填项。限255个字符以内。仅支持ASCII字符。"
msgstr "必填。255 個字元(含)以下。僅限 ASCII 字元。"
#: build/lib/core/models.py:150 core/models.py:150
msgid "full name"
@@ -189,280 +185,345 @@ msgstr "全名"
#: build/lib/core/models.py:152 core/models.py:152
msgid "short name"
msgstr "简称"
msgstr "簡稱"
#: build/lib/core/models.py:155 core/models.py:155
msgid "identity email address"
msgstr "身份电子邮件地址"
msgstr "身份驗證電子郵件地址"
#: build/lib/core/models.py:160 core/models.py:160
msgid "admin email address"
msgstr "管理员电子邮件地址"
msgstr "管理員電子郵件地址"
#: build/lib/core/models.py:167 core/models.py:167
msgid "language"
msgstr "言"
msgstr "言"
#: build/lib/core/models.py:168 core/models.py:168
msgid "The language in which the user wants to see the interface."
msgstr "用户希望看到的界面语言。"
msgstr "使用者希望介面顯示的語言。"
#: build/lib/core/models.py:176 core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr "用户查看时间希望的时区。"
msgstr "使用者希望時間顯示的時區。"
#: build/lib/core/models.py:179 core/models.py:179
msgid "device"
msgstr "设备"
msgstr "裝置"
#: build/lib/core/models.py:181 core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr "用户是设备还是真实用户。"
msgstr "使用者是裝置還是真實使用者。"
#: build/lib/core/models.py:184 core/models.py:184
msgid "staff status"
msgstr "员工状态"
msgstr "工作人員狀態"
#: build/lib/core/models.py:186 core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr "用户是否可以登录该管理员站点。"
msgstr "使用者是否可以登入此管理後台。"
#: build/lib/core/models.py:189 core/models.py:189
msgid "active"
msgstr "激活"
msgstr "啟用"
#: build/lib/core/models.py:192 core/models.py:192
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "是否应将此用户视为活跃用户。取消选择此选项而不是删除账户。"
msgstr "此使用者是否應被視為處於啟用狀態。請取消勾選此項而非刪除帳號。"
#: build/lib/core/models.py:204 core/models.py:204
msgid "user"
msgstr "用户"
msgstr "使用者"
#: build/lib/core/models.py:205 core/models.py:205
msgid "users"
msgstr "个用户"
msgstr "使用者"
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
#: build/lib/core/models.py:360 core/models.py:360
msgid "Active email address"
msgstr ""
#: build/lib/core/models.py:361 core/models.py:361
msgid "Email address to deactivate"
msgstr ""
#: build/lib/core/models.py:388 core/models.py:388
msgid "Unique ID in the source file"
msgstr ""
#: build/lib/core/models.py:394 build/lib/core/models.py:692 core/models.py:394
#: core/models.py:692
msgid "Pending"
msgstr ""
#: build/lib/core/models.py:395 core/models.py:395
msgid "Ready"
msgstr ""
#: build/lib/core/models.py:396 build/lib/core/models.py:694 core/models.py:396
#: core/models.py:694
msgid "Done"
msgstr ""
#: build/lib/core/models.py:397 build/lib/core/models.py:695 core/models.py:397
#: core/models.py:695
msgid "Error"
msgstr ""
#: build/lib/core/models.py:405 core/models.py:405
msgid "user reconciliation"
msgstr ""
#: build/lib/core/models.py:406 core/models.py:406
msgid "user reconciliations"
msgstr ""
#: build/lib/core/models.py:644 core/models.py:644
msgid "You have requested a reconciliation of your user accounts on Docs.\n"
" To confirm that you are the one who initiated the request\n"
" and that this email belongs to you:"
msgstr ""
#: build/lib/core/models.py:650 core/models.py:650
msgid "Confirm by clicking the link to start the reconciliation"
msgstr ""
#: build/lib/core/models.py:655 build/lib/core/models.py:761 core/models.py:655
#: core/models.py:761
msgid "Click here"
msgstr ""
#: build/lib/core/models.py:656 core/models.py:656
msgid "Confirm"
msgstr ""
#: build/lib/core/models.py:667 core/models.py:667
msgid "Your reconciliation request has been processed.\n"
" New documents are likely associated with your account:"
msgstr ""
#: build/lib/core/models.py:672 core/models.py:672
msgid "Your accounts have been merged"
msgstr ""
#: build/lib/core/models.py:677 core/models.py:677
msgid "Click here to see"
msgstr ""
#: build/lib/core/models.py:678 core/models.py:678
msgid "See my documents"
msgstr ""
#: build/lib/core/models.py:688 core/models.py:688
msgid "CSV file"
msgstr ""
#: build/lib/core/models.py:693 core/models.py:693
msgid "Running"
msgstr ""
#: build/lib/core/models.py:703 core/models.py:703
msgid "user reconciliation CSV import"
msgstr ""
#: build/lib/core/models.py:704 core/models.py:704
msgid "user reconciliation CSV imports"
msgstr ""
#: build/lib/core/models.py:748 core/models.py:748
#, python-brace-format
msgid "Your request for reconciliation was unsuccessful.\n"
" Reconciliation failed for the following email addresses:\n"
" {recipient_email}, {other_email}.\n"
" Please check for typos.\n"
" You can submit another request with the valid email addresses."
msgstr ""
#: build/lib/core/models.py:756 core/models.py:756
msgid "Reconciliation of your Docs accounts not completed"
msgstr ""
#: build/lib/core/models.py:762 core/models.py:762
msgid "Make a new request"
msgstr ""
#: build/lib/core/models.py:861 core/models.py:861
msgid "title"
msgstr "标题"
msgstr "標題"
#: build/lib/core/models.py:362 core/models.py:362
#: build/lib/core/models.py:862 core/models.py:862
msgid "excerpt"
msgstr "摘要"
#: build/lib/core/models.py:411 core/models.py:411
#: build/lib/core/models.py:911 core/models.py:911
msgid "Document"
msgstr "文"
msgstr "文"
#: build/lib/core/models.py:412 core/models.py:412
#: build/lib/core/models.py:912 core/models.py:912
msgid "Documents"
msgstr "个文档"
msgstr "文件"
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
#: build/lib/core/models.py:924 build/lib/core/models.py:1328
#: core/models.py:924 core/models.py:1328
msgid "Untitled Document"
msgstr "未命名文"
msgstr "未命名文"
#: build/lib/core/models.py:862 core/models.py:862
#: build/lib/core/models.py:1329 core/models.py:1329
msgid "Open"
msgstr "開啟"
#: build/lib/core/models.py:1364 core/models.py:1364
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} 与您共享了一个文档"
msgstr "{name} 與您分享了一份文件"
#: build/lib/core/models.py:866 core/models.py:866
#: build/lib/core/models.py:1368 core/models.py:1368
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} 邀您以{role}角色访问以下文"
msgstr "{name} 邀您以{role}角色參與以下文"
#: build/lib/core/models.py:872 core/models.py:872
#: build/lib/core/models.py:1374 core/models.py:1374
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} 与您共享了一个文档{title}"
msgstr "{name} 與您分享了一份文件{title}"
#: build/lib/core/models.py:973 core/models.py:973
#: build/lib/core/models.py:1475 core/models.py:1475
msgid "Document/user link trace"
msgstr "文档/用户链接跟踪"
msgstr "文件/使用者連結追蹤"
#: build/lib/core/models.py:974 core/models.py:974
#: build/lib/core/models.py:1476 core/models.py:1476
msgid "Document/user link traces"
msgstr "个文档/用户链接跟踪"
msgstr "文件/使用者連結追蹤"
#: build/lib/core/models.py:980 core/models.py:980
#: build/lib/core/models.py:1482 core/models.py:1482
msgid "A link trace already exists for this document/user."
msgstr "此文档/用户的链接跟踪已存在。"
msgstr "此文件/使用者已存在連結追蹤。"
#: build/lib/core/models.py:1003 core/models.py:1003
#: build/lib/core/models.py:1505 core/models.py:1505
msgid "Document favorite"
msgstr "文收藏"
msgstr "文收藏"
#: build/lib/core/models.py:1004 core/models.py:1004
#: build/lib/core/models.py:1506 core/models.py:1506
msgid "Document favorites"
msgstr "文收藏"
msgstr "文收藏"
#: build/lib/core/models.py:1010 core/models.py:1010
#: build/lib/core/models.py:1512 core/models.py:1512
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "该文档已被同一用户的收藏关系实例关联。"
msgstr "此使用者已將此文件加入收藏。"
#: build/lib/core/models.py:1032 core/models.py:1032
#: build/lib/core/models.py:1534 core/models.py:1534
msgid "Document/user relation"
msgstr "文档/用户关系"
msgstr "文件/使用者關聯"
#: build/lib/core/models.py:1033 core/models.py:1033
#: build/lib/core/models.py:1535 core/models.py:1535
msgid "Document/user relations"
msgstr "文档/用户关系集"
msgstr "文件/使用者關聯"
#: build/lib/core/models.py:1039 core/models.py:1039
#: build/lib/core/models.py:1541 core/models.py:1541
msgid "This user is already in this document."
msgstr "该用户已在此文中。"
msgstr "此使用者已在此文中。"
#: build/lib/core/models.py:1045 core/models.py:1045
#: build/lib/core/models.py:1547 core/models.py:1547
msgid "This team is already in this document."
msgstr "该团队已在此文中。"
msgstr "此團隊已在此文中。"
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
#: build/lib/core/models.py:1553 core/models.py:1553
msgid "Either user or team must be set, not both."
msgstr "必须设置用户或团队之一,不能同时设置两者。"
msgstr "必須設定使用者或團隊其中之一,不能同時設定兩者。"
#: build/lib/core/models.py:1202 core/models.py:1202
#: build/lib/core/models.py:1704 core/models.py:1704
msgid "Document ask for access"
msgstr "文档需要访问权限"
msgstr "要求文件存取權"
#: build/lib/core/models.py:1203 core/models.py:1203
#: build/lib/core/models.py:1705 core/models.py:1705
msgid "Document ask for accesses"
msgstr "文档需要访问权限"
msgstr "要求文件存取權"
#: build/lib/core/models.py:1209 core/models.py:1209
#: build/lib/core/models.py:1711 core/models.py:1711
msgid "This user has already asked for access to this document."
msgstr "用户已申请该文档的访问权限。"
msgstr "此使用者已要求過存取此文件的權限。"
#: build/lib/core/models.py:1266 core/models.py:1266
#: build/lib/core/models.py:1768 core/models.py:1768
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} 申请访问文档"
msgstr "{name} 想要存取文件"
#: build/lib/core/models.py:1270 core/models.py:1270
#: build/lib/core/models.py:1772 core/models.py:1772
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} 申请访问以下文"
msgstr "{name} 想要存取以下文"
#: build/lib/core/models.py:1276 core/models.py:1276
#: build/lib/core/models.py:1778 core/models.py:1778
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name}申请文档{title}的访问权限"
msgstr "{name} 正要求存取文件{title}"
#: build/lib/core/models.py:1318 core/models.py:1318
#: build/lib/core/models.py:1820 core/models.py:1820
msgid "Thread"
msgstr ""
msgstr "對話串"
#: build/lib/core/models.py:1319 core/models.py:1319
#: build/lib/core/models.py:1821 core/models.py:1821
msgid "Threads"
msgstr ""
msgstr "對話串"
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
#: build/lib/core/models.py:1824 build/lib/core/models.py:1876
#: core/models.py:1824 core/models.py:1876
msgid "Anonymous"
msgstr ""
msgstr "匿名"
#: build/lib/core/models.py:1369 core/models.py:1369
#: build/lib/core/models.py:1871 core/models.py:1871
msgid "Comment"
msgstr ""
msgstr "評論"
#: build/lib/core/models.py:1370 core/models.py:1370
#: build/lib/core/models.py:1872 core/models.py:1872
msgid "Comments"
msgstr ""
msgstr "評論"
#: build/lib/core/models.py:1419 core/models.py:1419
#: build/lib/core/models.py:1921 core/models.py:1921
msgid "This emoji has already been reacted to this comment."
msgstr ""
msgstr "此評論已標記過此表情符號。"
#: build/lib/core/models.py:1423 core/models.py:1423
#: build/lib/core/models.py:1925 core/models.py:1925
msgid "Reaction"
msgstr ""
msgstr "回應"
#: build/lib/core/models.py:1424 core/models.py:1424
#: build/lib/core/models.py:1926 core/models.py:1926
msgid "Reactions"
msgstr ""
msgstr "回應"
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr "说明"
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr "代码"
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr "公开"
#: build/lib/core/models.py:1441 core/models.py:1441
msgid "Whether this template is public for anyone to use."
msgstr "该模板是否公开供任何人使用。"
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr "模板"
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr "模板"
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr "模板/用户关系"
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr "模板/用户关系集"
#: build/lib/core/models.py:1508 core/models.py:1508
msgid "This user is already in this template."
msgstr "该用户已在此模板中。"
#: build/lib/core/models.py:1514 core/models.py:1514
msgid "This team is already in this template."
msgstr "该团队已在此模板中。"
#: build/lib/core/models.py:1591 core/models.py:1591
#: build/lib/core/models.py:1936 core/models.py:1936
msgid "email address"
msgstr "电子邮件地址"
msgstr "電子郵件地址"
#: build/lib/core/models.py:1610 core/models.py:1610
#: build/lib/core/models.py:1955 core/models.py:1955
msgid "Document invitation"
msgstr "文档邀请"
msgstr "文件邀請"
#: build/lib/core/models.py:1611 core/models.py:1611
#: build/lib/core/models.py:1956 core/models.py:1956
msgid "Document invitations"
msgstr "文档邀请"
msgstr "文件邀請"
#: build/lib/core/models.py:1631 core/models.py:1631
#: build/lib/core/models.py:1976 core/models.py:1976
msgid "This email is already associated to a registered user."
msgstr "此电子邮件已经与现有注册用户关联。"
msgstr "此電子郵件地址已與已註冊使用者關聯。"
#: build/lib/impress/settings.py:702 impress/settings.py:702
msgid "Docs AI"
msgstr ""
#: core/templates/mail/html/template.html:153
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr "徽标邮件"
msgstr "電子郵件標誌"
#: core/templates/mail/html/template.html:200
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr "打开"
#: core/templates/mail/html/template.html:217
#: core/templates/mail/html/template.html:219
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr " Docs——您的全新必工具,帮助团队组织、共享和协作处理文档。 "
msgstr " Docs,您團隊組織、分享及協作文件的全新必工具。 "
#: core/templates/mail/html/template.html:224
#: core/templates/mail/html/template.html:226
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr " 由 %(brandname)s 倾力打造。 "
msgstr " 由 %(brandname)s 提供 "
+6 -3
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "4.4.0"
version = "4.6.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -53,14 +53,17 @@ dependencies = [
"markdown==3.10",
"mozilla-django-oidc==5.0.2",
"nested-multipart-parser==1.6.0",
"openai==2.14.0",
"openai==2.21.0",
"psycopg[binary]==3.3.2",
"pycrdt==0.12.44",
"pycrdt==0.12.44",
"pydantic==2.12.5",
"pydantic-ai-slim[openai,logfire,web]==1.58.0",
"PyJWT==2.10.1",
"python-magic==0.4.27",
"redis<6.0.0",
"requests==2.32.5",
"sentry-sdk==2.48.0",
"uvicorn==0.40.0",
"whitenoise==6.11.0",
]
+12 -4
View File
@@ -1,3 +1,5 @@
ARG FRONTEND_IMAGE=frontend-build-output
FROM node:24-alpine AS frontend-deps
# Upgrade system packages to install security updates
@@ -32,7 +34,7 @@ EXPOSE 3000
CMD [ "yarn", "dev"]
# Tilt will rebuild impress target so, we dissociate impress and impress-builder
# Tilt will rebuild impress target so, we dissociate impress and impress-builder
# to avoid rebuilding the app at every changes.
FROM impress AS impress-builder
@@ -49,6 +51,14 @@ ENV NEXT_PUBLIC_PUBLISH_AS_MIT=${PUBLISH_AS_MIT}
RUN yarn build
# Normalize output path to /app (matches the runtime-prod layout)
FROM scratch AS frontend-build-output
COPY --from=impress-builder /home/frontend/apps/impress/out /app
# When FRONTEND_IMAGE is set to an external image, BuildKit skips
# frontend-deps,impress-builder, and frontend-build-output entirely
FROM ${FRONTEND_IMAGE} AS frontend-source
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:alpine3.22 AS frontend-production
@@ -62,9 +72,7 @@ RUN apk update && \
ARG DOCKER_USER
USER ${DOCKER_USER}
COPY --from=impress-builder \
/home/frontend/apps/impress/out \
/usr/share/nginx/html
COPY --from=frontend-source /app /app
COPY ./src/frontend/apps/impress/conf/default.conf /etc/nginx/conf.d
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
@@ -192,10 +192,10 @@ endobj
(react-pdf)
endobj
55 0 obj
(D:20260120141652Z)
(D:20260210135720Z)
endobj
56 0 obj
(chromium-1944-0-doc-export-override-content)
(chromium-4728-0-doc-export-override-content)
endobj
52 0 obj
<<
@@ -216,7 +216,7 @@ endobj
58 0 obj
<<
/Type /FontDescriptor
/FontName /NRSKJK+Inter18pt-Regular
/FontName /XWNEXS+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 /NRSKJK+Inter18pt-Regular
/BaseFont /XWNEXS+Inter18pt-Regular
/CIDSystemInfo <<
/Registry (Adobe)
/Ordering (Identity)
@@ -247,7 +247,7 @@ endobj
<<
/Type /Font
/Subtype /Type0
/BaseFont /NRSKJK+Inter18pt-Regular
/BaseFont /XWNEXS+Inter18pt-Regular
/Encoding /Identity-H
/DescendantFonts [59 0 R]
/ToUnicode 60 0 R
@@ -256,7 +256,7 @@ endobj
62 0 obj
<<
/Type /FontDescriptor
/FontName /XTJBQL+Inter18pt-Bold
/FontName /QGXPNV+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 /XTJBQL+Inter18pt-Bold
/BaseFont /QGXPNV+Inter18pt-Bold
/CIDSystemInfo <<
/Registry (Adobe)
/Ordering (Identity)
@@ -287,7 +287,7 @@ endobj
<<
/Type /Font
/Subtype /Type0
/BaseFont /XTJBQL+Inter18pt-Bold
/BaseFont /QGXPNV+Inter18pt-Bold
/Encoding /Identity-H
/DescendantFonts [63 0 R]
/ToUnicode 64 0 R
@@ -296,7 +296,7 @@ endobj
66 0 obj
<<
/Type /FontDescriptor
/FontName /EDRVHV+Inter18pt-Italic
/FontName /SLYFFZ+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 /EDRVHV+Inter18pt-Italic
/BaseFont /SLYFFZ+Inter18pt-Italic
/CIDSystemInfo <<
/Registry (Adobe)
/Ordering (Identity)
@@ -327,7 +327,7 @@ endobj
<<
/Type /Font
/Subtype /Type0
/BaseFont /EDRVHV+Inter18pt-Italic
/BaseFont /SLYFFZ+Inter18pt-Italic
/Encoding /Identity-H
/DescendantFonts [67 0 R]
/ToUnicode 68 0 R
@@ -336,7 +336,7 @@ endobj
70 0 obj
<<
/Type /FontDescriptor
/FontName /JIDLHQ+GeistMono-Regular
/FontName /GPERZO+GeistMono-Regular
/Flags 5
/FontBBox [-1738 -247 654 1012]
/ItalicAngle 0
@@ -352,7 +352,7 @@ endobj
<<
/Type /Font
/Subtype /CIDFontType2
/BaseFont /JIDLHQ+GeistMono-Regular
/BaseFont /GPERZO+GeistMono-Regular
/CIDSystemInfo <<
/Registry (Adobe)
/Ordering (Identity)
@@ -367,7 +367,7 @@ endobj
<<
/Type /Font
/Subtype /Type0
/BaseFont /JIDLHQ+GeistMono-Regular
/BaseFont /GPERZO+GeistMono-Regular
/Encoding /Identity-H
/DescendantFonts [71 0 R]
/ToUnicode 72 0 R
@@ -376,7 +376,7 @@ endobj
74 0 obj
<<
/Type /FontDescriptor
/FontName /SELAIX+Inter18pt-BoldItalic
/FontName /CNJFYA+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 /SELAIX+Inter18pt-BoldItalic
/BaseFont /CNJFYA+Inter18pt-BoldItalic
/CIDSystemInfo <<
/Registry (Adobe)
/Ordering (Identity)
@@ -407,7 +407,7 @@ endobj
<<
/Type /Font
/Subtype /Type0
/BaseFont /SELAIX+Inter18pt-BoldItalic
/BaseFont /CNJFYA+Inter18pt-BoldItalic
/Encoding /Identity-H
/DescendantFonts [75 0 R]
/ToUnicode 76 0 R
@@ -692,21 +692,24 @@ endstream
endobj
6 0 obj
<<
/Length 1381
/Length 1393
/Filter /FlateDecode
>>
stream
xœíËnÜ6ð®¯à¾À‡¤m€
¤6ÐCC"ÛMm€Ö€ûûáCÒj¹»*°©³®-SšGÃá¼HPtm€nÁ€ Q)°¢ßv mù¿tT¤(±`'€tûꇻÇ?ú»_ß½ýC§ÅCÿµ{õîÄï4êý8²!Û›×Y× qþ  ¸¹ï>¼V„¤†Ôti†š¥æ®Dp2QN &Ä+ZF´>@Bø+zsÖ|„+¡>Š›Ÿ»oH¼*€µÒ è‰ö>É^¯…¢¬— U
+r¡*©ŒíÑš¨ :åƒJ.qŽpîlÕÍIBVâ&Jô^“TæX‘¬:jÄP}*ÄŸ›ª略`iINë6T©ZfçI?þ›ÛΫ¥¾­%íØ¢÷k´dTËäBKMZ:_5Âz¨ œ * JSýënÏ6ƒÛù#É£¢ðÎxeC@6Ö8ýÔ–ºÓ
Ü“ìßxu ùδ@¤ÛìøËUªŪõªÔ·íE›T·bÑü÷Pmsz¬h¡]\3¹Ø ‚ÓôòE±íèÅ(†ü˜n=#ÀäzÎï=L]é1ÝúÄqH÷ÚúîK÷›ø:—‚E ËÖž”8Ô1ÑQÍÂ’õ>Q±¬dï £i†›üxìµáÎð€‚óÄd(`ÔuX—‡1»`H&~HàÐñˆfúq£» 8UqÌfÈ ³.ê‹’^ÁÀã4·ô”~ðm›¥Èì˜ç‘f`&ø’»e
PæÛsõ_Ÿ9æ–PpKíŽK­fx]žó=w t©«ÖQzg]¡Äê™ é2ÁK¤n߬~ƒ¨ž×ÊV“rbc#¿ÛYZK¾ ™€oC§°V¶+ËØ€•€9Œ¶ùâ„GœpÄoçøõ‹Ÿ’ɶý4J§U°ÍÜÖòSƒÇóÛe§otãäH8ÌÂ)aRá½åø‘f:ŒÐòÙwz¤Ù‡–O¦Î¼‡Z>ç4jZ>køšË|ˆrÍlfK÷öšk`Þ’Û1
™+0×oéœø»»î>|$Ö·EñSñÈ{˜a?2í S;—± +¶Õ¥26-Wkšž³Rƒ1vM]¬áDéxÙ®¥Í3Êð9UsÂÑš@—2­4”Ë3à %¸ F%Wq\ xtB{—ÇÎ(Ì I¹:Ñ9ÚNmlrPGÓÜXpÂ¥l«˜µC"¢È@P#Î…Lg,³Nc mî?£9§· Mµ¿µÉÒ0P$L8–šéÊLЫ<;ZôŠÓôÎD§S1êæÒîêó3ê["Ä’T͉} :¦÷JcCäÓ­UiW»gvuX±g~ÚI0¾]CI¥£:}¬ÇG43Â'ž%3ðÙ ”Z1^ °´óÇòÓJ[?˾¸8h6 Ó1jÕË+P$p&˜f$ر·Cø?·žv¹@ x»Öxôњ䲃ª±'rÆwáío%NZÚ“ê“_È`æ„O<—>;…šW ῌža¤_iÿÇâ_ är ¤ #JHeíZ9õ9ã²C¤Nˤ¶m¸$’Î1Šr:²ÄœúÝÏGX½Ç$£N#xóZ‡Whùì«àCvu³[u¿÷½=8ôƪ¨4E°üQsŽWTí)À9½ß9àÙ )³- ý/%Ùæž ӟφ̡= 9lTk=Æzû¬=&†Ö)U±\uíxOÁ ÜÌŽ‘ªöyÆ­Á¤#£Êb÷%Z‚ûÀt[nSŸSi{¶¢6>†ÅÃǰ“<ÛC‹ïPÉgõñž¯Tð©Ÿ
xœíËnÜ6ð®¯àá›@àCÒ6@Rè!È!‘í¦€6@kÀýýÎ II+q³*°©µeJ³Ãáh8œ)
¯à-Z1)NôûîÏr€0ŽþKGE*¡¥.Ø  }ƾúáæþþæ×woD×)¦¸ë¿v¯ÞÝøýG6dxs5ã:ëºBÎ?iZ\Ýv^+DbÓØLi›Ãæ/Dô2B™´ .ðÍQ:g} Œ‰B}W?w?^¡xUç¤Õ&ꈿP´÷,{½ŠrA6T¦¡+] UIe ˜ M
’W!Z¡äççϦQ#´=©QÈJÜ%©C0Ö²Ê<)T‡ ªO…øsS•4.Éi]؆*UËìê'|w»3âyµÔ·µd<¹Q
a‹–\Xk)¶”d¤WPñU¤…ÚÊ˨¢V†4êœøëfeœÑü¡„èRÉoƒr1j²Ö4ý4»y nQøï¼<gZ!Tnöüå2ÕŽ‰bÓ‚UêëöªMÚÛ°j $[€y´Õ5g‰A ×Û§ sÔhXîäó…±ïðíZ ù1ÝzB`,Ês~ïq$wñcºõÌqà{m}÷¥ûM|KA"`À%ãgMN vLtq³°hI¨T­HV´}Áâ4äÇ}G¨u2@
. “¡€ÉÔa|Fì¢E™èÜw4‚Á¡£ fúq¡˜]¼ª8b3dX—aUHfG¢ð+¸Ÿæ6 žøÝöYŠÌŽÁ<™à ;yË 6êêË>S.aáÛ Õ29C6ðº8?Ùï-uh鹫
Iï|¡Ô9î5@Óob»C³ˆh9æ€ÓâI9ÅÔ°# ¢M)/v.Ñ»½Ã%¥Û è6tëL$ór„º‡ÑD_|ñ¾8â÷süŒúÅ]ÑdÛ7*ºfžk¹«Ã„M)ÆgœÏ#Ö,sDu–Q Ë…ùžb Ox¡å³ïÌH³†–O¢Î¼‡Z>ç4j-Ÿ5”Íe>F¹e6³|{I%2mÙÆí†Ï ˜Ë·¿t^üÝ]v>"ëë¢ÿ©¶¤Šü_˜‘ä=#·sɰaÛ]
gÛr»¦z' Xë¶”ÍÆ˜gïfÆù…›=ýÌ4¥pJDÆ è9³áª#ˆ9>ÞbâË`R‚邊„£
!h/Lð¸ï¬Ò´œÃ™Îc­´s쬧¹sà…LåNk¯Iˆ¤E¢q>f:ëˆ5µ¸d~ÖPŽæ·hÇ5½Y›¥! HÈ8’šèÊLtPyv¸öLg¸°Òñæ³6ìê’lí‰Ü}¤xÆ÷Jëb¢“°MéØ„çŸŽ­Z¦ããqâaç
@øv%•Iêôq ìÌx>˜ä dw€XêÉt!ÀI0>@‹ÌOíþ,[L á¨õ´,Hoɦ‚ôQ  ÞFÛ ÿç&Ô®ÅpÄà¶ZÝR«<ñ8ë·ä’Gá%í.^:ÜÈš“Ÿ¯Ðxæ„<—>;ˆš—ñ¿ §q¤ßè éÉäã;9cÌLZ—¾íÄÁóßÃ:³Ì enû7hR£þuåde‰9õ»ŸpfÅ$£N#h³[‡Whùì«àCus\—`õ-?z¬S)bùªé[íV-¬Z)À}”½=8: /³-#Ä•-i‰vº2% úó™’=¶g´Çmk³÷`”}îÞãù;ö±ƒV,x©@;𤂸-˜B­yÇmÁðqSe1‚k`I§–à˜sËÀý·©Ï©´ÕÁ¯¨ŽsõñãÜIžýQÀÒ3³sºþÂÒÀ.
endstream
endobj
15 0 obj
<<
/Length 5411
/Length 5425
/Filter /FlateDecode
>>
stream
@@ -730,10 +733,8 @@ AZ
¨¡V;@ ÔP@ ¨¡²ÑÿÍSCEù;)ß•!±ü;p0áH&à`¦°¸&à`ºj(p0p0Ó­Kû}×Âw^ÅÀÍ'©HªˆÉŽ0Yû TGþêHÕPÕPÕÑ
¶µ :Ê-wÑ ÌŒ¼¬ó€êÈf{òåÂumÀ0Ó¹ùw3ï¢:õ“ŽóÈç?¢Ét£s‚‘ªïÿQ,ÿð¹/åÿðÿQûPþ#à?þ#à?þ£Ûò½\˜®æo]š/h’mpP#ÅÄ*0IX ɱåZ€)–€ ˜‘\òÌHÀŒÌHíC˜‘€ ˜‘š¢ÌHÀŒt˜éåÂuŽ/‰Ät) ¨ŸËò)]ðA)‰˜h( €1)’€1 “ÜBŒIÀ˜ŒIíC“€1 “€1 “n˘¤Ë:0ÁGîÊ‚¹VHß"í¸ôI@Ÿ„ƒ@ŸôI@ŸôI§ è“#è“€>éÁòðôö»ÐÑf7dÜÀšKÀšT2°&d1Àš¬IÀš¬IÀš¬IÀš¬I7eMz¹ŒhÒ}&ß|g'O¹ã˜bn¤ åÐ)­¿útJ©°Ð)Ð)­v€N 蔀N 蔀Né*:¥Ö/Üjìj;æ"[ ¸•.À­´Çßš„·R¬À­Ô…Þ-§n¥Ã±n%àVn%àVêÀè[æVjú’¬>šT YŠ$ Yš¥Y4K@³t¿[4K@³4K@³´
Ð,ÍÒU4KÑïçd¿(«ëQD“´«9î%ª{‹ñÚ_¿Çä!¼€ÆdguˆÑQÌÕ>~ÿ(5Amëy¸¾Õ/fý cÛÚ˜%ˆ}Ø>*wµºýa\úù¹|ÿÏÝðèí^»CÐË噣ÔÈHkOÊ­'] ¹H>(ÞaG°¥ËøÜ­CræÐ ú <®ð7‡bªÊj1å«Í†{|ž)9ðçd¯¥tð{¼äGíHõÀåÞ¬”¹¥I£ä Ç©¹,ÊÛrª¶º»R÷`.üÊ@Ábƒq&³S>
ÁB7Òÿ'…ihÁŒ1õÑ=ì™ûÀh(Üö<ÉÐø®—FD)òWëŸÏ¾“E
êF
KSóŒƒ>Š&(pò¾¡ Y3”ÙƒwÀ‚ÒÉj ºÛsôû áNÃéÝå‘ ʲ»ËžßF¬Þ5.Fm½ÂüzÑ=p!¬'. ñT±¼ÑŽåðq¾‘ð£†ÞµpëÁwžàïJž^^”3zW´Ú7ç”~µü}¬„þxGØ{éÈø^:ÂßKGÄ{éˆ|¬Ž¼Ãê|Õú¯sn íOóºh+s4¯±v;5]|­;óÓu-ë$'ém¸Ç¨X ×»í¬Î±N=8wl æKƒºT7âœCà/óQÏ:'6´» ÆA¶1'¬ú¡\¹:nµï-òg÷Z89³ÿ)Ø@騨lñ¶®{Ê«öÏÖ›¯Obþ4§}Úþ™ŒBŒóÁÿ†7×¾åŸàô1ï¼ý¾ÅË­ƒµžeFºÚFz¸›û
O}ý
ÁB7Òÿ'…ihÁŒ1õÑ=ì™ûÀh(Üö<ɰx×Ë£*‰ù«õÏgßÉ"u#…¥©yÆAŸNEÄûΘJg
)((³/“Õ2t·çè÷A†ӻË#$”ew—=¿æêÈûÅÆÈÆdú˜Â‡0¿btëŠ l<e,o4d9|œïH$üǨ¡y-Ü‚ð¨'æ4ü-D±öɱ±Ì(gö®xµoΩýjùúX‰ýñް÷Ò‘ñ½t„¿—Žˆ÷ÒùXy‡U<ùªõ_çjÜÜŸæuÑVèh^cí¶jº7[xæ'ìZÖIn¿o÷¨•«á|·ÕY#Ö©çŽÔ|yP—ìFœsüe>êY DØDÒî†?ÙÆœ¸ê‡reë¸9Ô¾·ÈŸÝkáäÌ>¨`¥£¢²ÅÛ‚VVퟭ7_ŸÄü©Nû´ý3ç7‚ÿ ®}Ë?Áéc>Þy~‹—=Zk=ËŒtµôpW÷?Löw
endstream
endobj
77 0 obj
@@ -1325,10 +1326,10 @@ xref
0000000059 00000 n
0000005563 00000 n
0000006178 00000 n
0000026254 00000 n
0000026266 00000 n
0000001770 00000 n
0000001585 00000 n
0000033101 00000 n
0000033127 00000 n
0000002627 00000 n
0000007053 00000 n
0000007208 00000 n
@@ -1336,24 +1337,24 @@ xref
0000000526 00000 n
0000000650 00000 n
0000000752 00000 n
0000032010 00000 n
0000032036 00000 n
0000000883 00000 n
0000000985 00000 n
0000001116 00000 n
0000001218 00000 n
0000001350 00000 n
0000001452 00000 n
0000037952 00000 n
0000045271 00000 n
0000054156 00000 n
0000062501 00000 n
0000071744 00000 n
0000080223 00000 n
0000082133 00000 n
0000037978 00000 n
0000045297 00000 n
0000054182 00000 n
0000062527 00000 n
0000071770 00000 n
0000080249 00000 n
0000082159 00000 n
0000024508 00000 n
0000002219 00000 n
0000002079 00000 n
0000091285 00000 n
0000091311 00000 n
0000007311 00000 n
0000007428 00000 n
0000007558 00000 n
@@ -1387,23 +1388,23 @@ xref
0000006330 00000 n
0000006609 00000 n
0000024118 00000 n
0000031739 00000 n
0000032307 00000 n
0000036923 00000 n
0000044355 00000 n
0000052437 00000 n
0000061643 00000 n
0000070822 00000 n
0000079503 00000 n
0000081429 00000 n
0000083219 00000 n
0000031765 00000 n
0000032333 00000 n
0000036949 00000 n
0000044381 00000 n
0000052463 00000 n
0000061669 00000 n
0000070848 00000 n
0000079529 00000 n
0000081455 00000 n
0000083245 00000 n
trailer
<<
/Size 87
/Root 3 0 R
/Info 52 0 R
/ID [<6a2a704b01cba44185a92d8d4bcaa9d7> <6a2a704b01cba44185a92d8d4bcaa9d7>]
/ID [<4d0627755c809232c991979db9766911> <4d0627755c809232c991979db9766911>]
>>
startxref
101700
101726
%%EOF
@@ -76,28 +76,6 @@ test.describe('Config', () => {
expect(webSocket.url()).toContain('ws://localhost:4444/collaboration/ws/');
});
test('it checks the AI feature flag from config endpoint', async ({
page,
browserName,
}) => {
await overrideConfig(page, {
AI_FEATURE_ENABLED: false,
});
await page.goto('/');
await createDoc(page, 'doc-ai-feature', browserName, 1);
await page.locator('.bn-block-outer').last().fill('Anything');
await page.getByText('Anything').selectText();
expect(
await page.locator('button[data-test="convertMarkdown"]').count(),
).toBe(1);
expect(await page.locator('button[data-test="ai-actions"]').count()).toBe(
0,
);
});
test('it checks that Crisp is trying to init from config endpoint', async ({
page,
}) => {
@@ -140,26 +118,6 @@ test.describe('Config', () => {
).toBeAttached();
});
test('it checks theme_customization.translations config', async ({
page,
}) => {
await overrideConfig(page, {
theme_customization: {
translations: {
en: {
translation: {
Docs: 'MyCustomDocs',
},
},
},
},
});
await page.goto('/');
await expect(page.getByText('MyCustomDocs')).toBeAttached();
});
test('it checks the config api is called', async ({ page }) => {
const responsePromise = page.waitForResponse(
(response) =>
@@ -172,11 +130,7 @@ test.describe('Config', () => {
expect(response.ok()).toBeTruthy();
const json = (await response.json()) as typeof CONFIG;
const { theme_customization, ...configApi } = json;
expect(theme_customization).toBeDefined();
const { theme_customization: _, ...CONFIG_LEFT } = CONFIG;
expect(configApi).toStrictEqual(CONFIG_LEFT);
expect(json).toStrictEqual(CONFIG);
});
});
@@ -186,14 +140,24 @@ test.describe('Config: Not logged', () => {
test('it checks that theme is configured from config endpoint', async ({
page,
}) => {
await page.goto('/');
await expect(
page.getByText('Collaborative writing, Simplified.'),
).toHaveCSS('font-family', /Roboto/i, {
timeout: 10000,
});
await overrideConfig(page, {
FRONTEND_THEME: 'dsfr',
});
await page.goto('/');
const header = page.locator('header').first();
// alt 'Gouvernement Logo' comes from the theme
await expect(header.getByAltText('Gouvernement Logo')).toBeVisible();
await expect(
page.getByText('Collaborative writing, Simplified.'),
).toHaveCSS('font-family', /Marianne/i, {
timeout: 10000,
});
});
});
@@ -0,0 +1,339 @@
/* eslint-disable playwright/no-conditional-expect */
import { expect, test } from '@playwright/test';
import {
createDoc,
mockedDocument,
overrideConfig,
verifyDocName,
} from './utils-common';
import {
mockAIResponse,
openSuggestionMenu,
writeInEditor,
} from './utils-editor';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc AI feature', () => {
[
{
AI_FEATURE_ENABLED: false,
selector: 'Ask AI',
},
{
AI_FEATURE_ENABLED: true,
AI_FEATURE_BLOCKNOTE_ENABLED: false,
selector: 'Ask AI',
},
{
AI_FEATURE_ENABLED: true,
AI_FEATURE_LEGACY_ENABLED: false,
selector: 'AI',
},
].forEach((config) => {
test(`it checks the AI feature flag from config endpoint: ${JSON.stringify(config)}`, async ({
page,
browserName,
}) => {
await overrideConfig(page, config);
await page.goto('/');
await createDoc(page, 'doc-ai-feature', browserName, 1);
await page.locator('.bn-block-outer').last().fill('Anything');
await page.getByText('Anything').selectText();
expect(
await page.locator('button[data-test="convertMarkdown"]').count(),
).toBe(1);
await expect(
page.getByRole('button', { name: config.selector, exact: true }),
).toBeHidden();
});
});
test('it checks the AI feature and accepts changes', async ({
page,
browserName,
}) => {
await overrideConfig(page, {
AI_BOT: {
name: 'Albert AI',
color: '#8bc6ff',
},
});
await mockAIResponse(page);
await page.goto('/');
await createDoc(page, 'doc-ai', browserName, 1);
await openSuggestionMenu({ page });
await page.getByText('Ask AI').click();
await expect(
page.getByRole('option', { name: 'Continue Writing' }),
).toBeVisible();
await expect(page.getByRole('option', { name: 'Summarize' })).toBeVisible();
await page.keyboard.press('Escape');
const editor = await writeInEditor({ page, text: 'Hello World' });
await editor.getByText('Hello World').selectText();
// Check from toolbar
await page.getByRole('button', { name: 'Ask AI' }).click();
await expect(
page.getByRole('option', { name: 'Improve Writing' }),
).toBeVisible();
await expect(
page.getByRole('option', { name: 'Fix Spelling' }),
).toBeVisible();
await expect(page.getByRole('option', { name: 'Translate' })).toBeVisible();
await page.getByRole('option', { name: 'Translate' }).click();
await page
.getByRole('textbox', { name: 'Ask anything...' })
.fill('Translate into french');
await page.getByRole('textbox', { name: 'Ask anything...' }).press('Enter');
await expect(editor.getByText('Albert AI')).toBeVisible();
await page
.locator('p.bn-mt-suggestion-menu-item-title')
.getByText('Accept')
.click();
await expect(editor.getByText('Bonjour le monde')).toBeVisible();
// Check Suggestion menu
await page.locator('.bn-block-outer').last().fill('/');
await expect(page.getByText('Write with AI')).toBeVisible();
// Reload the page to check that the AI change is still there
await page.goto(page.url());
await expect(editor.getByText('Bonjour le monde')).toBeVisible();
});
test('it reverts with the AI feature', async ({ page, browserName }) => {
await overrideConfig(page, {
AI_BOT: {
name: 'Albert AI',
color: '#8bc6ff',
},
});
await mockAIResponse(page);
await page.goto('/');
await createDoc(page, 'doc-ai', browserName, 1);
const editor = await writeInEditor({ page, text: 'Hello World' });
await editor.getByText('Hello World').selectText();
// Check from toolbar
await page.getByRole('button', { name: 'Ask AI' }).click();
await page.getByRole('option', { name: 'Translate' }).click();
await page
.getByRole('textbox', { name: 'Ask anything...' })
.fill('Translate into french');
await page.getByRole('textbox', { name: 'Ask anything...' }).press('Enter');
await expect(editor.getByText('Albert AI')).toBeVisible();
await expect(editor.getByText('Bonjour le monde')).toBeVisible();
await page
.locator('p.bn-mt-suggestion-menu-item-title')
.getByText('Revert')
.click();
await expect(editor.getByText('Hello World')).toBeVisible();
});
test('it checks the AI buttons feature legacy', async ({
page,
browserName,
}) => {
await page.route(/.*\/ai-translate\//, async (route) => {
const request = route.request();
if (request.method().includes('POST')) {
await route.fulfill({
json: {
answer: 'Hallo Welt',
},
});
} else {
await route.continue();
}
});
await createDoc(page, 'doc-ai', browserName, 1);
await page.locator('.bn-block-outer').last().fill('Hello World');
const editor = page.locator('.ProseMirror');
await editor.getByText('Hello').selectText();
await page.getByRole('button', { name: 'AI', exact: true }).click();
await expect(
page.getByRole('menuitem', { name: 'Use as prompt' }),
).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Rephrase' }),
).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Summarize' }),
).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Correct' })).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Language' }),
).toBeVisible();
await page.getByRole('menuitem', { name: 'Language' }).hover();
await expect(
page.getByRole('menuitem', { name: 'English', exact: true }),
).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'French', exact: true }),
).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'German', exact: true }),
).toBeVisible();
await page.getByRole('menuitem', { name: 'German', exact: true }).click();
await expect(editor.getByText('Hallo Welt')).toBeVisible();
});
[
{ ai_transform: false, ai_translate: false },
{ ai_transform: true, ai_translate: false },
{ ai_transform: false, ai_translate: true },
].forEach(({ ai_transform, ai_translate }) => {
test(`it checks AI buttons when can transform is at "${ai_transform}" and can translate is at "${ai_translate}"`, async ({
page,
browserName,
}) => {
await mockedDocument(page, {
accesses: [
{
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
role: 'owner',
user: {
email: 'super@owner.com',
full_name: 'Super Owner',
},
},
],
abilities: {
destroy: true, // Means owner
link_configuration: true,
ai_transform,
ai_translate,
accesses_manage: true,
accesses_view: true,
update: true,
partial_update: true,
retrieve: true,
},
link_reach: 'restricted',
link_role: 'editor',
created_at: '2021-09-01T09:00:00Z',
title: '',
});
const [randomDoc] = await createDoc(
page,
'doc-editor-ai',
browserName,
1,
);
await verifyDocName(page, randomDoc);
await page.locator('.bn-block-outer').last().fill('Hello World');
const editor = page.locator('.ProseMirror');
await editor.getByText('Hello').selectText();
if (!ai_transform && !ai_translate) {
await expect(
page.getByRole('button', { name: 'AI', exact: true }),
).toBeHidden();
return;
}
await page.getByRole('button', { name: 'AI', exact: true }).click();
if (ai_transform) {
await expect(
page.getByRole('menuitem', { name: 'Use as prompt' }),
).toBeVisible();
} else {
await expect(
page.getByRole('menuitem', { name: 'Use as prompt' }),
).toBeHidden();
}
if (ai_translate) {
await expect(
page.getByRole('menuitem', { name: 'Language' }),
).toBeVisible();
} else {
await expect(
page.getByRole('menuitem', { name: 'Language' }),
).toBeHidden();
}
});
});
test(`it checks ai_proxy ability`, async ({ page, browserName }) => {
await mockedDocument(page, {
accesses: [
{
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
role: 'owner',
user: {
email: 'super@owner.com',
full_name: 'Super Owner',
},
},
],
abilities: {
destroy: true, // Means owner
link_configuration: true,
ai_proxy: false,
accesses_manage: true,
accesses_view: true,
update: true,
partial_update: true,
retrieve: true,
},
link_reach: 'restricted',
link_role: 'editor',
created_at: '2021-09-01T09:00:00Z',
title: '',
});
const [randomDoc] = await createDoc(
page,
'doc-editor-ai-proxy',
browserName,
1,
);
await verifyDocName(page, randomDoc);
await page.locator('.bn-block-outer').last().fill('Hello World');
const editor = page.locator('.ProseMirror');
await editor.getByText('Hello').selectText();
await expect(page.getByRole('button', { name: 'Ask AI' })).toBeHidden();
await page.locator('.bn-block-outer').last().fill('/');
await expect(page.getByText('Write with AI')).toBeHidden();
});
});
@@ -41,7 +41,7 @@ test.describe('Doc Comments', () => {
// We add a comment with the first user
const editor = await writeInEditor({ page, text: 'Hello World' });
await editor.getByText('Hello').selectText();
await page.getByRole('button', { name: 'Comment' }).click();
await page.getByRole('button', { name: 'Comment', exact: true }).click();
const thread = page.locator('.bn-thread');
await thread.getByRole('paragraph').first().fill('This is a comment');
@@ -124,7 +124,7 @@ test.describe('Doc Comments', () => {
// Checks add react reaction
const editor = await writeInEditor({ page, text: 'Hello' });
await editor.getByText('Hello').selectText();
await page.getByRole('button', { name: 'Comment' }).click();
await page.getByRole('button', { name: 'Comment', exact: true }).click();
const thread = page.locator('.bn-thread');
await thread.getByRole('paragraph').first().fill('This is a comment');
@@ -191,7 +191,7 @@ test.describe('Doc Comments', () => {
/* Delete the last comment remove the thread */
await editor.getByText('Hello').selectText();
await page.getByRole('button', { name: 'Comment' }).click();
await page.getByRole('button', { name: 'Comment', exact: true }).click();
await thread.getByRole('paragraph').first().fill('This is a new comment');
await thread.locator('[data-test="save"]').click();
@@ -249,7 +249,9 @@ test.describe('Doc Comments', () => {
editor.getByText('Hello, I can edit the document'),
).toBeVisible();
await otherEditor.getByText('Hello').selectText();
await otherPage.getByRole('button', { name: 'Comment' }).click();
await otherPage
.getByRole('button', { name: 'Comment', exact: true })
.click();
const otherThread = otherPage.locator('.bn-thread');
await otherThread
.getByRole('paragraph')
@@ -280,7 +282,7 @@ test.describe('Doc Comments', () => {
await expect(otherThread).toBeHidden();
await otherEditor.getByText('Hello').selectText();
await expect(
otherPage.getByRole('button', { name: 'Comment' }),
otherPage.getByRole('button', { name: 'Comment', exact: true }),
).toBeHidden();
await otherPage.reload();
@@ -334,7 +336,7 @@ test.describe('Doc Comments', () => {
// We add a comment in the first document
const editor1 = await writeInEditor({ page, text: 'Document One' });
await editor1.getByText('Document One').selectText();
await page.getByRole('button', { name: 'Comment' }).click();
await page.getByRole('button', { name: 'Comment', exact: true }).click();
const thread1 = page.locator('.bn-thread');
await thread1.getByRole('paragraph').first().fill('Comment in Doc One');
@@ -388,7 +390,7 @@ test.describe('Doc Comments mobile', () => {
// Checks add react reaction
const editor = await writeInEditor({ page, text: 'Hello' });
await editor.getByText('Hello').selectText();
await page.getByRole('button', { name: 'Comment' }).click();
await page.getByRole('button', { name: 'Comment', exact: true }).click();
const thread = page.locator('.bn-thread');
await thread.getByRole('paragraph').first().fill('This is a comment');
@@ -1,4 +1,3 @@
/* eslint-disable playwright/no-conditional-expect */
import path from 'path';
import { expect, test } from '@playwright/test';
@@ -7,7 +6,6 @@ import cs from 'convert-stream';
import {
createDoc,
goToGridDoc,
mockedDocument,
overrideConfig,
verifyDocName,
} from './utils-common';
@@ -39,6 +37,7 @@ test.describe('Doc Editor', () => {
.selectText();
const toolbar = page.locator('.bn-formatting-toolbar');
await expect(toolbar.getByRole('button', { name: 'Ask AI' })).toBeVisible();
await expect(
toolbar.locator('button[data-test="comment-toolbar-button"]'),
).toBeVisible();
@@ -64,9 +63,6 @@ test.describe('Doc Editor', () => {
await expect(
toolbar.locator('button[data-test="createLink"]'),
).toBeVisible();
await expect(
toolbar.locator('button[data-test="ai-actions"]'),
).toBeVisible();
await expect(
toolbar.locator('button[data-test="convertMarkdown"]'),
).toBeVisible();
@@ -93,14 +89,12 @@ test.describe('Doc Editor', () => {
await expect(image).toHaveAttribute('role', 'presentation');
await image.dblclick();
await image.click();
await expect(toolbar.getByRole('button', { name: 'Ask AI' })).toBeHidden();
await expect(
toolbar.locator('button[data-test="comment-toolbar-button"]'),
).toBeHidden();
await expect(
toolbar.locator('button[data-test="ai-actions"]'),
).toBeHidden();
await expect(
toolbar.locator('button[data-test="convertMarkdown"]'),
).toBeHidden();
@@ -389,139 +383,6 @@ test.describe('Doc Editor', () => {
await expect(image).toHaveAttribute('aria-hidden', 'true');
});
test('it checks the AI buttons', async ({ page, browserName }) => {
await page.route(/.*\/ai-translate\//, async (route) => {
const request = route.request();
if (request.method().includes('POST')) {
await route.fulfill({
json: {
answer: 'Bonjour le monde',
},
});
} else {
await route.continue();
}
});
await createDoc(page, 'doc-ai', browserName, 1);
await page.locator('.bn-block-outer').last().fill('Hello World');
const editor = page.locator('.ProseMirror');
await editor.getByText('Hello').selectText();
await page.getByRole('button', { name: 'AI' }).click();
await expect(
page.getByRole('menuitem', { name: 'Use as prompt' }),
).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Rephrase' }),
).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Summarize' }),
).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Correct' })).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Language' }),
).toBeVisible();
await page.getByRole('menuitem', { name: 'Language' }).hover();
await expect(
page.getByRole('menuitem', { name: 'English', exact: true }),
).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'French', exact: true }),
).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'German', exact: true }),
).toBeVisible();
await page.getByRole('menuitem', { name: 'English', exact: true }).click();
await expect(editor.getByText('Bonjour le monde')).toBeVisible();
});
[
{ ai_transform: false, ai_translate: false },
{ ai_transform: true, ai_translate: false },
{ ai_transform: false, ai_translate: true },
].forEach(({ ai_transform, ai_translate }) => {
test(`it checks AI buttons when can transform is at "${ai_transform}" and can translate is at "${ai_translate}"`, async ({
page,
browserName,
}) => {
await mockedDocument(page, {
accesses: [
{
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
role: 'owner',
user: {
email: 'super@owner.com',
full_name: 'Super Owner',
},
},
],
abilities: {
destroy: true, // Means owner
link_configuration: true,
ai_transform,
ai_translate,
accesses_manage: true,
accesses_view: true,
update: true,
partial_update: true,
retrieve: true,
},
link_reach: 'restricted',
link_role: 'editor',
created_at: '2021-09-01T09:00:00Z',
title: '',
});
const [randomDoc] = await createDoc(
page,
'doc-editor-ai',
browserName,
1,
);
await verifyDocName(page, randomDoc);
await page.locator('.bn-block-outer').last().fill('Hello World');
const editor = page.locator('.ProseMirror');
await editor.getByText('Hello').selectText();
if (!ai_transform && !ai_translate) {
await expect(page.getByRole('button', { name: 'AI' })).toBeHidden();
return;
}
await page.getByRole('button', { name: 'AI' }).click();
if (ai_transform) {
await expect(
page.getByRole('menuitem', { name: 'Use as prompt' }),
).toBeVisible();
} else {
await expect(
page.getByRole('menuitem', { name: 'Use as prompt' }),
).toBeHidden();
}
if (ai_translate) {
await expect(
page.getByRole('menuitem', { name: 'Language' }),
).toBeVisible();
} else {
await expect(
page.getByRole('menuitem', { name: 'Language' }),
).toBeHidden();
}
});
});
test('it downloads unsafe files', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1);
@@ -976,7 +837,10 @@ test.describe('Doc Editor', () => {
await expect(pdfBlock).toBeVisible();
// Try with invalid PDF first
await page.getByText(/Add (PDF|file)/).click();
await page
.getByText(/Add (PDF|file)/)
.first()
.click();
await page.locator('[data-test="embed-tab"]').click();
@@ -994,7 +858,6 @@ test.describe('Doc Editor', () => {
// Now with a valid PDF
await page.getByText(/Add (PDF|file)/).click();
const fileChooserPromise = page.waitForEvent('filechooser');
const downloadPromise = page.waitForEvent('download');
await page.getByText(/Upload (PDF|file)/).click();
const fileChooser = await fileChooserPromise;
@@ -1003,24 +866,16 @@ test.describe('Doc Editor', () => {
// Wait for the media-check to be processed
await page.waitForTimeout(1000);
const pdfEmbed = page
.locator('.--docs--editor-container embed.bn-visual-media')
const pdfIframe = page
.locator('.--docs--editor-container iframe.bn-visual-media')
.first();
// Check src of pdf
expect(await pdfEmbed.getAttribute('src')).toMatch(
expect(await pdfIframe.getAttribute('src')).toMatch(
/http:\/\/localhost:8083\/media\/.*\/attachments\/.*.pdf/,
);
await expect(pdfEmbed).toHaveAttribute('type', 'application/pdf');
await expect(pdfEmbed).toHaveAttribute('role', 'presentation');
// Check download with original filename
await pdfBlock.click();
await page.locator('[data-test="downloadfile"]').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('test-pdf.pdf');
await expect(pdfIframe).toHaveAttribute('role', 'presentation');
});
test('it preserves text when switching between mobile and desktop views', async ({
@@ -1,19 +1,18 @@
import fs from 'fs';
import path from 'path';
import { Download, Page, expect, test } from '@playwright/test';
import { expect, test } from '@playwright/test';
import cs from 'convert-stream';
import JSZip from 'jszip';
import { PDFParse } from 'pdf-parse';
import {
BrowserName,
TestLanguage,
createDoc,
verifyDocName,
waitForLanguageSwitch,
} from './utils-common';
import { openSuggestionMenu, writeInEditor } from './utils-editor';
import { comparePDFWithAssetFolder, overrideDocContent } from './utils-export';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -33,7 +32,9 @@ test.describe('Doc Export', () => {
await expect(page.getByTestId('modal-export-title')).toBeVisible();
await expect(
page.getByText(/Download your document in a \.docx, \.odt.*format\./i),
page.getByText(
'Export your document to print or download in .docx, .odt, .pdf or .html(zip) format.',
),
).toBeVisible();
await expect(page.getByRole('combobox', { name: 'Format' })).toBeVisible();
await expect(
@@ -306,10 +307,63 @@ test.describe('Doc Export', () => {
expect(pdfString).toContain('/Lang (fr)');
});
test('it exports the doc to PDF with PRINT feature and checks regressions', async ({
page,
browserName,
}, testInfo) => {
// PDF Binary comparison is different depending on the browser used
// We only run this test on Chromium to avoid having to maintain
// multiple sets of PDF fixtures
if (browserName !== 'chromium') {
test.skip();
}
await overrideDocContent({ page, browserName });
await page
.getByRole('button', {
name: 'Export the document',
})
.click();
await page.getByRole('combobox', { name: 'Format' }).click();
await page.getByRole('option', { name: 'Print' }).click();
await page.getByRole('button', { name: 'Print' }).click();
await expect(page.locator('#print-only-content-styles')).toBeAttached();
await page.emulateMedia({ media: 'print' });
const pdfBuffer = await page.pdf({
printBackground: true,
preferCSSPageSize: true,
format: 'A4',
scale: 1,
});
// If we need to update the PDF regression fixture, uncomment the line below
// await savePDFToAssetFolder(
// pdfBuffer,
// 'doc-export-PDF-browser-regressions.pdf',
// );
// Assert the generated PDF matches the initial PDF regression fixture
await comparePDFWithAssetFolder({
originPdfBuffer: pdfBuffer,
filename: 'doc-export-PDF-browser-regressions.pdf',
compareTextContent: false,
comparePixel: false,
testInfo,
});
await expect(page.locator('#print-only-content-styles')).not.toBeAttached();
});
test('it exports the doc to PDF and checks regressions', async ({
page,
browserName,
}) => {
}, testInfo) => {
// PDF Binary comparison is different depending on the browser used
// We only run this test on Chromium to avoid having to maintain
// multiple sets of PDF fixtures
@@ -325,10 +379,6 @@ test.describe('Doc Export', () => {
})
.click();
await expect(
page.getByTestId('doc-open-modal-download-button'),
).toBeVisible();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
});
@@ -338,148 +388,16 @@ test.describe('Doc Export', () => {
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
// If we need to update the PDF regression fixture, uncomment the line below
//await savePDFToAssetFolder(download);
//await savePDFToAssetFolder(pdfBuffer, 'doc-export-regressions.pdf');
// Assert the generated PDF matches "assets/doc-export-regressions.pdf"
await comparePDFWithAssetFolder(download);
await comparePDFWithAssetFolder({
originPdfBuffer: pdfBuffer,
filename: 'doc-export-regressions.pdf',
testInfo,
});
});
});
export const savePDFToAssetFolder = async (download: Download) => {
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfPath = path.join(__dirname, 'assets', `doc-export-regressions.pdf`);
fs.writeFileSync(pdfPath, pdfBuffer);
};
export const comparePDFWithAssetFolder = async (download: Download) => {
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
// Load reference PDF for comparison
const referencePdfPath = path.join(
__dirname,
'assets',
'doc-export-regressions.pdf',
);
const referencePdfBuffer = fs.readFileSync(referencePdfPath);
// Parse both PDFs
const generatedPdf = new PDFParse({ data: pdfBuffer });
const referencePdf = new PDFParse({ data: referencePdfBuffer });
const [generatedInfo, referenceInfo] = await Promise.all([
generatedPdf.getInfo(),
referencePdf.getInfo(),
]);
const [generatedScreenshot, referenceScreenshot] = await Promise.all([
generatedPdf.getScreenshot(),
referencePdf.getScreenshot(),
]);
generatedScreenshot.pages[0].data;
const [generatedText, referenceText] = await Promise.all([
generatedPdf.getText(),
referencePdf.getText(),
]);
// Compare page count
expect(generatedInfo.total).toBe(referenceInfo.total);
// Compare text content
expect(generatedText.text).toBe(referenceText.text);
// Compare screenshots page by page
for (let i = 0; i < generatedScreenshot.pages.length; i++) {
const genPage = generatedScreenshot.pages[i];
const refPage = referenceScreenshot.pages[i];
expect(genPage.width).toBe(refPage.width);
expect(genPage.height).toBe(refPage.height);
expect(genPage.data).toStrictEqual(refPage.data);
}
};
/**
* Override the document content API response to use a test content
* This test content contains many blocks to facilitate testing
* @param page
*/
export const overrideDocContent = async ({
page,
browserName,
}: {
page: Page;
browserName: BrowserName;
}) => {
// Override content prop with assets/base-content-test-pdf.txt
await page.route(/\**\/documents\/\**/, 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')
) {
const response = await route.fetch();
const json = await response.json();
json.content = fs.readFileSync(
path.join(__dirname, 'assets/base-content-test-pdf.txt'),
'utf-8',
);
void route.fulfill({
response,
body: JSON.stringify(json),
});
} else {
await route.continue();
}
});
const [randomDoc] = await createDoc(
page,
'doc-export-override-content',
browserName,
1,
);
await verifyDocName(page, randomDoc);
await page.waitForTimeout(1000);
// Add Image SVG
await page.keyboard.press('Enter');
const { suggestionMenu } = await openSuggestionMenu({ page });
await suggestionMenu.getByText('Resizable image with caption').click();
const fileChooserPromise = page.waitForEvent('filechooser');
await page.getByText('Upload image').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(path.join(__dirname, 'assets/test.svg'));
const image = page
.locator('.--docs--editor-container img.bn-visual-media[src$=".svg"]')
.first();
await expect(image).toBeVisible();
await page.keyboard.press('Enter');
await page.waitForTimeout(1000);
// Add Image PNG
await openSuggestionMenu({ page });
await suggestionMenu.getByText('Resizable image with caption').click();
const fileChooserPNGPromise = page.waitForEvent('filechooser');
await page.getByText('Upload image').click();
const fileChooserPNG = await fileChooserPNGPromise;
await fileChooserPNG.setFiles(
path.join(__dirname, 'assets/logo-suite-numerique.png'),
);
const imagePng = page
.locator('.--docs--editor-container img.bn-visual-media[src$=".png"]')
.first();
await expect(imagePng).toBeVisible();
await page.waitForTimeout(1000);
return randomDoc;
};
@@ -1,10 +1,26 @@
import { expect, test } from '@playwright/test';
import { createDoc, mockedListDocs, toggleHeaderMenu } from './utils-common';
import {
createDoc,
getGridRow,
getOtherBrowserName,
mockedListDocs,
toggleHeaderMenu,
verifyDocName,
} from './utils-common';
import { writeInEditor } from './utils-editor';
import {
addNewMember,
connectOtherUserToDoc,
updateShareLink,
} from './utils-share';
import { createRootSubPage } from './utils-sub-pages';
test.describe('Doc grid dnd', () => {
test('it creates a doc', async ({ page, browserName }) => {
test.describe('Doc grid move', () => {
test('it checks drag and drop functionality', async ({
page,
browserName,
}) => {
await page.goto('/');
const header = page.locator('header').first();
await createDoc(page, 'Draggable doc', browserName, 1);
@@ -29,7 +45,7 @@ test.describe('Doc grid dnd', () => {
await expect(draggableElement).toBeVisible();
await expect(dropZone).toBeVisible();
// Obtenir les positions des éléments
// Get the position of the elements
const draggableBoundingBox = await draggableElement.boundingBox();
const dropZoneBoundingBox = await dropZone.boundingBox();
@@ -46,7 +62,7 @@ test.describe('Doc grid dnd', () => {
);
await page.mouse.down();
// Déplacer vers la zone cible
// Move to the target zone
await page.mouse.move(
dropZoneBoundingBox.x + dropZoneBoundingBox.width / 2,
dropZoneBoundingBox.y + dropZoneBoundingBox.height / 2,
@@ -161,6 +177,213 @@ test.describe('Doc grid dnd', () => {
await page.mouse.up();
});
test('it moves a doc from the doc search modal', async ({
page,
browserName,
}) => {
await page.goto('/');
const [titleDoc1] = await createDoc(page, 'Draggable doc', browserName, 1);
const otherBrowserName = getOtherBrowserName(browserName);
await page.getByRole('button', { name: 'Share' }).click();
await addNewMember(page, 0, 'Administrator', otherBrowserName);
await page
.getByRole('dialog')
.getByRole('button', { name: 'close' })
.click();
await page.getByRole('button', { name: 'Back to homepage' }).click();
const [titleDoc2] = await createDoc(page, 'Droppable doc', browserName, 1);
await page.getByRole('button', { name: 'Back to homepage' }).click();
const docsGrid = page.getByTestId('docs-grid');
await expect(docsGrid.getByText(titleDoc1)).toBeVisible();
await expect(docsGrid.getByText(titleDoc2)).toBeVisible();
const row = await getGridRow(page, titleDoc1);
await row.getByText(`more_horiz`).click();
await page.getByRole('menuitem', { name: 'Move into a doc' }).click();
await expect(
page.getByRole('dialog').getByRole('heading', { name: 'Move' }),
).toBeVisible();
const input = page.getByRole('combobox', { name: 'Quick search input' });
await input.click();
await input.fill(titleDoc2);
await expect(
page.getByRole('option').first().getByText(titleDoc2),
).toBeVisible();
// Select the first result
await page.keyboard.press('Enter');
// The CTA should get the focus
await page.keyboard.press('Tab');
// Validate the move action
await page.keyboard.press('Enter');
await expect(
page
.getByRole('dialog')
.getByText('it will lose its current access rights'),
).toBeVisible();
await page
.getByRole('dialog')
.getByRole('button', { name: 'Move', exact: true })
.first()
.click();
await expect(docsGrid.getByText(titleDoc1)).toBeHidden();
await docsGrid
.getByRole('link', { name: `Open document ${titleDoc2}` })
.click();
await verifyDocName(page, titleDoc2);
const docTree = page.getByTestId('doc-tree');
await expect(docTree.getByText(titleDoc1)).toBeVisible();
});
test('it proposes an access request when moving a doc without sufficient permissions', async ({
page,
browserName,
}) => {
test.slow();
await page.goto('/');
const [titleDoc1] = await createDoc(page, 'Move doc', browserName, 1);
const { otherPage, cleanup } = await connectOtherUserToDoc({
docUrl: '/',
browserName,
});
// Another user creates a doc
const [titleDoc2] = await createDoc(otherPage, 'Drop doc', browserName, 1);
await writeInEditor({
page: otherPage,
text: 'Hello world',
});
// Make it public
await otherPage.getByRole('button', { name: 'Share' }).click();
await updateShareLink(otherPage, 'Public');
await otherPage
.getByRole('dialog')
.getByRole('button', { name: 'close' })
.click();
const otherPageUrl = otherPage.url();
// The first user visit the doc to have it in his grid list
await page.goto(otherPageUrl);
await expect(page.getByText('Hello world')).toBeVisible();
await page.waitForTimeout(1000);
await page.getByRole('button', { name: 'Back to homepage' }).click();
const docsGrid = page.getByTestId('docs-grid');
await expect(docsGrid.getByText(titleDoc1)).toBeVisible();
await expect(docsGrid.getByText(titleDoc2)).toBeVisible();
const row = await getGridRow(page, titleDoc1);
await row.getByText(`more_horiz`).click();
await page.getByRole('menuitem', { name: 'Move into a doc' }).click();
await expect(
page.getByRole('dialog').getByRole('heading', { name: 'Move' }),
).toBeVisible();
const input = page.getByRole('combobox', { name: 'Quick search input' });
await input.click();
await input.fill(titleDoc2);
await expect(
page.getByRole('option').first().getByText(titleDoc2),
).toBeVisible();
// Select the first result
await page.keyboard.press('Enter');
// The CTA should get the focus
await page.keyboard.press('Tab');
// Validate the move action
await page.keyboard.press('Enter');
// Request access modal should be visible
await expect(
page
.getByRole('dialog')
.getByText(
'You need edit access to the destination. Request access, then try again.',
),
).toBeVisible();
await page
.getByRole('dialog')
.getByRole('button', { name: 'Request access', exact: true })
.first()
.click();
// The other user should receive the access request and be able to approve it
await otherPage.getByRole('button', { name: 'Share' }).click();
await expect(otherPage.getByText('Access Requests')).toBeVisible();
await expect(otherPage.getByText(`E2E ${browserName}`)).toBeVisible();
const emailRequest = `user.test@${browserName}.test`;
await expect(otherPage.getByText(emailRequest)).toBeVisible();
const container = otherPage.getByTestId(
`doc-share-access-request-row-${emailRequest}`,
);
await container.getByTestId('doc-role-dropdown').click();
await otherPage.getByRole('menuitem', { name: 'Administrator' }).click();
await container.getByRole('button', { name: 'Approve' }).click();
await expect(otherPage.getByText('Access Requests')).toBeHidden();
await expect(otherPage.getByText('Share with 2 users')).toBeVisible();
await expect(otherPage.getByText(`E2E ${browserName}`)).toBeVisible();
// The first user should now be able to move the doc
await page.reload();
await row.getByText(`more_horiz`).click();
await page.getByRole('menuitem', { name: 'Move into a doc' }).click();
await expect(
page.getByRole('dialog').getByRole('heading', { name: 'Move' }),
).toBeVisible();
await input.click();
await input.fill(titleDoc2);
await expect(
page.getByRole('option').first().getByText(titleDoc2),
).toBeVisible();
// Select the first result
await page.keyboard.press('Enter');
// The CTA should get the focus
await page.keyboard.press('Tab');
// Validate the move action
await page.keyboard.press('Enter');
await expect(docsGrid.getByText(titleDoc1)).toBeHidden();
await docsGrid
.getByRole('link', { name: `Open document ${titleDoc2}` })
.click();
await verifyDocName(page, titleDoc2);
const docTree = page.getByTestId('doc-tree');
await expect(docTree.getByText(titleDoc1)).toBeVisible();
await cleanup();
});
});
test.describe('Doc grid dnd mobile', () => {
@@ -115,7 +115,7 @@ test.describe('Document grid item options', () => {
// Pin
await row.getByText(`more_horiz`).click();
await page.getByText('push_pin').click();
await page.getByRole('menuitem', { name: 'Pin' }).click();
// Check is pinned
await expect(row.getByTestId('doc-pinned-icon')).toBeVisible();
@@ -264,7 +264,7 @@ test.describe('Documents Grid', () => {
test('checks all the elements are visible', async ({ page }) => {
void page.goto('/');
let docs: SmallDoc[] = [];
let docs: SmallDoc[];
const response = await page.waitForResponse(
(response) =>
response.url().endsWith('documents/?page=1') &&
@@ -290,7 +290,7 @@ test.describe('Documents Grid', () => {
});
test('checks the infinite scroll', async ({ page }) => {
let docs: SmallDoc[] = [];
let docs: SmallDoc[];
const responsePromisePage1 = page.waitForResponse((response) => {
return (
response.url().endsWith(`/documents/?page=1`) &&
@@ -7,7 +7,13 @@ import {
mockedDocument,
verifyDocName,
} from './utils-common';
import { mockedAccesses, mockedInvitations } from './utils-share';
import { writeInEditor } from './utils-editor';
import {
connectOtherUserToDoc,
mockedAccesses,
mockedInvitations,
updateShareLink,
} from './utils-share';
import { createRootSubPage, getTreeRow } from './utils-sub-pages';
test.beforeEach(async ({ page }) => {
@@ -15,6 +21,46 @@ test.beforeEach(async ({ page }) => {
});
test.describe('Doc Header', () => {
test('toggles panel collapse from floating bar button', async ({
page,
browserName,
}) => {
const [docTitle] = await createDoc(
page,
'doc-floating-bar',
browserName,
1,
);
const cardCollapse = page.locator('.--docs--left-panel-collapse-button');
const collapseButton = cardCollapse.getByTestId(
'floating-bar-toggle-left-panel',
);
await expect(collapseButton).toBeVisible();
// Panel open
await expect(collapseButton).toHaveAttribute('aria-expanded', 'true');
await expect(collapseButton.getByText(docTitle)).toBeHidden();
// Collapse panel
await collapseButton.click();
await expect(collapseButton).toHaveAttribute('aria-expanded', 'false');
await expect(cardCollapse.getByText(docTitle)).toBeHidden();
// When the title is not visible in the viewport, the button should show the title
const editor = await writeInEditor({ page, text: 'Lorem ipsum' });
for (let i = 0; i < 25; i++) {
await editor.press('Enter');
}
await writeInEditor({ page, text: 'Lorem ipsum 2' });
await expect(cardCollapse.getByText(docTitle)).toBeVisible();
// Expand panel and check the title is hidden again
await collapseButton.click();
await expect(collapseButton).toHaveAttribute('aria-expanded', 'true');
await expect(cardCollapse.getByText(docTitle)).toBeHidden();
});
test('it checks the element are correctly displayed', async ({
page,
browserName,
@@ -52,13 +98,54 @@ test.describe('Doc Header', () => {
).toBeVisible();
});
test('it updates the title doc', async ({ page, browserName }) => {
await createDoc(page, 'doc-update', browserName, 1);
const docTitle = page.getByRole('textbox', { name: 'Document title' });
await expect(docTitle).toBeVisible();
await docTitle.fill('Hello World');
await docTitle.blur();
test('it updates the title doc and check the broadcast', async ({
page,
browserName,
}) => {
const [docTitle] = await createDoc(
page,
'doc-title-update',
browserName,
1,
);
await page.getByRole('button', { name: 'Share' }).click();
await updateShareLink(page, 'Public', 'Editing');
const docUrl = page.url();
const { otherPage, cleanup } = await connectOtherUserToDoc({
docUrl,
browserName,
withoutSignIn: true,
docTitle,
});
// Wait for other page to sync
await page.waitForTimeout(1000);
await page.keyboard.press('Escape');
const elTitle = page.getByRole('textbox', { name: 'Document title' });
await expect(elTitle).toBeVisible();
await elTitle.fill('Hello World');
await elTitle.blur();
await verifyDocName(page, 'Hello World');
// Wait for other page to sync
await page.waitForTimeout(1000);
// Check other user page
await verifyDocName(otherPage, 'Hello World');
const elTitleOther = otherPage.getByRole('textbox', {
name: 'Document title',
});
await elTitleOther.fill('Hello Other World');
await elTitleOther.blur();
// Check first user page
await verifyDocName(page, 'Hello Other World');
await cleanup();
});
test('it updates the title doc adding a leading emoji', async ({
@@ -459,7 +546,7 @@ test.describe('Doc Header', () => {
.click();
// Pin
await page.getByText('push_pin').click();
await page.getByRole('menuitem', { name: 'Pin' }).click();
await page
.getByRole('button', { name: 'Open the document options' })
.click();
@@ -480,11 +567,11 @@ test.describe('Doc Header', () => {
.click();
// Unpin
await page.getByText('Unpin').click();
await page.getByRole('menuitem', { name: 'Unpin' }).click();
await page
.getByRole('button', { name: 'Open the document options' })
.click();
await expect(page.getByText('push_pin')).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Pin' })).toBeVisible();
await page.goto('/');
@@ -45,7 +45,7 @@ test.describe('Document search', () => {
const listSearch = page.getByRole('listbox').getByRole('group');
const rowdoc = listSearch.getByRole('option').first();
await expect(rowdoc.getByText('keyboard_return')).toBeVisible();
await expect(rowdoc.getByText(/seconds? ago/)).toBeVisible();
await expect(rowdoc.getByText(/just now/)).toBeVisible();
await expect(
listSearch.getByRole('option').getByText(doc1Title),
@@ -56,14 +56,13 @@ test.describe('Footer', () => {
test('checks the footer is correctly overrided', async ({ page }) => {
await overrideConfig(page, {
FRONTEND_THEME: 'dsfr',
theme_customization: {
footer: {
default: {
logo: {
src: '/assets/logo-gouv.svg',
width: '220px',
alt: 'Gouvernement Logo',
style: { width: '220px', height: 'auto' },
},
externalLinks: [
{
@@ -34,10 +34,15 @@ test.describe('Header', () => {
FRONTEND_THEME: 'dsfr',
theme_customization: {
header: {
logo: {
src: '/assets/logo-gouv.svg',
width: '220px',
alt: 'Gouvernement Logo',
icon: {
src: '/assets/icon-docs-v2.svg',
style: {
width: '100px',
height: 'auto',
},
alt: '',
withTitle: false,
'data-testid': 'custom-testid-docs',
},
},
},
@@ -46,19 +51,11 @@ test.describe('Header', () => {
const header = page.locator('header').first();
await expect(header.getByTestId('header-icon-docs')).toBeVisible();
await expect(header.locator('h1').getByText('Docs')).toHaveCSS(
'font-family',
/Marianne/i,
await expect(header.getByTestId('custom-testid-docs')).toHaveAttribute(
'src',
'/assets/icon-docs-v2.svg',
);
await expect(
header.getByRole('button', {
name: 'Logout',
}),
).toBeVisible();
await expect(header.getByText('English')).toBeVisible();
await expect(header.locator('h1')).toBeHidden();
});
test('checks a custom waffle', async ({ page }) => {
@@ -146,32 +143,6 @@ test.describe('Header', () => {
});
});
test.describe('Header mobile', () => {
test.use({ viewport: { width: 500, height: 1200 } });
test('it checks the header when mobile with DSFR theme', async ({ page }) => {
await overrideConfig(page, {
FRONTEND_THEME: 'dsfr',
theme_customization: {
header: {
logo: {
src: '/assets/logo-gouv.svg',
width: '220px',
alt: 'Gouvernement Logo',
},
},
},
});
await page.goto('/');
const header = page.locator('header').first();
await expect(header.getByLabel('Open the header menu')).toBeVisible();
await expect(header.getByTestId('header-icon-docs')).toBeVisible();
});
});
test.describe('Header: Log out', () => {
test.use({ storageState: { cookies: [], origins: [] } });
@@ -8,6 +8,7 @@ test.beforeEach(async ({ page }) => {
test.describe('Home page', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('checks all the elements are visible', async ({ page }) => {
await page.goto('/docs/');
@@ -90,21 +91,44 @@ test.describe('Home page', () => {
],
},
},
header: {
logo: {
src: '/assets/logo-gouv.svg',
alt: 'Gouvernement Logo',
style: { width: '110px', height: 'auto' },
},
icon: {
src: '/assets/icon-docs-dsfr-v2.png',
style: {
width: '100px',
height: 'auto',
},
alt: '',
withTitle: false,
},
},
home: {
'with-proconnect': true,
'icon-banner': {
src: '/assets/icon-docs.svg',
style: {
width: '64px',
height: 'auto',
},
alt: '',
},
},
},
});
await page.goto('/docs/');
// Wait for the page to be fully loaded and responsive store to be initialized
await page.waitForLoadState('domcontentloaded');
// Wait a bit more for the responsive store to be initialized
await page.waitForTimeout(500);
// Check header content
const header = page.locator('header').first();
const footer = page.locator('footer').first();
await expect(header).toBeVisible();
await expect(header).toBeVisible({
timeout: 10000,
});
// Check for language picker - it should be visible on desktop
// Use a more flexible selector that works with both Header and HomeHeader
@@ -117,7 +141,6 @@ test.describe('Home page', () => {
header.getByRole('img', { name: 'Gouvernement Logo' }),
).toBeVisible();
await expect(header.getByTestId('header-icon-docs')).toBeVisible();
await expect(header.getByRole('heading', { name: 'Docs' })).toBeVisible();
// Check the titles
const h2 = page.locator('h2');
@@ -13,6 +13,32 @@ test.describe('Language', () => {
await page.goto('/');
});
test('it checks theme_customization.translations config', async ({
page,
}) => {
await overrideConfig(page, {
theme_customization: {
translations: {
en: {
translation: {
Docs: 'MyCustomDocs',
},
},
},
header: {
logo: {},
icon: {
withTitle: true,
},
},
},
});
await page.goto('/');
await expect(page.getByText('MyCustomDocs')).toBeAttached();
});
test('checks language switching', async ({ page }) => {
const header = page.locator('header').first();
const languagePicker = header.locator('.--docs--language-picker-text');
@@ -18,6 +18,20 @@ test.describe('Left panel desktop', () => {
await expect(page.getByTestId('home-button')).toBeVisible();
});
test('focuses main content after switching the docs filter', async ({
page,
}) => {
await page.goto('/');
const myDocsLink = page.getByRole('link', { name: 'My docs' });
await myDocsLink.focus();
await page.keyboard.press('Enter');
await expect(page).toHaveURL(/target=my_docs/);
const mainContent = page.locator('main#mainContent');
await expect(mainContent).toBeFocused();
});
test('checks resize handle is present and functional on document page', async ({
page,
browserName,
@@ -0,0 +1,22 @@
import { expect, test } from '@playwright/test';
import { overrideConfig } from './utils-common';
test.describe('Login: Not logged', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('It tries silent login', async ({ page }) => {
await overrideConfig(page, {
FRONTEND_SILENT_LOGIN_ENABLED: true,
});
const silentLoginRequest = page.waitForRequest((request) =>
request.url().includes('/api/v1.0/authenticate/?silent=true'),
);
await page.goto('/');
await silentLoginRequest;
expect(silentLoginRequest).toBeDefined();
});
});
@@ -1,10 +1,22 @@
import { Locator, Page, expect } from '@playwright/test';
import fs from 'fs';
import path from 'path';
import { Locator, Page, TestInfo, expect } from '@playwright/test';
import theme_customization from '../../../../../backend/impress/configuration/theme/default.json';
export type BrowserName = 'chromium' | 'firefox' | 'webkit';
export const BROWSERS: BrowserName[] = ['chromium', 'webkit', 'firefox'];
export const CONFIG = {
AI_BOT: {
name: 'Docs AI',
color: '#8bc6ff',
},
AI_FEATURE_ENABLED: true,
AI_FEATURE_BLOCKNOTE_ENABLED: true,
AI_FEATURE_LEGACY_ENABLED: true,
API_USERS_SEARCH_QUERY_MIN_LENGTH: 3,
CRISP_WEBSITE_ID: null,
COLLABORATION_WS_URL: 'ws://localhost:4444/collaboration/ws/',
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY: true,
@@ -14,6 +26,7 @@ export const CONFIG = {
FRONTEND_CSS_URL: null,
FRONTEND_JS_URL: null,
FRONTEND_HOMEPAGE_FEATURE_ENABLED: true,
FRONTEND_SILENT_LOGIN_ENABLED: false,
FRONTEND_THEME: null,
MEDIA_BASE_URL: 'http://localhost:8083',
LANGUAGES: [
@@ -27,7 +40,7 @@ export const CONFIG = {
POSTHOG_KEY: {},
SENTRY_DSN: null,
TRASHBIN_CUTOFF_DAYS: 30,
theme_customization: {},
theme_customization,
} as const;
export const overrideConfig = async (
@@ -387,3 +400,30 @@ export const clickInGridMenu = async (
.click();
await page.getByRole('menuitem', { name: textButton }).click();
};
export const writeReport = async (
testInfo: TestInfo,
filename: string,
attachName: string,
buffer: Buffer,
contentType: string,
) => {
const REPORT_DIRNAME = 'extra-report';
const REPORT_NAME = 'test-results';
const outDir = testInfo
? path.join(testInfo.outputDir, REPORT_DIRNAME, path.parse(filename).name)
: path.join(
process.cwd(),
REPORT_NAME,
REPORT_DIRNAME,
path.parse(filename).name,
);
fs.mkdirSync(outDir, { recursive: true });
const pathToFile = path.join(outDir, filename);
fs.writeFileSync(pathToFile, buffer);
await testInfo.attach(attachName, {
path: pathToFile,
contentType: contentType,
});
};
@@ -1,3 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { Page } from '@playwright/test';
export const getEditor = async ({ page }: { page: Page }) => {
@@ -30,3 +32,55 @@ export const writeInEditor = async ({
.fill(text);
return editor;
};
export const mockAIResponse = async (page: Page) => {
await page.route(/.*\/ai-proxy\//, async (route) => {
const req = route.request();
if (req.method() !== 'POST') {
return route.continue();
}
// Extract the block ID from the request's selectedBlocks
const requestData = req.postDataJSON();
const messages = requestData?.messages || [];
const userMessage = messages.find((msg: any) => msg.role === 'user');
const documentState = userMessage?.metadata?.documentState;
const selectedBlocks = documentState?.selectedBlocks || [];
const blockId = selectedBlocks[0]?.id || 'initialBlockId$';
const sse = [
`data: {"type":"start"}\n\n`,
`data: {"type":"start-step"}\n\n`,
`data: ${JSON.stringify({
type: 'tool-input-available',
toolCallId: 'chatcmpl-mock-0',
toolName: 'applyDocumentOperations',
input: {
operations: [
{
type: 'update',
id: blockId,
block: '<p>Bonjour le monde</p>',
},
],
},
})}\n\n`,
`data: {"type":"finish-step"}\n\n`,
`data: {"type":"finish","finishReason":"tool-calls"}\n\n`,
`data: [DONE]\n\n`,
].join('');
await route.fulfill({
status: 200,
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
'x-vercel-ai-data-stream': 'v1',
'x-accel-buffering': 'no',
Connection: 'keep-alive',
},
body: sse,
});
});
};
@@ -0,0 +1,239 @@
import fs from 'fs';
import path from 'path';
import { Page, TestInfo, expect } from '@playwright/test';
import { PDFParse } from 'pdf-parse';
import pixelmatch from 'pixelmatch';
import { PNG } from 'pngjs';
import {
BrowserName,
createDoc,
verifyDocName,
writeReport,
} from './utils-common';
import { openSuggestionMenu } from './utils-editor';
/**
* Override the document content API response to use a test content
* This test content contains many blocks to facilitate testing
* @param page
*/
export const overrideDocContent = async ({
page,
browserName,
}: {
page: Page;
browserName: BrowserName;
}) => {
// Override content prop with assets/base-content-test-pdf.txt
await page.route(/\**\/documents\/\**/, 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')
) {
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),
});
} else {
await route.continue();
}
});
const [randomDoc] = await createDoc(
page,
'doc-export-override-content',
browserName,
1,
);
await verifyDocName(page, randomDoc);
await page.waitForTimeout(1000);
// Add Image SVG
await page.keyboard.press('Enter');
const { suggestionMenu } = await openSuggestionMenu({ page });
await suggestionMenu.getByText('Resizable image with caption').click();
const fileChooserPromise = page.waitForEvent('filechooser');
await page.getByText('Upload image').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(path.join(__dirname, 'assets/test.svg'));
const image = page
.locator('.--docs--editor-container img.bn-visual-media[src$=".svg"]')
.first();
await expect(image).toBeVisible();
await page.keyboard.press('Enter');
await page.waitForTimeout(1000);
// Add Image PNG
await openSuggestionMenu({ page });
await suggestionMenu.getByText('Resizable image with caption').click();
const fileChooserPNGPromise = page.waitForEvent('filechooser');
await page.getByText('Upload image').click();
const fileChooserPNG = await fileChooserPNGPromise;
await fileChooserPNG.setFiles(
path.join(__dirname, 'assets/logo-suite-numerique.png'),
);
const imagePng = page
.locator('.--docs--editor-container img.bn-visual-media[src$=".png"]')
.first();
await expect(imagePng).toBeVisible();
await page.waitForTimeout(1000);
return randomDoc;
};
export const savePDFToAssetFolder = async (
pdfBuffer: Buffer,
filename: string,
) => {
const pdfPath = path.join(__dirname, 'assets', filename);
fs.writeFileSync(pdfPath, pdfBuffer);
};
interface ComparePDFWithAssetFolderOptions {
originPdfBuffer: Buffer;
filename: string;
compareTextContent?: boolean;
comparePixel?: boolean;
testInfo?: TestInfo;
}
export const comparePDFWithAssetFolder = async ({
originPdfBuffer,
filename,
compareTextContent = true,
comparePixel = true,
testInfo,
}: ComparePDFWithAssetFolderOptions) => {
// Load reference PDF for comparison
const referencePdfPath = path.join(__dirname, 'assets', filename);
const referencePdfBuffer = fs.readFileSync(referencePdfPath);
// Parse both PDFs
const generatedPdf = new PDFParse({ data: originPdfBuffer });
const referencePdf = new PDFParse({ data: referencePdfBuffer });
const [generatedInfo, referenceInfo] = await Promise.all([
generatedPdf.getInfo(),
referencePdf.getInfo(),
]);
const [generatedScreenshot, referenceScreenshot] = await Promise.all([
generatedPdf.getScreenshot(),
referencePdf.getScreenshot(),
]);
const [generatedText, referenceText] = await Promise.all([
generatedPdf.getText(),
referencePdf.getText(),
]);
// Compare page count
expect(generatedInfo.total).toBe(referenceInfo.total);
/*
Compare text content
We make this optional because text extraction from PDFs can vary
slightly between environments and PDF versions, leading to false negatives.
Particularly with emojis which can be represented differently when
exporting or parsing the PDF.
*/
if (compareTextContent) {
expect(generatedText.text).toBe(referenceText.text);
}
// Compare screenshots page by page
for (let i = 0; i < generatedScreenshot.pages.length; i++) {
const genPage = generatedScreenshot.pages[i];
const refPage = referenceScreenshot.pages[i];
const genPng = PNG.sync.read(Buffer.from(genPage.data));
const refPng = PNG.sync.read(Buffer.from(refPage.data));
// Compare actual raster dimensions (integers)
expect(genPng.width).toBe(refPng.width);
expect(genPng.height).toBe(refPng.height);
if (!comparePixel) {
continue;
}
const diffPng = new PNG({ width: genPng.width, height: genPng.height });
const numDiffPixels = pixelmatch(
genPng.data,
refPng.data,
diffPng.data,
genPng.width,
genPng.height,
{ threshold: 0.1, includeAA: false },
);
const totalPixels = genPng.width * genPng.height;
const diffRatio = numDiffPixels / totalPixels;
const maxDiffRatio = 0.0005;
try {
expect(numDiffPixels).toBeLessThan(0.0005);
} catch {
if (testInfo) {
const pageNo = String(i + 1).padStart(2, '0');
await writeReport(
testInfo,
`generated.pdf`,
`pdf-generated`,
originPdfBuffer,
'application/pdf',
);
await writeReport(
testInfo,
`reference.pdf`,
`pdf-reference`,
referencePdfBuffer,
'application/pdf',
);
await writeReport(
testInfo,
`page-${pageNo}-diff.png`,
`page-${pageNo}-diff`,
PNG.sync.write(diffPng),
'image/png',
);
await writeReport(
testInfo,
`page-${pageNo}-generated.png`,
`page-${pageNo}-generated`,
PNG.sync.write(genPng),
'image/png',
);
await writeReport(
testInfo,
`page-${pageNo}-reference.png`,
`page-${pageNo}-reference`,
PNG.sync.write(refPng),
'image/png',
);
}
throw new Error(
`PDF visual regression: ${filename} page ${i + 1} diffRatio=${diffRatio.toFixed(6)} (${numDiffPixels} px) > ${maxDiffRatio}`,
);
}
}
};
+6 -3
View File
@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "4.4.0",
"version": "4.6.0",
"repository": "https://github.com/suitenumerique/docs",
"author": "DINUM",
"license": "MIT",
@@ -15,15 +15,18 @@
"test:ui::chromium": "yarn test:ui --project=chromium"
},
"devDependencies": {
"@playwright/test": "1.57.0",
"@playwright/test": "1.58.2",
"@types/node": "*",
"@types/pdf-parse": "1.1.5",
"eslint-plugin-docs": "*",
"typescript": "*"
},
"dependencies": {
"@types/pngjs": "6.0.5",
"convert-stream": "1.0.2",
"pdf-parse": "2.4.5"
"pdf-parse": "2.4.5",
"pixelmatch": "7.1.0",
"pngjs": "7.0.0"
},
"packageManager": "yarn@1.22.22"
}
+1 -2
View File
@@ -38,5 +38,4 @@ service-worker.js
# Font embedding
public/assets/fonts/emoji/*
!public/assets/fonts/emoji/fallback.png
public/assets/fonts/Marianne/*
!public/assets/fonts/emoji/fallback.png
+1 -1
View File
@@ -3,7 +3,7 @@ server {
listen 3000;
server_name localhost;
root /usr/share/nginx/html;
root /app;
location / {
try_files $uri index.html $uri/index.html =404;
-37
View File
@@ -19,24 +19,6 @@ const themeWhiteLabelLight = getUIKitThemesFromGlobals(whiteLabelGlobals, {
'2xs': '0.375rem',
},
},
components: {
logo: {
src: '',
alt: '',
widthHeader: '',
widthFooter: '',
},
'home-proconnect': false,
icon: {
src: '/assets/icon-docs.svg',
width: '32px',
height: 'auto',
},
favicon: {
'png-light': '/assets/favicon-light.png',
'png-dark': '/assets/favicon-dark.png',
},
},
},
});
@@ -56,25 +38,6 @@ const themesDSFRLight = getUIKitThemesFromGlobals(dsfrGlobals, {
},
},
},
components: {
logo: {
src: '/assets/logo-gouv.svg',
widthHeader: '110px',
widthFooter: '220px',
alt: 'Gouvernement Logo',
},
'home-proconnect': true,
icon: {
src: '/assets/icon-docs-dsfr.svg',
width: '32px',
height: 'auto',
},
favicon: {
ico: '/assets/favicon-dsfr.ico',
'png-light': '/assets/favicon-dsfr.png',
'png-dark': '/assets/favicon-dark-dsfr.png',
},
},
},
});
-8
View File
@@ -55,14 +55,6 @@ const nextConfig = {
to: path.resolve(__dirname, 'public/assets/fonts/emoji'),
force: true,
},
{
from: path.resolve(
__dirname,
'../../node_modules/@gouvfr-lasuite/ui-kit/dist/assets/fonts/Marianne',
),
to: path.resolve(__dirname, 'public/assets/fonts/Marianne'),
force: true,
},
],
}),
);
+43 -40
View File
@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "4.4.0",
"version": "4.6.0",
"repository": "https://github.com/suitenumerique/docs",
"author": "DINUM",
"license": "MIT",
@@ -19,89 +19,92 @@
},
"dependencies": {
"@ag-media/react-pdf-table": "2.0.3",
"@blocknote/code-block": "0.45.0",
"@blocknote/core": "0.45.0",
"@blocknote/mantine": "0.45.0",
"@blocknote/react": "0.45.0",
"@blocknote/xl-docx-exporter": "0.45.0",
"@blocknote/xl-multi-column": "0.45.0",
"@blocknote/xl-odt-exporter": "0.45.0",
"@blocknote/xl-pdf-exporter": "0.45.0",
"@ai-sdk/openai": "3.0.19",
"@blocknote/code-block": "0.47.0",
"@blocknote/core": "0.47.0",
"@blocknote/mantine": "0.47.0",
"@blocknote/react": "0.47.0",
"@blocknote/xl-ai": "0.47.0",
"@blocknote/xl-docx-exporter": "0.47.0",
"@blocknote/xl-multi-column": "0.47.0",
"@blocknote/xl-odt-exporter": "0.47.0",
"@blocknote/xl-pdf-exporter": "0.47.0",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/modifiers": "9.0.0",
"@emoji-mart/data": "1.2.1",
"@emoji-mart/react": "1.1.1",
"@fontsource-variable/inter": "5.2.8",
"@fontsource-variable/material-symbols-outlined": "5.2.30",
"@fontsource-variable/material-symbols-outlined": "5.2.35",
"@fontsource/material-icons": "5.2.7",
"@gouvfr-lasuite/cunningham-react": "4.1.0",
"@gouvfr-lasuite/cunningham-react": "4.2.0",
"@gouvfr-lasuite/integration": "1.0.3",
"@gouvfr-lasuite/ui-kit": "0.18.6",
"@hocuspocus/provider": "3.4.3",
"@mantine/core": "8.3.10",
"@mantine/hooks": "8.3.10",
"@gouvfr-lasuite/ui-kit": "0.19.6",
"@hocuspocus/provider": "3.4.4",
"@mantine/core": "8.3.14",
"@mantine/hooks": "8.3.14",
"@react-pdf/renderer": "4.3.1",
"@sentry/nextjs": "10.32.1",
"@tanstack/react-query": "5.90.16",
"@sentry/nextjs": "10.38.0",
"@tanstack/react-query": "5.90.21",
"@tiptap/extensions": "*",
"ai": "6.0.49",
"canvg": "4.0.3",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"crisp-sdk-web": "1.0.26",
"docx": "*",
"crisp-sdk-web": "1.0.27",
"emoji-datasource-apple": "16.0.0",
"emoji-mart": "5.6.0",
"emoji-regex": "10.6.0",
"i18next": "25.7.3",
"i18next-browser-languagedetector": "8.2.0",
"i18next": "25.8.12",
"i18next-browser-languagedetector": "8.2.1",
"idb": "8.0.3",
"lodash": "4.17.23",
"luxon": "3.7.2",
"next": "15.5.9",
"posthog-js": "1.312.0",
"next": "15.5.10",
"posthog-js": "1.347.2",
"react": "*",
"react-aria-components": "1.14.0",
"react-aria-components": "1.15.1",
"react-dom": "*",
"react-dropzone": "14.3.8",
"react-i18next": "16.5.1",
"react-intersection-observer": "10.0.0",
"react-dropzone": "15.0.0",
"react-i18next": "16.5.4",
"react-intersection-observer": "10.0.2",
"react-resizable-panels": "3.0.6",
"react-select": "5.10.2",
"styled-components": "6.1.19",
"use-debounce": "10.0.6",
"styled-components": "6.3.9",
"use-debounce": "10.1.0",
"uuid": "13.0.0",
"y-protocols": "1.0.7",
"yjs": "*",
"zustand": "5.0.9"
"zod": "3.25.28",
"zustand": "5.0.11"
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.91.2",
"@tanstack/react-query-devtools": "5.91.3",
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.1",
"@testing-library/react": "16.3.2",
"@testing-library/user-event": "14.6.1",
"@types/lodash": "4.17.21",
"@types/lodash": "4.17.23",
"@types/luxon": "3.7.1",
"@types/node": "*",
"@types/react": "*",
"@types/react-dom": "*",
"@vitejs/plugin-react": "5.1.2",
"@vitejs/plugin-react": "5.1.4",
"copy-webpack-plugin": "13.0.1",
"cross-env": "10.1.0",
"dotenv": "17.2.3",
"dotenv": "17.3.1",
"eslint-plugin-docs": "*",
"fetch-mock": "9.11.0",
"jsdom": "27.4.0",
"jsdom": "28.1.0",
"node-fetch": "2.7.0",
"prettier": "3.7.4",
"prettier": "3.8.1",
"stylelint": "16.26.1",
"stylelint-config-standard": "39.0.1",
"stylelint-prettier": "5.0.3",
"typescript": "*",
"vite-tsconfig-paths": "6.0.3",
"vitest": "4.0.16",
"webpack": "5.104.1",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.0.18",
"webpack": "5.105.2",
"workbox-webpack-plugin": "7.1.0"
},
"packageManager": "yarn@1.22.22"
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

@@ -0,0 +1,34 @@
<svg
width="100"
height="40"
viewBox="0 0 100 40"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M25.6305 32.8312C26.7983 32.5038 27.9166 31.9062 28.6505 30.8503C29.3749 29.8163 29.5789 28.5047 29.5789 27.2425V8.75099C29.5789 8.42358 29.5611 8.09557 29.5216 7.77148C30.1016 7.99961 30.5486 8.37658 30.8626 8.90239C31.2331 9.50024 31.4184 10.2876 31.4184 11.2643V30.0464C31.4184 31.3684 31.0942 32.3578 30.4458 33.0146C29.7974 33.6714 28.8207 33.9998 27.5155 33.9998H20.4209C20.5889 33.9704 20.7574 33.9401 20.9262 33.909C22.4067 33.6444 23.9713 33.2854 25.6185 32.8346L25.6305 32.8312Z"
fill="#C83F49"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M8.58203 29.655V10.8477C8.58203 9.70251 8.88938 8.83519 9.50408 8.24575C10.1272 7.65631 10.9524 7.33212 11.9797 7.27318C13.4954 7.18055 14.9311 7.05425 16.2868 6.89425C17.6425 6.72584 18.9393 6.53217 20.1771 6.31324C21.4234 6.0943 22.6359 5.85011 23.8148 5.58065C25.0274 5.29435 25.9578 5.4375 26.6062 6.0101C27.2546 6.58269 27.5788 7.49632 27.5788 8.75099V27.2425C27.5788 28.3456 27.3893 29.1666 27.0104 29.7055C26.6315 30.2529 25.9915 30.6528 25.0905 30.9054C23.4906 31.3433 21.9833 31.6886 20.5687 31.9412C19.154 32.2022 17.7731 32.4001 16.4258 32.5348C15.0785 32.6696 13.6975 32.7748 12.2829 32.8506C11.1124 32.918 10.203 32.6738 9.5546 32.118C8.90622 31.5707 8.58203 30.7497 8.58203 29.655ZM13.2087 14.2624C15.0635 14.1444 16.7632 13.9631 18.3075 13.7183C18.6822 13.6572 19.0564 13.5936 19.4291 13.5276C19.8192 13.4585 20.1013 13.1186 20.1013 12.7234C20.1013 12.2115 19.638 11.8261 19.135 11.9119C18.846 11.9612 18.5555 12.0091 18.2635 12.0556C16.7346 12.2992 15.0452 12.48 13.1952 12.5977C12.9182 12.6156 12.6978 12.7019 12.5561 12.8739C12.4221 13.0366 12.3564 13.2323 12.3564 13.4553C12.3564 13.6821 12.433 13.8795 12.5857 14.0418L12.5878 14.0439C12.7534 14.2095 12.9637 14.2811 13.2087 14.2624ZM13.208 18.456C15.0631 18.338 16.763 18.1566 18.3075 17.9119C19.8588 17.6589 21.3936 17.3638 22.9112 17.0266C23.2191 16.9581 23.4498 16.8503 23.5652 16.683C23.6786 16.5221 23.7347 16.3376 23.7347 16.1332C23.7347 15.9026 23.6469 15.704 23.476 15.5426C23.2921 15.3689 23.0348 15.3284 22.7304 15.3911L22.7285 15.3915C21.2823 15.7194 19.794 16.0053 18.2635 16.2492C16.7346 16.4928 15.0452 16.6735 13.1952 16.7913C12.9182 16.8091 12.6978 16.8954 12.5561 17.0675C12.4228 17.2294 12.3564 17.4205 12.3564 17.6363C12.3564 17.8703 12.4321 18.0723 12.5856 18.2354L12.59 18.2396C12.755 18.3949 12.9632 18.4655 13.2055 18.4562L13.208 18.456ZM13.2085 22.6494C15.0634 22.5229 16.7631 22.3374 18.3075 22.0927C19.8589 21.8482 21.3934 21.5573 22.9112 21.22C23.2199 21.1514 23.4508 21.0391 23.566 20.8627C23.6783 20.7029 23.7347 20.5233 23.7347 20.3266C23.7347 20.0961 23.6469 19.8974 23.476 19.7361C23.2921 19.5623 23.0348 19.5218 22.7304 19.5845L22.729 19.5848C21.2827 19.9043 19.7942 20.1861 18.2635 20.43C16.7345 20.6736 15.045 20.8586 13.1949 20.9847C12.918 21.0026 12.6977 21.0889 12.5561 21.2609C12.4228 21.4228 12.3564 21.6139 12.3564 21.8297C12.3564 22.0637 12.4321 22.2658 12.5856 22.4289L12.59 22.433C12.755 22.5883 12.9632 22.6589 13.2055 22.6496L13.2085 22.6494ZM18.3075 26.257C16.7632 26.5018 15.0635 26.6831 13.2087 26.8012C12.9637 26.8198 12.7534 26.7482 12.5878 26.5826L12.5857 26.5805C12.433 26.4182 12.3564 26.2208 12.3564 25.9941C12.3564 25.771 12.4221 25.5753 12.5561 25.4126C12.6978 25.2406 12.9183 25.1543 13.1953 25.1364C15.0453 25.0187 16.7346 24.838 18.2635 24.5943C18.5555 24.5478 18.846 24.4999 19.135 24.4506C19.638 24.3648 20.1013 24.7503 20.1013 25.2621C20.1013 25.6573 19.8192 25.9972 19.4291 26.0663C19.0564 26.1323 18.6822 26.1959 18.3075 26.257Z"
fill="#2845C1"
/>
<path
d="M41.2 27.95C41.0895 27.95 41 27.8605 41 27.75V12.2C41 12.0895 41.0895 12 41.2 12H47.2205C48.4813 12 49.6282 12.2127 50.6611 12.638C51.6941 13.0633 52.5827 13.6482 53.3271 14.3925C54.0866 15.1216 54.6638 15.9647 55.0588 16.9217C55.4689 17.8787 55.674 18.8965 55.674 19.975C55.674 21.0535 55.4689 22.0713 55.0588 23.0283C54.6638 23.9853 54.0866 24.836 53.3271 25.5803C52.5827 26.3094 51.6941 26.8867 50.6611 27.312C49.6282 27.7373 48.4813 27.95 47.2205 27.95H41.2ZM47.2433 14.5292H44.0026C43.8922 14.5292 43.8026 14.6188 43.8026 14.7292V25.2208C43.8026 25.3312 43.8922 25.4208 44.0026 25.4208H47.2433C48.0484 25.4208 48.7851 25.2841 49.4535 25.0106C50.1371 24.722 50.7219 24.3271 51.208 23.8258C51.7093 23.3245 52.0966 22.7473 52.3701 22.0941C52.6435 21.4409 52.7802 20.7345 52.7802 19.975C52.7802 19.2155 52.6435 18.5091 52.3701 17.8559C52.0966 17.1875 51.7093 16.6103 51.208 16.1242C50.7219 15.6229 50.1371 15.2356 49.4535 14.9621C48.7851 14.6735 48.0484 14.5292 47.2433 14.5292Z"
fill="#2845C1"
/>
<path
d="M63.3939 25.6031C63.9104 25.6031 64.3889 25.5044 64.8294 25.3069C65.2699 25.0943 65.6497 24.8132 65.9687 24.4639C66.3029 24.1145 66.5611 23.7119 66.7434 23.2562C66.9257 22.7853 67.0168 22.284 67.0168 21.7524C67.0168 21.0232 66.8573 20.37 66.5383 19.7928C66.2193 19.2155 65.7864 18.7598 65.2395 18.4256C64.6927 18.0763 64.0775 17.9016 63.3939 17.9016C62.8622 17.9016 62.3686 18.0003 61.9128 18.1978C61.4723 18.3953 61.085 18.6687 60.7508 19.0181C60.4318 19.3674 60.1811 19.7776 59.9988 20.2485C59.8166 20.7194 59.7254 21.2207 59.7254 21.7524C59.7254 22.4663 59.8849 23.1195 60.2039 23.7119C60.5229 24.2892 60.9558 24.7525 61.5027 25.1019C62.0647 25.436 62.6951 25.6031 63.3939 25.6031ZM63.3711 15.5546C64.2977 15.5546 65.1408 15.7141 65.9003 16.0331C66.675 16.3521 67.3358 16.8003 67.8827 17.3775C68.4447 17.9395 68.8701 18.6003 69.1587 19.3599C69.4625 20.1042 69.6144 20.9017 69.6144 21.7524C69.6144 22.603 69.4625 23.4081 69.1587 24.1676C68.8701 24.912 68.4447 25.5728 67.8827 26.15C67.3358 26.712 66.675 27.1526 65.9003 27.4716C65.1408 27.7906 64.2977 27.9501 63.3711 27.9501C62.4445 27.9501 61.5938 27.7906 60.8191 27.4716C60.0596 27.1526 59.3988 26.712 58.8368 26.15C58.2747 25.5728 57.8418 24.912 57.538 24.1676C57.2494 23.4081 57.1051 22.603 57.1051 21.7524C57.1051 20.9017 57.2494 20.1042 57.538 19.3599C57.8418 18.6003 58.2747 17.9395 58.8368 17.3775C59.3988 16.8003 60.0596 16.3521 60.8191 16.0331C61.5938 15.7141 62.4445 15.5546 63.3711 15.5546Z"
fill="#2845C1"
/>
<path
d="M77.2852 15.5546C78.3333 15.5546 79.2675 15.7673 80.0878 16.1926C80.8611 16.5723 81.5043 17.082 82.0173 17.7219C82.086 17.8076 82.0681 17.9322 81.9806 17.9986L80.3269 19.2531C80.2346 19.3232 80.1027 19.2999 80.0326 19.2076C79.7582 18.8466 79.4045 18.548 78.9713 18.3117C78.4852 18.0383 77.9156 17.9016 77.2624 17.9016C76.7307 17.9016 76.237 18.0003 75.7813 18.1978C75.3408 18.3953 74.961 18.6687 74.642 19.0181C74.323 19.3674 74.0724 19.7776 73.8901 20.2485C73.7078 20.7042 73.6167 21.2055 73.6167 21.7524C73.6167 22.4815 73.7762 23.1423 74.0952 23.7347C74.4142 24.3119 74.8471 24.7677 75.394 25.1019C75.9408 25.436 76.5788 25.6031 77.308 25.6031C77.9308 25.6031 78.4852 25.4664 78.9713 25.193C79.4045 24.9567 79.7582 24.6581 80.0326 24.2971C80.1027 24.2048 80.2346 24.1815 80.3269 24.2516L81.979 25.5049C82.0671 25.5717 82.0845 25.6976 82.0145 25.7831C81.5019 26.4095 80.8597 26.9191 80.0878 27.3121C79.2675 27.7374 78.3333 27.9501 77.2852 27.9501C75.994 27.9501 74.8775 27.669 73.9357 27.107C72.9939 26.5298 72.2647 25.7702 71.7483 24.8284C71.247 23.8866 70.9963 22.8613 70.9963 21.7524C70.9963 20.9169 71.1406 20.127 71.4293 19.3826C71.7179 18.6383 72.1356 17.9775 72.6825 17.4003C73.2293 16.823 73.8901 16.3749 74.6648 16.0559C75.4395 15.7217 76.313 15.5546 77.2852 15.5546Z"
fill="#2845C1"
/>
<path
d="M85.9175 18.9041C85.9175 19.2687 86.0466 19.5725 86.3049 19.8156C86.5783 20.0434 86.9277 20.2409 87.353 20.408C87.7783 20.5751 88.2265 20.7574 88.6974 20.9549C89.1835 21.1371 89.6392 21.365 90.0645 21.6384C90.4898 21.8967 90.8316 22.246 91.0899 22.6866C91.3633 23.1119 91.5 23.6512 91.5 24.3044C91.5 25.0791 91.3101 25.7399 90.9304 26.2867C90.5658 26.8184 90.0797 27.2285 89.4721 27.5171C88.8644 27.8058 88.1961 27.9501 87.4669 27.9501C86.4795 27.9501 85.6213 27.7678 84.8921 27.4032C84.2203 27.0533 83.6259 26.5938 83.1088 26.0246C83.0352 25.9436 83.0442 25.8185 83.1263 25.7462L84.5297 24.5112C84.6154 24.4358 84.7464 24.4475 84.821 24.534C85.1512 24.917 85.5168 25.2354 85.9175 25.4892C86.3732 25.7778 86.8821 25.9221 87.4441 25.9221C87.9758 25.9221 88.3784 25.7854 88.6518 25.512C88.9404 25.2234 89.0847 24.874 89.0847 24.4639C89.0847 24.0841 88.948 23.7803 88.6746 23.5524C88.4163 23.3246 88.0745 23.1271 87.6492 22.96C87.2239 22.7929 86.7682 22.6182 86.2821 22.4359C85.8112 22.2384 85.363 22.003 84.9377 21.7296C84.5124 21.4561 84.163 21.1068 83.8896 20.6814C83.6313 20.2409 83.5022 19.6789 83.5022 18.9953C83.5022 18.3573 83.6693 17.78 84.0035 17.2636C84.3377 16.7319 84.801 16.3142 85.3934 16.0104C86.001 15.7065 86.6922 15.5546 87.4669 15.5546C88.3024 15.5546 89.0771 15.7369 89.7911 16.1015C90.4556 16.4338 90.9877 16.8481 91.3872 17.3444C91.4528 17.4258 91.4385 17.5439 91.359 17.6117L89.9551 18.8097C89.8666 18.8853 89.7328 18.8692 89.6595 18.7789C89.4001 18.4596 89.1022 18.19 88.7657 17.9699C88.3708 17.7117 87.9378 17.5826 87.4669 17.5826C87.1327 17.5826 86.8441 17.6433 86.6011 17.7649C86.3732 17.8864 86.1985 18.0459 86.077 18.2434C85.9707 18.4408 85.9175 18.6611 85.9175 18.9041Z"
fill="#2845C1"
/>
</svg>

After

Width:  |  Height:  |  Size: 9.1 KiB

@@ -1,248 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
x="0px"
y="0px"
viewBox="0 0 311.6 186.5"
style="enable-background: new 0 0 311.6 186.5"
xml:space="preserve"
>
<style type="text/css">
.st0 {
fill: #ffffff;
}
.st1 {
fill: #000091;
}
.st2 {
fill: #e1000f;
}
.st3 {
fill: #9c9b9b;
}
</style>
<g id="Fond">
<rect x="0" class="st0" width="311.6" height="186.5" />
</g>
<g id="Calque_1">
<path
id="Devise_Républicaine_1_"
d="M100.5,150.8c0.6,0,1.1,0.4,0.8,1.5l-2.7,0.6C99.1,151.8,99.9,150.8,100.5,150.8 M102,155.2
h-0.5c-0.7,0.8-1.4,1.4-2.1,1.4c-0.7,0-1.1-0.4-1.1-1.4c0-0.4,0-0.8,0.1-1.2l4.3-1.4c0.8-2-0.2-2.9-1.4-2.9c-2,0-4.4,3.4-4.4,6.4
c0,1.3,0.6,2.1,1.6,2.1C99.8,158.2,101,157,102,155.2 M101.2,148.9l3-2.8v-0.3h-1.6l-1.9,3.2H101.2z M91.9,150.9h1.4l-2.3,6.2
c-0.2,0.5,0.1,1.1,0.6,1.1c1.3,0,3.4-1.3,4.1-3h-0.4c-0.6,0.6-1.7,1.4-2.6,1.6l2.1-5.8h2.1l0.3-0.9h-2.1l0.8-2.2h-0.8l-1.5,2.2
l-1.8,0.2V150.9z M89.9,150.6c0.2-0.6-0.2-0.9-0.5-0.9c-1.2,0-2.7,1.1-3.3,2.7h0.4c0.4-0.6,1.1-1.2,1.7-1.3l-2.4,6.2
c-0.2,0.6,0.2,0.9,0.5,0.9c1.2,0,2.6-1.1,3.2-2.7h-0.4c-0.4,0.6-1.1,1.2-1.7,1.3L89.9,150.6z M90.4,147.5c0.6,0,1-0.5,1-1
c0-0.6-0.5-1-1-1c-0.6,0-1,0.5-1,1C89.3,147,89.8,147.5,90.4,147.5 M76.6,157c-0.3,0.7,0,1.2,0.7,1.2c0.4,0,0.6-0.1,0.8-0.6
l1.7-4.4c0.8-0.9,2.3-1.9,3-1.9c0.5,0,0.4,0.4,0.1,0.9l-2.5,4.9c-0.2,0.5,0.1,1.1,0.6,1.1c1.2,0,2.7-1.1,3.3-2.7h-0.4
c-0.4,0.6-1.1,1.2-1.7,1.3l2.2-4.4c0.3-0.6,0.4-1.1,0.4-1.5c0-0.7-0.4-1.2-1.2-1.2c-1.1,0-2.2,1.2-3.5,2.7v-1.2
c0-0.8-0.3-1.6-1-1.6c-0.5,0-0.9,0.4-1.3,1v0.2c0.8,0,1.2,1.2,0.6,2.4L76.6,157z M76.6,151.6c0.3-1,0.1-1.9-0.6-1.9
c-0.9,0-1.2,0.7-2.1,2.7v-1.2c0-0.8-0.3-1.6-1-1.6c-0.9,0-1.7,1.4-2.3,2.7H71c0.4-0.6,0.8-1,1.1-1c0.4,0,0.6,0.6,0,1.9l-1.7,3.7
c-0.3,0.7,0,1.2,0.7,1.2c0.4,0,0.6-0.1,0.8-0.6l1.7-4.4c0.5-0.6,0.9-1.1,1.4-1.6H76.6z M67,150.8c0.6,0,1.1,0.4,0.8,1.5l-2.7,0.6
C65.6,151.8,66.4,150.8,67,150.8 M68.5,155.2H68c-0.7,0.8-1.4,1.4-2.1,1.4c-0.7,0-1.1-0.4-1.1-1.4c0-0.4,0-0.8,0.1-1.2l4.3-1.4
c0.8-2-0.2-2.9-1.4-2.9c-2,0-4.4,3.4-4.4,6.4c0,1.3,0.6,2.1,1.6,2.1C66.3,158.2,67.5,157,68.5,155.2 M58.3,150.9h1.4l-2.3,6.2
c-0.2,0.5,0.1,1.1,0.6,1.1c1.3,0,3.4-1.3,4.1-3h-0.4c-0.6,0.6-1.7,1.4-2.6,1.6l2.1-5.8h2.1l0.3-0.9h-2.1l0.8-2.2h-0.8l-1.5,2.2
l-1.8,0.2V150.9z M50.5,155.7c0-1.9,2.1-4.5,3.3-4.5c0.3,0,0.5,0,0.7,0.1l-1.2,3.3c-0.7,0.9-1.8,1.9-2.3,1.9
C50.7,156.6,50.5,156.3,50.5,155.7 M57,149.3l-0.7,0l-0.7,0.7h-0.2c-3.6,0-6.6,4-6.6,6.9c0,0.8,0.5,1.3,1.3,1.3
c0.9,0,1.8-1.3,2.8-2.7l0,0.5c-0.1,1.4,0.3,2.2,1.1,2.2c0.9,0,1.7-1.4,2.3-2.7h-0.4c-0.4,0.6-0.8,1-1.1,1c-0.3,0-0.6-0.6,0-1.9
L57,149.3z M49.5,151.6c0.3-1,0.1-1.9-0.6-1.9c-0.9,0-1.2,0.7-2.1,2.7v-1.2c0-0.8-0.3-1.6-1-1.6c-0.9,0-1.7,1.4-2.3,2.7h0.4
c0.4-0.6,0.8-1,1.1-1c0.4,0,0.6,0.6,0,1.9l-1.7,3.7c-0.3,0.7,0,1.2,0.7,1.2c0.4,0,0.6-0.1,0.8-0.6l1.7-4.4c0.5-0.6,0.9-1.1,1.4-1.6
H49.5z M37.5,157.9l0.2-0.5c-2.1-0.4-2.3-0.4-1.5-2.6l0.8-2.3h2.3c1,0,1,0.4,0.9,1.5h0.6l1.4-3.8h-0.6c-0.5,0.9-0.9,1.5-2,1.5h-2.3
l1.2-3.3c0.4-1,0.6-1.3,2-1.3h1c1.4,0,1.6,0.4,1.6,1.9h0.6l0.5-2.6h-8.6l-0.2,0.5c1.7,0.3,1.8,0.5,1,2.6l-1.9,5.1
c-0.8,2.1-1.1,2.3-3,2.6l-0.1,0.5H37.5z M79.7,131.4c0.6,0,1.1,0.4,0.8,1.5l-2.7,0.6C78.3,132.3,79.1,131.4,79.7,131.4 M81.2,135.7
h-0.5c-0.7,0.8-1.4,1.4-2.1,1.4c-0.7,0-1.1-0.4-1.1-1.4c0-0.4,0-0.8,0.1-1.2l4.3-1.4c0.8-2-0.2-2.9-1.4-2.9c-2,0-4.4,3.4-4.4,6.4
c0,1.3,0.6,2.1,1.6,2.1C79,138.7,80.2,137.6,81.2,135.7 M80.3,129.4l3-2.8v-0.3h-1.6l-1.9,3.2H80.3z M71,131.4h1.4l-2.3,6.2
c-0.2,0.5,0.1,1.1,0.6,1.1c1.3,0,3.4-1.3,4.1-3h-0.4c-0.6,0.6-1.7,1.4-2.6,1.6l2.1-5.8h2.1l0.3-0.9h-2.1l0.8-2.2h-0.8l-1.5,2.2
l-1.8,0.2V131.4z M69.1,131.1c0.2-0.6-0.2-0.9-0.5-0.9c-1.2,0-2.7,1.1-3.3,2.7h0.4c0.4-0.6,1.1-1.2,1.7-1.3l-2.4,6.2
c-0.2,0.6,0.2,0.9,0.5,0.9c1.2,0,2.6-1.1,3.2-2.7h-0.4c-0.4,0.6-1.1,1.2-1.7,1.3L69.1,131.1z M69.5,128c0.6,0,1-0.5,1-1
c0-0.6-0.5-1-1-1c-0.6,0-1,0.5-1,1C68.5,127.6,68.9,128,69.5,128 M61.2,137.3l4.3-11.4l-0.1-0.2l-2.7,0.3v0.3l0.5,0.4
c0.5,0.4,0.3,0.7-0.1,1.9l-3.4,8.9c-0.2,0.5,0.1,1.1,0.6,1.1c1.2,0,2.6-1.1,3.2-2.7h-0.4C62.7,136.6,61.8,137.2,61.2,137.3
M53,136.3c0-1.9,2.1-4.5,3.3-4.5c0.3,0,0.5,0,0.7,0.1l-1.2,3.3c-0.7,0.9-1.8,1.9-2.3,1.9C53.1,137.1,53,136.8,53,136.3
M59.5,129.9l-0.7,0l-0.7,0.7H58c-3.6,0-6.6,4-6.6,6.9c0,0.8,0.5,1.3,1.3,1.3c0.9,0,1.8-1.3,2.8-2.7l0,0.5
c-0.1,1.4,0.3,2.2,1.1,2.2c0.9,0,1.7-1.4,2.3-2.7h-0.4c-0.4,0.6-0.8,1-1.1,1c-0.3,0-0.6-0.6,0-1.9L59.5,129.9z M43.7,140.2
c0-0.8,0.8-1.3,1.9-1.8c0.4,0.2,0.9,0.4,1.7,0.6c1.2,0.4,1.6,0.5,1.6,0.9c0,0.8-1.3,1.3-3.1,1.3C44.4,141.3,43.7,141,43.7,140.2
M46.9,135.2c-0.5,0-0.7-0.4-0.7-0.9c0-1.4,0.7-3.4,1.9-3.4c0.5,0,0.7,0.4,0.7,0.9C48.8,133.1,48,135.2,46.9,135.2 M50.3,139.4
c0-1-0.9-1.4-2.3-1.8c-1.2-0.4-1.8-0.5-1.8-0.9c0-0.3,0.3-0.7,0.8-1c2-0.1,3.4-1.9,3.4-3.5c0-0.3-0.1-0.6-0.2-0.9h1.7l0.3-0.9h-2.7
c-0.3-0.2-0.7-0.3-1.1-0.3c-2.2,0-3.6,1.9-3.6,3.4c0,1.2,0.7,1.9,1.7,2.1c-1,0.5-1.6,1-1.6,1.6c0,0.4,0.1,0.6,0.4,0.8
c-2.3,0.7-3.3,1.5-3.3,2.6c0,1.1,1.4,1.5,3.1,1.5C47.9,142.3,50.3,140.7,50.3,139.4 M39.4,132.8c1,0,1,0.4,0.9,1.5h0.6l1.4-3.8
h-0.6c-0.5,0.9-0.9,1.5-2,1.5h-2.3l1.1-3.1c0.4-1,0.6-1.2,2-1.2h1c1.4,0,1.6,0.4,1.6,1.8h0.6l0.5-2.6h-8.6l-0.2,0.5
c1.7,0.3,1.8,0.5,1,2.6l-1.9,5.1c-0.8,2.1-1.1,2.3-3,2.6l-0.1,0.5H41l1.7-2.7H42c-1.1,1-2.5,2-4.4,2c-2.5,0-2.3-0.1-1.5-2.4
l0.9-2.5H39.4z M40.6,126.2l3-2.1v-0.3h-1.8l-1.7,2.4H40.6z M78.7,111.9c0.6,0,1.1,0.4,0.8,1.5l-2.7,0.6
C77.3,112.8,78,111.9,78.7,111.9 M80.2,116.2h-0.5c-0.7,0.8-1.4,1.4-2.1,1.4c-0.7,0-1.1-0.4-1.1-1.4c0-0.4,0-0.8,0.1-1.2l4.3-1.4
c0.8-2-0.2-2.9-1.4-2.9c-2,0-4.4,3.4-4.4,6.4c0,1.3,0.6,2.1,1.6,2.1C77.9,119.2,79.2,118.1,80.2,116.2 M79.3,110l3-2.8v-0.3h-1.6
l-1.9,3.2H79.3z M70.4,111.9h1.1l-2.3,6.2c-0.2,0.5,0.1,1.1,0.6,1.1c1.3,0,3.4-1.3,4.1-3h-0.4c-0.6,0.6-1.7,1.4-2.6,1.6l2.1-5.8
h2.1l0.3-0.9h-2.1l0.8-2.2h-0.8l-1.5,2.2l-1.5,0.2V111.9z M69.2,112.6c0.3-1,0.1-1.9-0.6-1.9c-0.9,0-1.2,0.7-2.1,2.7v-1.2
c0-0.8-0.3-1.6-1-1.6c-0.9,0-1.7,1.4-2.3,2.7h0.4c0.4-0.6,0.8-1,1.1-1c0.4,0,0.6,0.6,0,1.9l-1.7,3.7c-0.3,0.7,0,1.2,0.7,1.2
c0.4,0,0.6-0.1,0.8-0.6l1.7-4.4c0.5-0.6,0.9-1.1,1.4-1.6H69.2z M59.7,111.9c0.6,0,1.1,0.4,0.8,1.5l-2.7,0.6
C58.3,112.8,59.1,111.9,59.7,111.9 M61.2,116.2h-0.5c-0.7,0.8-1.4,1.4-2.1,1.4c-0.7,0-1.1-0.4-1.1-1.4c0-0.4,0-0.8,0.1-1.2l4.3-1.4
c0.8-2-0.2-2.9-1.4-2.9c-2,0-4.4,3.4-4.4,6.4c0,1.3,0.6,2.1,1.6,2.1C59,119.2,60.2,118.1,61.2,116.2 M50.6,118c-0.4,0-1-0.4-1-0.7
c0-0.1,0.2-0.6,0.4-1.2l0.7-1.9c0.7-0.9,1.9-1.8,2.5-1.8c0.4,0,0.7,0.2,0.7,0.8C53.9,114.9,52.3,118,50.6,118 M55.5,112.5
c0-1.3-0.5-1.7-1.3-1.7c-1.1,0-2.1,1.2-3.1,2.6l2.6-6.8l-0.1-0.2l-2.7,0.3v0.3l0.5,0.4c0.5,0.4,0.3,0.8-0.1,1.9l-2.8,7.2
c-0.2,0.5-0.5,1.2-0.5,1.3c0,0.7,1,1.4,1.9,1.4C52,119.2,55.5,115.4,55.5,112.5 M47,111.6c0.2-0.6-0.2-0.9-0.5-0.9
c-1.2,0-2.7,1.1-3.3,2.7h0.4c0.4-0.6,1.1-1.2,1.7-1.3l-2.4,6.2c-0.2,0.6,0.2,0.9,0.5,0.9c1.2,0,2.6-1.1,3.2-2.7h-0.4
c-0.4,0.6-1.1,1.2-1.7,1.3L47,111.6z M47.5,108.5c0.6,0,1-0.5,1-1c0-0.6-0.5-1-1-1c-0.6,0-1,0.5-1,1
C46.4,108.1,46.9,108.5,47.5,108.5 M41.2,107.5h-5.7l-0.2,0.5c1.7,0.3,1.8,0.5,1,2.6l-1.8,5.1c-0.8,2.1-1.1,2.3-3,2.6l-0.1,0.5H40
l1.9-3.3h-0.7c-1.1,1.2-2.3,2.6-4.2,2.6c-1.4,0-1.6-0.2-0.8-2.4l1.8-5.1c0.8-2.1,1.1-2.3,3-2.6L41.2,107.5z"
/>
<path
d="M40.9,88.2c1,0,1.9-0.2,2.7-0.5c0.8-0.3,1.5-0.8,2.1-1.3v-3.5h-5.3v-3.8h9.4v8.8c-1,1.3-2.2,2.3-3.8,3.1s-3.3,1.2-5.2,1.2
c-1.7,0-3.2-0.3-4.6-0.9c-1.4-0.6-2.6-1.4-3.6-2.4c-1-1-1.8-2.1-2.3-3.5c-0.6-1.3-0.8-2.7-0.8-4.2c0-1.5,0.3-2.9,0.8-4.2
c0.5-1.3,1.3-2.5,2.2-3.5c1-1,2.1-1.8,3.5-2.4s2.8-0.9,4.5-0.9c1.9,0,3.5,0.4,5,1.2c1.5,0.8,2.7,1.8,3.7,3L46,77
c-0.6-0.8-1.3-1.5-2.3-2.1s-2-0.8-3.1-0.8c-1,0-1.9,0.2-2.7,0.5c-0.8,0.4-1.5,0.9-2.1,1.5c-0.6,0.6-1,1.4-1.4,2.2
c-0.3,0.9-0.5,1.8-0.5,2.8s0.2,1.9,0.5,2.8c0.3,0.9,0.8,1.6,1.4,2.2c0.6,0.6,1.4,1.1,2.2,1.5C39,88,39.9,88.2,40.9,88.2z M64,70.3
c1.6,0,3.1,0.3,4.4,0.9s2.5,1.4,3.5,2.4c1,1,1.7,2.1,2.2,3.5c0.5,1.3,0.8,2.7,0.8,4.2c0,1.5-0.3,2.9-0.8,4.2
c-0.5,1.3-1.3,2.5-2.2,3.5c-1,1-2.1,1.8-3.5,2.4s-2.8,0.9-4.4,0.9c-1.6,0-3.1-0.3-4.5-0.9s-2.5-1.4-3.5-2.4c-1-1-1.7-2.1-2.2-3.5
c-0.5-1.3-0.8-2.7-0.8-4.2c0-1.5,0.3-2.9,0.8-4.2c0.5-1.3,1.3-2.5,2.2-3.5c1-1,2.1-1.8,3.5-2.4S62.4,70.3,64,70.3z M64,88.2
c1,0,1.9-0.2,2.7-0.5c0.8-0.4,1.5-0.9,2.1-1.5c0.6-0.6,1-1.4,1.4-2.2s0.5-1.8,0.5-2.8s-0.2-1.9-0.5-2.8c-0.3-0.9-0.8-1.6-1.4-2.2
c-0.6-0.6-1.3-1.1-2.1-1.5c-0.8-0.4-1.7-0.5-2.7-0.5c-1,0-1.9,0.2-2.7,0.5c-0.8,0.4-1.5,0.9-2.1,1.5c-0.6,0.6-1,1.4-1.4,2.2
c-0.3,0.9-0.5,1.8-0.5,2.8s0.2,1.9,0.5,2.8c0.3,0.9,0.8,1.6,1.4,2.2c0.6,0.6,1.3,1.1,2.1,1.5C62.1,88,63,88.2,64,88.2z M91.1,83.8
V70.9h4.2v12.6c0,2.7-0.8,4.8-2.3,6.4c-1.5,1.5-3.5,2.3-6.1,2.3c-2.6,0-4.6-0.8-6.1-2.3s-2.2-3.7-2.2-6.4V70.9h4.2v12.9
c0,1.4,0.4,2.5,1.1,3.2c0.7,0.8,1.8,1.2,3.1,1.2c1.3,0,2.3-0.4,3-1.2C90.8,86.3,91.1,85.2,91.1,83.8z M97.9,70.9h4.5l6.1,16.1
l6.1-16.1h4.5l-7.8,20.7h-5.5L97.9,70.9z M122.2,91.5V70.9h12v3.6h-7.8v4.8h6.7v3.6h-6.7V88h7.8v3.6H122.2z M139.1,91.5V70.9h6.3
c2.3,0,4.1,0.6,5.4,1.7c1.3,1.1,2,2.6,2,4.5c0,1.2-0.3,2.3-0.9,3.2c-0.6,0.9-1.4,1.6-2.4,2.1l6.5,9.1h-5l-5.5-8.3h-2.2v8.3H139.1z
M145.7,74.4h-2.4v5.2h2.4c0.9,0,1.6-0.2,2.1-0.7c0.5-0.5,0.7-1.1,0.7-1.9c0-0.8-0.2-1.4-0.7-1.9C147.2,74.7,146.6,74.4,145.7,74.4
z M158.6,91.5V70.9h5.4l9.2,14.8V70.9h4.2v20.7H172l-9.2-14.8v14.8H158.6z M183.1,91.5V70.9h12v3.6h-7.8v4.8h6.7v3.6h-6.7V88h7.8
v3.6H183.1z M200,91.5V70.9h5.3l5,8.5l5-8.5h5.3v20.7h-4.2V76.8l-4.6,7.6h-3l-4.6-7.6v14.7H200z M226.3,91.5V70.9h12v3.6h-7.8v4.8
h6.7v3.6h-6.7V88h7.8v3.6H226.3z M243.2,91.5V70.9h5.4l9.2,14.8V70.9h4.2v20.7h-5.4l-9.2-14.8v14.8H243.2z M265.7,74.7v-3.8h16.9
v3.8h-6.4v16.8H272V74.7H265.7z"
/>
<g id="Marianne">
<path
id="Fond_2_"
class="st0"
d="M63.6,53.4c0.3-0.3,0.6-0.6,0.9-1h0c0.6-0.6,1.1-1.3,1.8-1.8c0.2-0.2,0.4-0.3,0.6-0.5
c0.1-0.1,0.1-0.2,0.1-0.2c-0.3,0.1-0.4,0.3-0.7,0.4c-0.1,0-0.1-0.1-0.1-0.1c0.2-0.1,0.4-0.3,0.6-0.4c0,0,0,0,0,0
c-0.1,0-0.1-0.1-0.1-0.1c-0.7-0.1-1.2,0.4-1.7,0.8c-0.1,0.1-0.2-0.1-0.3-0.1c-0.8,0.3-1.4,1-2.2,1.3v-0.1c-0.3,0.1-0.6,0.3-1,0.4
c-0.5,0.1-0.9,0.1-1.3,0.1c-0.6,0.1-1.3,0.2-1.9,0.3c0,0,0,0-0.1,0c-0.3,0.1-0.7,0.2-1,0.4c0,0,0,0,0,0c0,0-0.1,0.1-0.1,0.1
c-0.1,0.1-0.2,0.2-0.4,0.3c-0.3,0.2-0.6,0.5-0.9,0.7c0,0-0.1,0-0.1,0c-0.3,0.3-0.6,0.6-0.9,0.8c0,0-0.1,0-0.2,0c0,0,0,0,0,0
c0,0,0,0,0-0.1c0-0.1,0.1-0.2,0.1-0.2c0.1-0.1,0.1-0.2,0.2-0.2c0.1-0.1,0.1-0.2,0.2-0.3c0,0,0-0.1,0-0.1c0,0,0,0-0.1,0
c0.3-0.3,0.6-0.5,0.9-0.7v0c0,0-0.1,0-0.1-0.1c0,0,0.1-0.1,0.1-0.1c0,0,0,0,0,0c0,0,0,0,0,0c-0.1,0.1-0.2,0.1-0.3,0.2
c-0.1,0.1-0.2,0.4-0.4,0.3c0,0-0.1,0-0.1,0c0,0,0,0-0.1,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0
c0,0,0,0,0,0c0,0,0,0,0-0.1c0,0,0,0,0,0c0,0,0-0.1,0.1-0.1c0,0,0,0,0-0.1c0,0,0-0.1,0.1-0.1c0,0,0-0.1,0-0.1
c0.1-0.1,0.2-0.2,0.4-0.3h0c0.2-0.1,0.4-0.2,0.6-0.3c0,0,0.1-0.1,0.1-0.1c-0.3,0.1-0.6,0.2-0.9,0.4c0,0-0.1,0-0.1,0
c0,0-0.1,0-0.1,0c0,0,0,0,0,0c0.1-0.1,0.2-0.2,0.3-0.3c0.1,0,0.1,0,0.1,0.1c1.7-1.3,4.1-1,6.1-1.7c0.2-0.1,0.3-0.2,0.5-0.3
c0.3-0.1,0.5-0.4,0.8-0.5c0.4-0.3,0.7-0.7,0.9-1.2c0-0.1-0.1-0.1-0.1-0.1c-0.7,0.8-1.5,1.3-2.4,1.8c-1.1,0.6-2.4,0.5-3.5,0.6
c0.1-0.1,0.2-0.1,0.3-0.1c0-0.2,0.1-0.2,0.2-0.3H59c0.1,0,0.1-0.1,0.1-0.1c0.1,0,0.3-0.1,0.2-0.1c-0.2-0.2-0.5,0.2-0.8,0
c0.1-0.1,0.1-0.3,0.2-0.3h0.2c0-0.1,0.1-0.2,0.1-0.2c0.8-0.5,1.6-0.9,2.3-1.3c-0.2,0-0.3,0.2-0.4,0.1c0.1,0,0-0.2,0.1-0.2
c0.6-0.2,1.1-0.5,1.7-0.7c-0.2,0-0.4,0.2-0.6,0c0.1-0.1,0.2-0.2,0.3-0.2v-0.2c0-0.1,0.1-0.1,0.1-0.1c-0.1,0-0.1-0.1-0.1-0.1
c0.1-0.1,0.2-0.1,0.3-0.2c-0.1,0-0.2,0-0.2-0.1c0.2-0.2,0.4-0.3,0.7-0.3c-0.1-0.1-0.2,0-0.2-0.1c0-0.1,0.1-0.1,0.1-0.1H63
c-0.1-0.1-0.1-0.2-0.1-0.2c0.3-0.4,0.3-0.9,0.5-1.3c-0.1,0-0.1,0-0.1-0.1c-0.5,0.6-1.4,0.8-2.2,1h-0.3c-0.3,0.1-0.6,0.1-0.9-0.1
c-0.2-0.1-0.3-0.3-0.5-0.4c-0.4-0.3-0.9-0.5-1.3-0.6c-1.3-0.4-2.7-0.6-4.1-0.6c0.6-0.3,1.2-0.3,1.9-0.5c0.9-0.3,1.8-0.6,2.7-0.5
c-0.2-0.1-0.4,0-0.5,0c-0.8-0.1-1.5,0.2-2.3,0.3c-0.5,0.1-1,0.3-1.6,0.4c-0.3,0.1-0.5,0.4-0.9,0.4v-0.2c0.5-0.6,1.2-1.3,2-1.3
c1-0.2,1.9,0,2.8,0.1c0.7,0.1,1.3,0.2,2,0.4c0.3,0,0.3,0.4,0.5,0.5c0.3,0.1,0.6,0,1,0.2c0-0.1-0.1-0.2,0-0.3
c0.2-0.2,0.5,0.1,0.7-0.1c0.4-0.3-0.4-0.7-0.6-1.1c0-0.1,0.1-0.1,0.1-0.1c0.4,0.4,0.7,0.8,1.3,1.1c0.3,0.1,0.9,0.3,0.8-0.1
c-0.3-0.6-0.8-1.1-1.2-1.6v-0.2c-0.1,0-0.1-0.1-0.2-0.1v-0.2c-0.2-0.1-0.2-0.3-0.3-0.5c-0.2-0.3-0.1-0.6-0.2-1
C62.1,39.3,62,39,62,38.7c-0.2-0.9-0.4-1.7-0.5-2.6c-0.1-1,0.6-1.8,1.1-2.7c0.4-0.6,0.8-1.3,1.5-1.7c0.2-0.6,0.6-1.2,1-1.7
c0.4-0.5,1.1-0.8,1.7-1.1c0.7-0.3,1.4-0.5,1.4-0.5l10.7,0c0,0,0.1,0,0.3,0.1c0.2,0.1,0.6,0.3,0.8,0.4c0.4,0.2,0.8,0.5,1,0.9
c0.1,0.2,0.3,0.5,0.2,0.7c-0.1,0.3-0.2,0.7-0.4,0.8c-0.3,0.2-0.7,0.2-1.1,0.1c-0.2,0-0.4-0.1-0.6-0.1c0.8,0.3,1.6,0.7,2.1,1.4
c0.1,0.1,0.3,0.2,0.5,0.2c0.1,0,0.1,0.1,0.1,0.2c-0.1,0.1-0.2,0.2-0.2,0.3h0.2c0.3-0.1,0.2-0.6,0.6-0.5c0.3,0.2,0.4,0.5,0.2,0.8
c-0.2,0.2-0.4,0.4-0.6,0.5c-0.1,0.1-0.1,0.3,0,0.4c0.2,0.2,0.2,0.4,0.3,0.6c0.2,0.4,0.2,0.8,0.4,1.2c0.2,0.8,0.4,1.6,0.4,2.4
c0,0.4-0.2,0.8-0.1,1.2c0.1,0.4,0.4,0.8,0.6,1.1c0.2,0.3,0.4,0.5,0.6,0.9c0.3,0.5,0.9,1.1,0.6,1.7c-0.2,0.4-0.8,0.3-1.1,0.5
c-0.3,0.3-0.1,0.7,0.1,1c0.3,0.5-0.3,0.8-0.7,1c0.1,0.2,0.3,0.1,0.4,0.2c0.1,0.3,0.3,0.4,0.2,0.7c-0.2,0.3-0.9,0.5-0.5,1
c0.2,0.4,0.1,0.8-0.1,1.2c-0.2,0.5-0.6,0.7-1,0.8c-0.3,0.1-0.7,0.1-1,0.1c-0.1-0.1-0.2-0.1-0.3-0.1c-0.9-0.1-1.8-0.4-2.7-0.4
c-0.3,0.1-0.5,0.1-0.7,0.2c-0.2,0.2-0.5,0.4-0.6,0.6c0,0,0,0,0,0c0,0-0.1,0.1-0.1,0.1c0,0,0,0.1-0.1,0.1c0,0,0,0,0,0.1
c-0.2,0.2-0.3,0.4-0.4,0.6c0,0,0,0,0,0c0,0,0,0.1,0,0.1c-0.2,0.3-0.3,0.7-0.4,1c-0.4,1.2-0.2,2.3,0.1,2.5c0.1,0.1,1.8,0.6,2.9,1.1
c0.6,0.2,0.9,0.4,1.3,0.6l-22,0c1-0.7,2-1.1,3.5-1.8C61.6,54.6,63.1,53.9,63.6,53.4 M55.4,49.5c-0.1,0-0.3,0.1-0.3-0.1
c0.1-0.3,0.4-0.3,0.6-0.4c0.1-0.1,0.3-0.2,0.4-0.1c0.1,0.2,0.3,0.1,0.4,0.2C56.2,49.5,55.8,49.4,55.4,49.5 M47.2,48.3
c0,0-0.1-0.1-0.1-0.1c0.7-0.9,1.2-1.8,1.7-2.7c0.7-0.4,1.3-0.9,1.8-1.5c0.9-1,1.9-1.8,3-2.4c0.4-0.2,1-0.1,1.4,0.1
c-0.2,0.2-0.4,0.2-0.6,0.3c-0.1,0-0.1,0-0.2-0.1c0.1-0.1,0.1-0.1,0.1-0.2c-0.5,0.6-1.3,0.9-1.7,1.6c-0.3,0.5-0.5,1.2-1.2,1.4
c-0.2,0.1,0.1-0.2-0.1-0.1C49.6,45.7,48.5,46.9,47.2,48.3 M51.6,44.8c-0.1,0.1-0.1,0.1-0.2,0.2c-0.1,0.1-0.1,0.2-0.2,0.2
c-0.1,0-0.1,0-0.1-0.1c0.1-0.2,0.2-0.4,0.4-0.5C51.6,44.7,51.6,44.8,51.6,44.8 M54.1,52.8c0,0.1-0.1,0.1-0.1,0.2
c0.1,0,0.1,0,0.1,0.1c-0.1,0.1-0.2,0.2-0.4,0.3c0,0,0,0-0.1,0c-0.1,0.1-0.1,0.1-0.2,0.2c-0.1,0.1-0.4,0-0.3-0.1
c0.1-0.1,0.3-0.3,0.4-0.4c0.1-0.1,0.2-0.1,0.2-0.2c0,0,0.1-0.1,0.1-0.1C53.9,52.8,54.2,52.7,54.1,52.8 M53.2,52.4
C53.2,52.4,53.1,52.4,53.2,52.4c-0.2,0.2-0.4,0.3-0.6,0.4c-0.2,0.1-0.5,0.2-0.7,0.3c0,0,0,0,0,0c0,0-0.1,0-0.1,0
c-0.2,0.1-0.4,0.3-0.5,0.4c0,0,0,0-0.1,0.1c0,0,0,0,0,0c0,0,0,0,0,0c0,0-0.1,0.1-0.1,0.1c0,0,0,0,0,0c0,0,0,0,0,0
c0,0-0.1,0.1-0.1,0.1c0,0,0,0.1-0.1,0.1c0,0-0.1,0-0.1,0c0,0,0,0,0,0c0,0-0.1,0-0.1,0c0,0-0.1,0-0.1,0c0,0,0,0,0,0c0,0,0,0,0,0
c-0.1,0.1-0.1,0.1-0.2,0.2c-0.1,0.1-0.2,0.2-0.3,0.3c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0.1
l0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0-0.1,0-0.1l0,0c0,0,0,0,0,0c0.1-0.1,0.1-0.1,0.2-0.2c0,0,0,0,0,0c0,0,0,0,0.1-0.1
c0,0,0.1-0.1,0.1-0.1c0,0,0,0,0,0c0.1-0.1,0.1-0.2,0.2-0.2c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0.1-0.1,0.1-0.1c0,0,0-0.1,0.1-0.1
c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0-0.1,0.1-0.1c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0-0.1c0,0,0,0,0,0c0,0,0-0.1,0-0.1
c0,0,0,0,0,0c0.1-0.1,0.1-0.2,0.2-0.3c0,0,0,0,0,0c-0.1,0-0.1,0.1-0.2,0.2c-0.1,0-0.2,0-0.1-0.1c0,0,0.1-0.1,0.1-0.1c0,0,0,0,0,0
c0.1-0.1,0.2-0.2,0.3-0.3c0.1,0,0.1-0.1,0.2-0.1c0,0,0,0,0,0c0,0,0.1-0.1,0.1-0.1c0,0,0,0,0,0c0.5-0.5,1.4-0.5,2.1-0.8
c0.3-0.1,0.6,0.1,0.9,0c0.2,0,0.3,0,0.5,0.1C54,51.8,53.6,52.1,53.2,52.4 M54.3,48.7c-0.1-0.1,0.2,0,0.2-0.1H54
c-0.1,0-0.1-0.1-0.1-0.1c-0.3,0.1-0.6,0.2-0.9,0.2c-0.4,0.1-0.7,0.4-1.1,0.5c-0.6,0.2-1.1,0.7-1.7,0.9c-0.1,0-0.1-0.1-0.1-0.1
c0.1-0.2,0.3-0.2,0.4-0.4c0-0.1,0-0.1-0.1-0.1c0.4-0.6,1-0.9,1.6-1.4v-0.2c0.2-0.2,0.4-0.3,0.5-0.6c0.1-0.2,0.3-0.4,0.5-0.5
c-0.1-0.1-0.2-0.1-0.2-0.2c-0.2,0-0.4,0.1-0.6-0.1c0.1-0.1,0.2-0.2,0.3-0.2c0,0-0.1,0-0.1-0.1c-0.1-0.1,0.1-0.2,0.3-0.3
c0.2-0.1,0.5-0.1,0.6-0.2c-0.4-0.1-0.8,0.1-1.2-0.1c0.3-0.7,0.7-1.3,1.3-1.6c0.1,0,0.2,0,0.2,0.1c0,0.3-0.2,0.5-0.4,0.5
c0.4,0.1,0.9,0.1,1.3,0.3c-0.1,0.1-0.2,0.1-0.2,0.1c0.3,0.2,0.6,0.1,0.9,0.3c-0.2,0.2-0.3,0-0.5,0c1.7,0.5,3.4,0.9,4.8,1.9
c-1.2,0.6-2.4,0.9-3.7,1.1c-0.2,0-0.3,0-0.4-0.1c0,0.1,0,0.2-0.1,0.2c-0.2,0-0.4,0-0.5,0.1C54.7,48.8,54.4,48.8,54.3,48.7"
/>
<path
id="Rouge_1_"
class="st1"
d="M63.6,53.4c0.3-0.3,0.6-0.6,0.9-1h0c0.6-0.6,1.1-1.3,1.8-1.8c0.2-0.2,0.4-0.3,0.6-0.5
c0.1-0.1,0.1-0.2,0.1-0.2c-0.3,0.1-0.4,0.3-0.7,0.4c-0.1,0-0.1-0.1-0.1-0.1c0.2-0.1,0.4-0.3,0.6-0.4c0,0,0,0,0,0
c-0.1,0-0.1-0.1-0.1-0.1c-0.7-0.1-1.2,0.4-1.7,0.8c-0.1,0.1-0.2-0.1-0.3-0.1c-0.8,0.3-1.4,1-2.2,1.3v-0.1c-0.3,0.1-0.6,0.3-1,0.4
c-0.5,0.1-0.9,0.1-1.3,0.1c-0.6,0.1-1.3,0.2-1.9,0.3c0,0,0,0-0.1,0c-0.3,0.1-0.7,0.2-1,0.4c0,0,0,0,0,0c0,0-0.1,0.1-0.1,0.1
c-0.1,0.1-0.2,0.2-0.4,0.3c-0.3,0.2-0.6,0.5-0.9,0.7c0,0-0.1,0-0.1,0c-0.3,0.3-0.6,0.6-0.9,0.8c0,0-0.1,0-0.2,0c0,0,0,0,0,0
c0,0,0,0,0-0.1c0-0.1,0.1-0.2,0.1-0.2c0.1-0.1,0.1-0.2,0.2-0.2c0.1-0.1,0.1-0.2,0.2-0.3c0,0,0-0.1,0-0.1c0,0,0,0-0.1,0
c0.3-0.3,0.6-0.5,0.9-0.7v0c0,0-0.1,0-0.1-0.1c0,0,0.1-0.1,0.1-0.1c0,0,0,0,0,0c0,0,0,0,0,0c-0.1,0.1-0.2,0.1-0.3,0.2
c-0.1,0.1-0.2,0.4-0.4,0.3c0,0-0.1,0-0.1,0c0,0,0,0-0.1,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0
c0,0,0,0,0,0c0,0,0,0,0-0.1c0,0,0,0,0,0c0,0,0-0.1,0.1-0.1c0,0,0,0,0-0.1c0,0,0-0.1,0.1-0.1c0,0,0-0.1,0-0.1
c0.1-0.1,0.2-0.2,0.4-0.3h0c0.2-0.1,0.4-0.2,0.6-0.3c0,0,0.1-0.1,0.1-0.1c-0.3,0.1-0.6,0.2-0.9,0.4c0,0-0.1,0-0.1,0
c0,0-0.1,0-0.1,0c0,0,0,0,0,0c0.1-0.1,0.2-0.2,0.3-0.3c0.1,0,0.1,0,0.1,0.1c1.7-1.3,4.1-1,6.1-1.7c0.2-0.1,0.3-0.2,0.5-0.3
c0.3-0.1,0.5-0.4,0.8-0.5c0.4-0.3,0.7-0.7,0.9-1.2c0-0.1-0.1-0.1-0.1-0.1c-0.7,0.8-1.5,1.3-2.4,1.8c-1.1,0.6-2.4,0.5-3.5,0.6
c0.1-0.1,0.2-0.1,0.3-0.1c0-0.2,0.1-0.2,0.2-0.3H59c0.1,0,0.1-0.1,0.1-0.1c0.1,0,0.3-0.1,0.2-0.1c-0.2-0.2-0.5,0.2-0.8,0
c0.1-0.1,0.1-0.3,0.2-0.3h0.2c0-0.1,0.1-0.2,0.1-0.2c0.8-0.5,1.6-0.9,2.3-1.3c-0.2,0-0.3,0.2-0.4,0.1c0.1,0,0-0.2,0.1-0.2
c0.6-0.2,1.1-0.5,1.7-0.7c-0.2,0-0.4,0.2-0.6,0c0.1-0.1,0.2-0.2,0.3-0.2v-0.2c0-0.1,0.1-0.1,0.1-0.1c-0.1,0-0.1-0.1-0.1-0.1
c0.1-0.1,0.2-0.1,0.3-0.2c-0.1,0-0.2,0-0.2-0.1c0.2-0.2,0.4-0.3,0.7-0.3c-0.1-0.1-0.2,0-0.2-0.1c0-0.1,0.1-0.1,0.1-0.1H63
c-0.1-0.1-0.1-0.2-0.1-0.2c0.3-0.4,0.3-0.9,0.5-1.3c-0.1,0-0.1,0-0.1-0.1c-0.5,0.6-1.4,0.8-2.2,1h-0.3c-0.3,0.1-0.6,0.1-0.9-0.1
c-0.2-0.1-0.3-0.3-0.5-0.4c-0.4-0.3-0.9-0.5-1.3-0.6c-1.3-0.4-2.7-0.6-4.1-0.6c0.6-0.3,1.2-0.3,1.9-0.5c0.9-0.3,1.8-0.6,2.7-0.5
c-0.2-0.1-0.4,0-0.5,0c-0.8-0.1-1.5,0.2-2.3,0.3c-0.5,0.1-1,0.3-1.6,0.4c-0.3,0.1-0.5,0.4-0.9,0.4V44c0.5-0.6,1.2-1.3,2-1.3
c1-0.2,1.9,0,2.8,0.1c0.7,0.1,1.3,0.2,2,0.4c0.3,0,0.3,0.4,0.5,0.5c0.3,0.1,0.6,0,1,0.2c0-0.1-0.1-0.2,0-0.3
c0.2-0.2,0.5,0.1,0.7-0.1c0.4-0.3-0.4-0.7-0.6-1.1c0-0.1,0.1-0.1,0.1-0.1c0.4,0.4,0.7,0.8,1.3,1.1c0.3,0.1,0.9,0.3,0.8-0.1
c-0.3-0.6-0.8-1.1-1.2-1.6v-0.2c-0.1,0-0.1-0.1-0.2-0.1v-0.2c-0.2-0.1-0.2-0.3-0.3-0.5c-0.2-0.3-0.1-0.6-0.2-1
C62.1,39.3,62,39,62,38.7c-0.2-0.9-0.4-1.7-0.5-2.6c-0.1-1,0.6-1.8,1.1-2.7c0.4-0.6,0.8-1.3,1.5-1.7c0.2-0.6,0.6-1.2,1-1.7
c0.4-0.5,1.1-0.8,1.7-1.1c0.7-0.3,1.4-0.5,1.4-0.5H31.4v28.3h26c1-0.7,2-1.1,3.5-1.8C61.6,54.6,63.1,53.9,63.6,53.4 M55.4,49.5
c-0.1,0-0.3,0.1-0.3-0.1c0.1-0.3,0.4-0.3,0.6-0.4c0.1-0.1,0.3-0.2,0.4-0.1c0.1,0.2,0.3,0.1,0.4,0.2C56.2,49.5,55.8,49.4,55.4,49.5
M47.2,48.3c0,0-0.1-0.1-0.1-0.1c0.7-0.9,1.2-1.8,1.7-2.7c0.7-0.4,1.3-0.9,1.8-1.5c0.9-1,1.9-1.8,3-2.4c0.4-0.2,1-0.1,1.4,0.1
c-0.2,0.2-0.4,0.2-0.6,0.3c-0.1,0-0.1,0-0.2-0.1c0.1-0.1,0.1-0.1,0.1-0.2c-0.5,0.6-1.3,0.9-1.7,1.6c-0.3,0.5-0.5,1.2-1.2,1.4
c-0.2,0.1,0.1-0.2-0.1-0.1C49.7,45.7,48.5,46.9,47.2,48.3 M51.6,44.8c-0.1,0.1-0.1,0.1-0.2,0.2c-0.1,0.1-0.1,0.2-0.2,0.2
c-0.1,0-0.1,0-0.1-0.1c0.1-0.2,0.2-0.4,0.4-0.5C51.6,44.7,51.6,44.8,51.6,44.8 M54.1,52.8c0,0.1-0.1,0.1-0.1,0.2
c0.1,0,0.1,0,0.1,0.1c-0.1,0.1-0.2,0.2-0.4,0.3c0,0,0,0-0.1,0c-0.1,0.1-0.1,0.1-0.2,0.2c-0.1,0.1-0.4,0-0.3-0.1
c0.1-0.1,0.3-0.3,0.4-0.4c0.1-0.1,0.2-0.1,0.2-0.2c0,0,0.1-0.1,0.1-0.1C53.9,52.8,54.2,52.7,54.1,52.8 M53.2,52.4
C53.2,52.4,53.2,52.4,53.2,52.4c-0.2,0.2-0.4,0.3-0.6,0.4c-0.2,0.1-0.5,0.2-0.7,0.3c0,0,0,0,0,0c0,0-0.1,0-0.1,0
c-0.2,0.1-0.4,0.3-0.5,0.4c0,0,0,0-0.1,0.1c0,0,0,0,0,0c0,0,0,0,0,0c0,0-0.1,0.1-0.1,0.1c0,0,0,0,0,0c0,0,0,0,0,0
c0,0-0.1,0.1-0.1,0.1c0,0,0,0.1-0.1,0.1c0,0-0.1,0-0.1,0c0,0,0,0,0,0c0,0-0.1,0-0.1,0c0,0-0.1,0-0.1,0c0,0,0,0,0,0c0,0,0,0,0,0
c-0.1,0.1-0.1,0.1-0.2,0.2c-0.1,0.1-0.2,0.2-0.3,0.3c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0.1
l0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0-0.1,0-0.1l0,0c0,0,0,0,0,0c0.1-0.1,0.1-0.1,0.2-0.2c0,0,0,0,0,0c0,0,0,0,0.1-0.1
c0,0,0.1-0.1,0.1-0.1c0,0,0,0,0,0c0.1-0.1,0.1-0.2,0.2-0.2c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0.1-0.1,0.1-0.1c0,0,0-0.1,0.1-0.1
c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0-0.1,0.1-0.1c0,0,0,0,0,0c0,0,0,0,0,0c0,0,0,0,0-0.1c0,0,0,0,0,0c0,0,0-0.1,0-0.1
c0,0,0,0,0,0c0.1-0.1,0.1-0.2,0.2-0.3c0,0,0,0,0,0c-0.1,0-0.1,0.1-0.2,0.2c-0.1,0-0.2,0-0.1-0.1c0,0,0.1-0.1,0.1-0.1c0,0,0,0,0,0
c0.1-0.1,0.2-0.2,0.3-0.3c0.1,0,0.1-0.1,0.2-0.1c0,0,0,0,0,0c0,0,0.1-0.1,0.1-0.1c0,0,0,0,0,0c0.5-0.5,1.4-0.5,2.1-0.8
c0.3-0.1,0.6,0.1,0.9,0c0.2,0,0.3,0,0.5,0.1C54,51.8,53.6,52.1,53.2,52.4 M54.3,48.7c-0.1-0.1,0.2,0,0.2-0.1H54
c-0.1,0-0.1-0.1-0.1-0.1c-0.3,0.1-0.6,0.2-0.9,0.2c-0.4,0.1-0.7,0.4-1.1,0.5c-0.6,0.2-1.1,0.7-1.7,0.9c-0.1,0-0.1-0.1-0.1-0.1
c0.1-0.2,0.3-0.2,0.4-0.4c0-0.1,0-0.1-0.1-0.1c0.4-0.6,1-0.9,1.6-1.4v-0.2c0.2-0.2,0.4-0.3,0.5-0.6c0.1-0.2,0.3-0.4,0.5-0.5
c-0.1-0.1-0.2-0.1-0.2-0.2c-0.2,0-0.4,0.1-0.6-0.1c0.1-0.1,0.2-0.2,0.3-0.2c0,0-0.1,0-0.1-0.1c-0.1-0.1,0.1-0.2,0.3-0.3
c0.2-0.1,0.5-0.1,0.6-0.2c-0.4-0.1-0.8,0.1-1.2-0.1c0.3-0.7,0.7-1.3,1.3-1.6c0.1,0,0.2,0,0.2,0.1c0,0.3-0.2,0.5-0.4,0.5
c0.4,0.1,0.9,0.1,1.3,0.3c-0.1,0.1-0.2,0.1-0.2,0.1c0.3,0.2,0.6,0.1,0.9,0.3c-0.2,0.2-0.3,0-0.5,0c1.7,0.5,3.4,0.9,4.8,1.9
c-1.2,0.6-2.4,0.9-3.7,1.1c-0.2,0-0.3,0-0.4-0.1c0,0.1,0,0.2-0.1,0.2c-0.2,0-0.4,0-0.5,0.1C54.7,48.8,54.4,48.8,54.3,48.7"
/>
<path
id="Bleu_1_"
class="st2"
d="M109.3,28.4H78.9c0,0,0.1,0,0.3,0.1c0.2,0.1,0.6,0.3,0.8,0.4c0.4,0.2,0.8,0.5,1,0.9
c0.1,0.2,0.3,0.5,0.2,0.7c-0.1,0.3-0.2,0.7-0.4,0.8c-0.3,0.2-0.7,0.2-1.1,0.1c-0.2,0-0.4-0.1-0.6-0.1c0.8,0.3,1.6,0.7,2.1,1.4
c0.1,0.1,0.3,0.2,0.5,0.2c0.1,0,0.1,0.1,0.1,0.2c-0.1,0.1-0.2,0.2-0.2,0.3h0.2c0.3-0.1,0.2-0.6,0.6-0.5c0.3,0.2,0.4,0.5,0.2,0.8
c-0.2,0.2-0.4,0.4-0.6,0.5c-0.1,0.1-0.1,0.3,0,0.4c0.2,0.2,0.2,0.4,0.3,0.6c0.2,0.4,0.2,0.8,0.4,1.2c0.2,0.8,0.4,1.6,0.4,2.4
c0,0.4-0.2,0.8-0.1,1.2c0.1,0.4,0.4,0.8,0.6,1.1c0.2,0.3,0.4,0.5,0.6,0.9c0.3,0.5,0.9,1.1,0.6,1.7c-0.2,0.4-0.8,0.3-1.1,0.5
c-0.3,0.3-0.1,0.7,0.1,1c0.3,0.5-0.3,0.8-0.7,1c0.1,0.2,0.3,0.1,0.4,0.2c0.1,0.3,0.3,0.4,0.2,0.7c-0.2,0.3-0.9,0.5-0.5,1
c0.2,0.4,0.1,0.8-0.1,1.2c-0.2,0.5-0.6,0.7-1,0.8c-0.3,0.1-0.7,0.1-1,0.1c-0.1-0.1-0.2-0.1-0.3-0.1c-0.9-0.1-1.8-0.4-2.7-0.4
c-0.3,0.1-0.5,0.1-0.7,0.2c-0.2,0.2-0.5,0.4-0.6,0.6c0,0,0,0,0,0c0,0-0.1,0.1-0.1,0.1c0,0,0,0.1-0.1,0.1c0,0,0,0,0,0.1
c-0.2,0.2-0.3,0.4-0.4,0.6c0,0,0,0,0,0c0,0,0,0.1,0,0.1c-0.2,0.3-0.3,0.7-0.4,1c-0.4,1.2-0.2,2.3,0.1,2.5c0.1,0.1,1.8,0.6,2.9,1.1
c0.6,0.2,0.9,0.4,1.3,0.6h29.9V28.4z"
/>
<path
id="Yeux_1_"
class="st3"
d="M80.7,38.7c0.2,0.1,0.5,0.1,0.5,0.2c-0.1,0.4-0.7,0.5-1.1,1H80c-0.2,0.1-0.1,0.4-0.3,0.4
c-0.2-0.1-0.3,0-0.5,0.1c0.2,0.2,0.5,0.4,0.8,0.3c0.1,0,0.2,0.1,0.2,0.2c0,0,0.1,0,0.1-0.1c0.1,0,0.1,0,0.1,0.1V41
c-0.2,0.2-0.4,0.1-0.6,0.2c0.4,0.1,0.9,0.1,1.2,0c0.3-0.1,0-0.6,0.2-0.9c-0.1,0,0-0.2-0.1-0.2c0.1-0.1,0.2-0.3,0.3-0.3
c0.1,0,0.3-0.1,0.3-0.2c0-0.1-0.2-0.2-0.2-0.3c0.3-0.2,0.6-0.5,0.5-0.9c-0.1-0.2-0.5-0.2-0.8-0.3c-0.3-0.1-0.6,0-0.9,0.1
c-0.3,0-0.5,0.2-0.8,0.2c-0.4,0.1-0.7,0.3-1,0.5c0.4-0.2,0.8-0.2,1.2-0.3C80.1,38.7,80.3,38.6,80.7,38.7"
/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2ZM11.751 7.14453C11.7159 7.14453 11.6808 7.15341 11.6504 7.1709L8.87988 8.7666C8.77836 8.82519 8.74931 8.95855 8.81641 9.05469L9.5166 10.0576C9.57558 10.1417 9.68874 10.1675 9.77832 10.1172L11.3301 9.24512C11.4633 9.17057 11.6279 9.26713 11.6279 9.41992V16.6553C11.6279 16.7657 11.7177 16.8555 11.8281 16.8555H13.2988C13.4093 16.8555 13.499 16.7657 13.499 16.6553V7.34473C13.499 7.23427 13.4093 7.14453 13.2988 7.14453H11.751Z" fill="#222631"/>
</svg>

After

Width:  |  Height:  |  Size: 708 B

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