Compare commits

..

76 Commits

Author SHA1 Message Date
Anthony LC
86bde354a5 save1 2026-01-27 17:53:21 +01:00
Anthony LC
8ec31d75d7 save 2026-01-27 17:53:21 +01:00
Anthony LC
2c4c65b05c fixup! ️(frontend) improve prompt of some actions 2026-01-27 17:53:21 +01:00
Anthony LC
610a469a08 fixup! (back) manage streaming with the ai service 2026-01-27 17:53:20 +01:00
Anthony LC
c8c58ddbdb fixup! (frontend) integrate new Blocknote AI feature 2026-01-27 17:53:20 +01:00
Anthony LC
b6b0748ab3 fixup! ️(frontend) improve prompt of some actions 2026-01-27 17:53:20 +01:00
Anthony LC
79b86b069b fixup! (frontend) integrate new Blocknote AI feature 2026-01-27 17:53:20 +01:00
Manuel Raynaud
96a759400a (back) manage streaming with the ai service
We want to handle both streaming or not when interacting with the AI
backend service.
2026-01-27 17:53:19 +01:00
Anthony LC
0ec06e81d6 test-instance 2026-01-27 17:53:19 +01:00
Anthony LC
b90e6271d9 🛂(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-01-27 17:53:19 +01:00
Anthony LC
980f882f2f 📄(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-01-27 17:53:19 +01:00
Anthony LC
270d87b0a4 🔥(project) remove previous AI feature
We replace the previous AI feature with a new one
that uses the BlockNote AI service.
We can remove the dead codes.
2026-01-27 17:53:18 +01:00
Anthony LC
dd68b5a1b3 ️(frontend) improve prompt of some actions
Some answers were a bit too concise or not detailed enough.
Improve some prompts to get better answers from the AI.
2026-01-27 17:53:18 +01:00
Anthony LC
91aa9d6acb 🔧(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-01-27 17:53:18 +01:00
Anthony LC
08b04dea90 (frontend) integrate new Blocknote AI feature
We integrate the new Blocknote AI feature
into Docs, enhancing the document editing experience
with AI capabilities.
2026-01-27 17:53:17 +01:00
Anthony LC
cc7ed88498 (backend) add ai_proxy
Add AI proxy to handle AI related requests
to the AI service.
2026-01-27 17:52:59 +01:00
Anthony LC
325c7d9786 🔧(project) add DJANGO_EMAIL_URL_APP environment variable
Most of Docs app is configured thanks to environment
variables, except the url in the email that
was from the django site table.
Now we can set it with DJANGO_EMAIL_URL_APP
environment variable to have a better consistency.
We keep the previous way to avoid breaking
changes.
2026-01-23 17:56:31 +01:00
renovate[bot]
1083aac920 ⬆️(dependencies) update lodash to v4.17.23 [SECURITY] 2026-01-23 00:38:29 +00:00
Anthony LC
dcfb1115dd 🐛(export) fix export column NaN
During the export of tables to PDF, columns
with NaN widths were not handled correctly,
leading to export not exporting.
We now take in case NaN columnwidths.
We update the regressions tests to include
this kind of tables.
2026-01-22 17:37:48 +01:00
Anthony LC
f64800727a (e2e) fix flaki tests
Some tests were getting flaky:
- check the reorder of sub page
- it checks interlink feature
2026-01-22 17:07:01 +01:00
Anthony LC
65b67a29b1 🚨(CI) gives warning if theme not updated
When updating the UIkit or Cunningham version,
ensure to also update the theme files accordingly.
2026-01-21 11:27:45 +01:00
Anthony LC
b8bdcbf7ed 🛂(frontend) use max size and extension from config
The max size and allowed extensions for document
import are now fetched from the application
configuration.
This ensures consistency across the app and
allows for easier updates to these
settings in the future.
2026-01-21 10:30:24 +01:00
Manuel Raynaud
be995fd211 ♻️(helm) increase client_max_body_size value
Image and document uploaded were limited to 10MB.
For the conversion service, we allow up to 20MB.
For the dev and feature environment, we have to increase this value
accordingly.
2026-01-21 10:27:59 +01:00
Manuel Raynaud
dd5b6bd023 (backend) improve validation on conversion uploaded file
We now check the size and the extension of the uploaded file for
conversion.
2026-01-21 10:27:59 +01:00
Stephan Meijer
9345d8deab (docker) add docspec deployment and service to kubernetes configuration
Added Helm templates for docspec deployment and service to enable
document specification conversion in the Kubernetes environment.
Updated Tiltfile, compose.yml, and Helm values to
configure docspec integration alongside the
backend converter service for document import functionality.
2026-01-21 10:27:58 +01:00
Stephan Meijer
f0cc29e779 ♻️(backend) stylistic and consistency changes
Refactored converter services based on PR #1609 review comments:
- Renamed parameter to `data` across all convert methods for consistency
- Replaced recursive call with explicit sequential calls for readability
- Hardcoded CONVERSION_API_SECURE=True in Production class for security
- Removed unused YdocConverter import from viewsets.py
- Updated tests to match new error message wording

Signed-off-by: Stephan Meijer <me@stephanmeijer.com>
2026-01-21 10:27:58 +01:00
Stephan Meijer
767710231d (backend) add tests for document import feature
Added comprehensive tests covering DocSpec converter service,
converter orchestration, and document creation with file uploads.

Tests validate DOCX and Markdown conversion workflows, error
handling, service availability, and edge cases including empty
files and Unicode filenames.

Signed-off-by: Stephan Meijer <me@stephanmeijer.com>
2026-01-21 10:27:57 +01:00
Stephan Meijer
3480604359 ⬆️(docker) upgrade docspec api to version 2.4.4
Updated docspec service image from 2.0.0 to 2.4.4 to
include latest features and bug fixes.

Signed-off-by: Stephan Meijer <me@stephanmeijer.com>
2026-01-21 10:27:57 +01:00
Anthony LC
2e6c39262d (frontend) add import document area in docs grid
Add import document area with drag and drop
support in the docs grid component.
We can now import docx and and md files just
by dropping them into the designated area.

We are using the `react-dropzone` library to
handle the drag and drop functionality.
2026-01-21 10:27:56 +01:00
Anthony LC
feb9f7d4a9 💄(frontend) adapt the docs grid title bar
Adapt the docs grid title bar to align with the
new design. We will add a upload button in a
future iteration.
2026-01-21 10:27:56 +01:00
Stephan Meijer
b547657efd (backend) Import of documents
We can now import documents in formats .docx and .md.
To do so we added a new container "docspec", which
uses the docspec service to convert
these formats to Blocknote format.

More here: #1567 #1569.
2026-01-21 10:27:56 +01:00
Anthony LC
61dbda0bf6 🔥(backend) remove all code related to template
The template feature is removed.
Migration created to drop related tables.
Files modified:
- viewsets
- serializers
- models
- admin
- factories
- urls
- tests
- demo data
2026-01-21 09:51:49 +01:00
Anthony LC
548f32bf4e 🔥(frontend) remove all code related to template
The template feature is removed from the frontend
applications. It was used mainly for document export
with predefined templates.
2026-01-21 09:50:21 +01:00
Anthony LC
dd02b9d940 ♻️(backend) include sub documents in the favorite_list route
The favorite_list route was returning all the favorite with depth=0. We
also want to see favorited document with a depth > 0
2026-01-20 16:26:04 +01:00
Anthony LC
f81db395ef ♻️(frontend) use dedicated favorite_list route
It exists a dedicated endpoint to list favorite
documents. Better use it to avoid relying on the
general documents listing endpoint.
2026-01-20 14:54:44 +01:00
Sylvain Boissel
668d7cd404 (backend) add field for button label in email template (#1817)
## Purpose

The email template is made with the idea that they link to a document.
This change allows to customize the label of the button (currently,
"Open") to allow for a different action verb. Additionally, the
'document_title' parameter is renamed to 'link_label' to reflect that it
can link to other things than documents.

## Proposal
- [x] Email template `template.mjml` updated as proposed
- [x] Method `send_email()` updated
- [x] Translations updated
2026-01-20 12:03:54 +01:00
Anthony LC
f199acf6c2 🔒️(trivy) fix vulnerability about jaraco.context
We got a vulnerability report from Trivy about
jaraco.context package. It comes from setuptools.
setuptools does not seems used by the application.
We removed it.
2026-01-20 09:15:25 +01:00
Anthony LC
75f71368f4 🐛(frontend) fix emojipicker closing
In the tree view, if the emoji picker is opened
near the bottom of the viewport, it would
trigger an overflow that rerendered the treeview
and closed the picker immediately.
The root problem is the treeview that rerender
because of not stable props.
To fix this, we change 2 things:
- we use "fixed" position for the emoji picker
  so it won't affect the document flow
- we adjust the position calculation logic, if
  the picker does not have enough space below,
  we position it above the icon instead.
2026-01-19 17:12:45 +01:00
Anthony LC
21f5feab3e 🚚(frontend) move emoji picker
The emoji picker component is used in different
parts of the application, so it makes sense to
move it to a more general location.
2026-01-19 17:12:17 +01:00
Anthony LC
8ec89a8348 🚨(frontend) fix warning resizable panel
We had a warning in the console about the
resizable panel component.
This commit fixes that warning by ensuring that the
size is never inferior to the minimum size.
2026-01-19 17:12:17 +01:00
Anthony LC
3b80ac7b4e 🐛(frontend) add fallback for unsupported blocknote languages
We had a bug when user selected a language that is
not supported by BlockNote editor, the app
would crash.
If the language is not supported by BlockNote,
we now fallback Blocknote editor to English.
2026-01-19 16:50:20 +01:00
Cyril
68df717854 ️(frontend) fix subdoc opening and emoji pick focus
ensures subdoc opens and emoji picker focus on input

Signed-off-by: Cyril <c.gromoff@gmail.com>
2026-01-19 11:59:09 +01:00
Anthony LC
2f52dddc84 (frontend) integrate configurable Waffle
Integrate Waffle component based on LaGaufreV2
from @gouvfr-lasuite/ui-kit.
Waffle will be fully configurable via the app config,
allowing to be set through environment variables
and api-provided configuration.
2026-01-14 17:26:23 +01:00
Anthony LC
b1231cea7c 💄(frontend) update cunningham theme
Last upgrade of Cunningham had breaking changes
regarding theme tokens. This commit updates the
Cunningham theme to match the latest version.
2026-01-14 12:16:28 +01:00
Anto59290
f9f32db854 (e2e) fix e2e test for other browsers
In this test the comment is made using the "current" browser which can
be Chromium but can also be Firefox or Webkit.
This is why the test failed with other browsers.

Signed-off-by: Anto59290 <antonin59290@hotmail.com>
2026-01-14 10:03:48 +01:00
Manuel Raynaud
0d967aba48 📌(backend) pin celery to version<5.6.0
Since celery version 5.6.0 we have trouble with retrying tasks and it is
impactig the malware_detection workflow. We have to use version 5.5.3
while we found the issue.
2026-01-14 10:01:22 +01:00
Anthony LC
5ec58cef99 🔖(minor) release 4.4.0
Added:
- (backend) add documents/all endpoint with descendants
- (export) add PDF regression tests
- 📝(docs) Add language configuration documentation
- 🔒(helm) Set default security context
- (backend) use langfuse to monitor AI actions

Changed:
- (frontend) improve accessibility:
  - (frontend) make html export accessible to screen reader users
  - (frontend) add missing label and fix Axes errors to improve a11y

Fixed:
- (backend) reduce flakiness on backend test
- 🐛(frontend) fix clickable main content regression
- 🐛(backend) fix TRASHBIN_CUTOFF_DAYS type error
- 💄(frontend) fix icon position in callout block

Security:
- 🔒️(backend) validate more strictly url used by cors-proxy endpoint
- 🔒️(frontend) fix props vulnerability in Interlinking
2026-01-13 14:33:03 +01:00
AntoLC
1170bdbfc1 🌐(i18n) update translated strings
Update translated files with new translations
2026-01-13 14:33:03 +01:00
Anthony LC
e807237dbe 🔒️(frontend) fix props vulnerability in Interlinking
We were not properly sanitizing props passed to the
InterlinkingLinkInlineContent component, which could
lead to XSS attacks. This commit remove most of the
props and only keep the necessary ones.
2026-01-13 13:13:51 +01:00
Anto59290
fa6f3e8b7c 💄(frontend) fix icon position in callout block
Make sure the icon in the callout block is aligned to the top instead of
centered when we have multi-line content.

Signed-off-by: Anto59290 <antonin59290@hotmail.com>
2026-01-12 14:49:50 +01:00
Cyril
b1a18b2477 (frontend) add missing label to improve a11y and pass axe checks
enhances a11y by adding label to fix axe tool errors on missing attributes

Signed-off-by: Cyril <c.gromoff@gmail.com>
2026-01-12 09:06:19 +01:00
Anthony LC
7823303d03 (frontend) improve export regression test pdf
We improved the export regression test PDF to
better cover edge case emoji.
PDF Binary comparison is different depending on the
browser used, we will only run this test on Chromium
to avoid having to maintain multiple sets of PDF
fixtures.
2026-01-09 15:43:06 +01:00
Anthony LC
f84455728b 📌(dependencies) use @gouvfr-lasuite/cunningham-react
In order to work correctly we the ui-kit dependencies,
we need to use the CunninghamProvider from
@gouvfr-lasuite/cunningham-react.
2026-01-09 15:43:06 +01:00
renovate[bot]
5afc825109 ⬆️(dependencies) update js dependencies 2026-01-09 15:43:06 +01:00
Manuel Raynaud
55fe73d001 (backend) use langfuse to monitor AI actions
We want to monitor AI actions. For this we choose to use langfuse. As
this usage is optional, we load langfuse sdk only if settings are
configured. Also, the openai client from langfuse is a dropin
replacement of openai client, so we only have to change how openai is
imported.
2026-01-09 14:38:56 +00:00
Christopher Spelt
39b9c8b5a9 🐛(backend) fix TRASHBIN_CUTOFF_DAYS type error
Fixes `TRASHBIN_CUTOFF_DAYS` type as described in #1777.

Signed-off-by: ChristopherSpelt <christopherspelt@icloud.com>
2026-01-09 14:00:23 +00:00
Cyril
b56ebf19af ️(frontend) make html export accessible to screen reader users
adjusted structure and semantics to ensure proper sr interpretation

Signed-off-by: Cyril <c.gromoff@gmail.com>
2026-01-09 09:08:12 +01:00
Manuel Raynaud
03d4b2afbe ♻️(backend) stop allowing redirect in cors-proxy endpoint
The cors-proxy endpoint was allowing redirect when fetching the target
url. This can be usefull if an image url has changed but also dangerous
if an attacker wants to hide a SSRF behind a redirect.
2026-01-08 15:58:00 +01:00
Manuel Raynaud
2556823a69 ♻️(backend) stop returning a 415 on cors-proxy endpoint
When the content-type return by the targeted url is not an image, the
endpoint was returning a 415 status code. We don't want to provide this
info anymore avoid disclosing information an attacker can use.
2026-01-08 15:58:00 +01:00
Manuel Raynaud
f28da7c2c2 🔒️(backend) validate more strictly url used by cors-proxy endpoint
The cors-proxy endpoint allow to download images host externally without
being blocked by cors headers. The response is filter on the return
content-type to avoid disclosure and the usage of this endpoint as the
proxy used by attacker. We want to restrict the usage of this endpoint
by filtering on non legit ips used. This filter avoid exploitation of
Server Side Request Forgery (SSRF).
2026-01-08 15:58:00 +01:00
Pierre Ozoux
dd2d2862be 🔒(helm) set default security context
In order to be able to deploy this in a restricted k8s cluster, we set
this default security context.

We set it as default because it doesn't change the way the app runs.

So it is better to be more secured by default.

Signed-off-by: Pierre Ozoux Krebber <pierre@ozoux.net>
2026-01-08 14:53:16 +00:00
Manuel Raynaud
c2387fcb02 📌(backend) ping django<6.0.0
We want to wait before migrating to django 6. For now we require all
versions less than version 6 and we add a rule in renovate configuration
2026-01-08 15:01:42 +01:00
Manuel Raynaud
80fdc72182 🔥(backend) remove tests related to django-lasuite
When all the backend authentication has been moved in the django-lasuite
library, we kept the tests to ensure that the mirgration was successful
and we didn't miss something during the transition. Now this tests are
managed in the django-lasuite library and should be maintained in it,
not in docs.
2026-01-08 15:01:42 +01:00
Manuel Raynaud
3636168a77 (backend) fix test related to django-treebeard 4.8.0 upgrade
In one test related to the Document::restore function, one more query is
made. Probably a cache issue fixed in django-treebeard 4.8.0. When
updating the numchild parent, one more query is made to fetch in
database the parent document, this was not made before.
2026-01-08 15:01:42 +01:00
renovate[bot]
1034545b7c ⬆️(dependencies) update python dependencies 2026-01-08 15:01:41 +01:00
Anthony LC
8901c6ee33 📝(docs) Add language configuration documentation
Add comprehensive guide explaining how to override LANGUAGES settings
using the DJANGO_LANGUAGES environment variable. Documentation includes:

- Default language configuration
- Environment variable format and examples
- Configuration for development, production, and Docker Compose
- Complete list of 15 available languages with translation files
- Language code formatting guidelines
- Testing and troubleshooting sections
2026-01-08 12:55:49 +01:00
Antonin
f7d697d9bd (backend) fix flaky test in user search api
Make sure the full is never John for the first user in order to make
sure we always have only 2 users (as the search is performed on both the
email and the full name).
    
Fixes #1765
    
Signed-off-by: Anto59290 <antonin59290@hotmail.com>
2026-01-08 11:50:07 +00:00
Anthony LC
f9c9e444c9 🐛(export) fix heading 4 5 6 in PDF
Heading 4 5 6 in PDF were not correctly
mapped to their corresponding styles in the
exported document.
The new export is now different than the regression
pdf, we need to update the regression PDF to match
the new correct export.
2026-01-08 11:32:58 +01:00
Anthony LC
e1d2d9e5c8 (export) simplify export testing
The regression test is asserting most of the
blocks of the editor, we can remove some redundant
tests.
We improved as well the odt and docx tests by
overriding as well the document content like for pdf,
it will assert more blocks and have less code
to maintain.
2026-01-08 11:32:58 +01:00
Anthony LC
ab92fc43d6 (export) add PDF regression tests
To avoid regression issues in PDF export
functionality, this commit introduces end-to-end
tests that compare exported PDFs against
known good reference files.
We compare the PDF on most of the blocks
that the editor supports.
If during a Blocknote release or pull request
there are intentional changes, the reference
files would need to be updated accordingly.
It can be done by uncommenting the line
in the test that saves the newly generated
PDF to the assets folder.
2026-01-08 11:32:58 +01:00
Anthony LC
3a3ed0453b ️(export) improve svg width when undefined
We improved the svg width calculation when the
width attribute is undefined by trying to use
the style attribute before falling back to a
default value.
2026-01-08 11:32:27 +01:00
Christopher Spelt
43a1a76a2f (backend) add documents/all endpoint with descendants
External dashboards need to find the latest updated documents across
the entire hierarchy. Currently this requires many API calls to
/documents/ and /documents/{id}/children for each level.
   
This endpoint allows retrieving all accessible documents in a single
request, enabling dashboards to efficiently display recently changed
documents regardless of their position in the hierarchy.
    
Signed-off-by: ChristopherSpelt <christopherspelt@icloud.com>
2026-01-08 09:33:55 +00:00
Cyril
62213812ee 🐛(frontend) fix clickable main content regression
removes accidental clickable area introduced by the skip-to-content feature
2026-01-08 09:32:21 +01:00
Anthony LC
3d2b018927 🔖(minor) release 4.3.0
Added:
- (helm) redirecting system
- 📱(frontend) add comments for smaller device
- (project) add custom js support via config

Changed:
- 🥅(frontend) intercept 401 error on GET threads
- 🦺(frontend) check content type pdf on PdfBlock
- ✈️(frontend) pause Posthog when offline

Fixed:
- 🐛(frontend) fix tables deletion
- 🐛(frontend) fix children not display when first resize
2026-01-06 10:29:35 +01:00
Anthony LC
bb0502b49b 🚸(frontend) set cursor after create comments
We have some issues with mobiles and the formatting
toolbar reopening after adding a comment, so we
restore the cursor position.
By restoring the cursor position at the head of
the selection, it will automatically close the
formatting toolbar.
2026-01-06 10:29:34 +01:00
AntoLC
9893558c74 🌐(i18n) update translated strings
Update translated files with new translations
2026-01-05 15:28:31 +01:00
239 changed files with 13349 additions and 9681 deletions

View File

@@ -6,6 +6,7 @@ on:
push:
branches:
- 'main'
- 'refacto/blocknote-ai'
tags:
- 'v*'
pull_request:

View File

@@ -19,6 +19,8 @@ jobs:
test-front:
needs: install-dependencies
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -41,6 +43,8 @@ jobs:
lint-front:
runs-on: ubuntu-latest
needs: install-dependencies
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -193,3 +197,38 @@ jobs:
strip-hash: "[-_.][a-f0-9]{8,}(?=\\.(?:js|css|html)$)"
omit-unchanged: true
install-script: "yarn install --frozen-lockfile"
uikit-theme-checker:
runs-on: ubuntu-latest
needs: install-dependencies
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22.x"
- name: Restore the frontend cache
uses: actions/cache@v4
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
fail-on-cache-miss: true
- name: Build theme
run: cd src/frontend/apps/impress && yarn build-theme
- name: Ensure theme is up to date
shell: bash
run: |
if [[ -n "$(git status --porcelain)" ]]; then
echo "Error: build-theme produced git changes (tracked or untracked)."
echo "--- git status --porcelain ---"
git status --porcelain
echo "--- git diff ---"
git --no-pager diff
exit 1
fi

View File

@@ -6,6 +6,66 @@ and this project adheres to
## [Unreleased]
### Added
- ✨(frontend) integrate configurable Waffle #1795
- ✨ Import of documents #1609
- 🚨(CI) gives warning if theme not updated #1811
- 🔧(project) add DJANGO_EMAIL_URL_APP environment variable #1825
- ✨(frontend) integrate new Blocknote AI feature #1016
### Changed
- ♿(frontend) improve accessibility:
- ♿️(frontend) fix subdoc opening and emoji pick focus #1745
- ✨(backend) add field for button label in email template #1817
### Fixed
- ✅(e2e) fix e2e test for other browsers #1799
- 🐛(export) fix export column NaN #1819
- 🐛(frontend) add fallback for unsupported Blocknote languages #1810
- 🐛(frontend) fix emojipicker closing in tree #1808
- 🐛(frontend) display children in favorite #1782
### Removed
- 🔥(project) remove all code related to template #1780
### Security
- 🔒️(trivy) fix vulnerability about jaraco.context #1806
## [4.4.0] - 2026-01-13
### Added
- ✨(backend) add documents/all endpoint with descendants #1553
- ✅(export) add PDF regression tests #1762
- 📝(docs) Add language configuration documentation #1757
- 🔒(helm) Set default security context #1750
- ✨(backend) use langfuse to monitor AI actions #1776
### Changed
- ♿(frontend) improve accessibility:
- ♿(frontend) make html export accessible to screen reader users #1743
- ♿(frontend) add missing label and fix Axes errors to improve a11y #1693
### Fixed
- ✅(backend) reduce flakiness on backend test #1769
- 🐛(frontend) fix clickable main content regression #1773
- 🐛(backend) fix TRASHBIN_CUTOFF_DAYS type error #1778
- 💄(frontend) fix icon position in callout block #1779
### Security
- 🔒️(backend) validate more strictly url used by cors-proxy endpoint #1768
- 🔒️(frontend) fix props vulnerability in Interlinking #1792
## [4.3.0] - 2026-01-05
### Added
- ✨(helm) redirecting system #1697
@@ -17,14 +77,12 @@ and this project adheres to
- 🥅(frontend) intercept 401 error on GET threads #1754
- 🦺(frontend) check content type pdf on PdfBlock #1756
- ✈️(frontend) pause Posthog when offline #1755
- 📱(frontend) toolbar to the bottom when mobile #1774
### Fixed
- 🐛(frontend) fix tables deletion #1752
- 🐛(frontend) fix tables deletion #1739
- 🐛(frontend) fix children not display when first resize #1753
## [4.2.0] - 2025-12-17
### Added
@@ -49,7 +107,6 @@ 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
### Added
@@ -963,7 +1020,9 @@ and this project adheres to
- ✨(frontend) Coming Soon page (#67)
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/suitenumerique/docs/compare/v4.2.0...main
[unreleased]: https://github.com/suitenumerique/docs/compare/v4.4.0...main
[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
[v4.1.0]: https://github.com/suitenumerique/docs/releases/v4.1.0
[v4.0.0]: https://github.com/suitenumerique/docs/releases/v4.0.0

View File

@@ -4,7 +4,7 @@
FROM python:3.13.3-alpine AS base
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip setuptools
RUN python -m pip install --upgrade pip
# Upgrade system packages to install security updates
RUN apk update && apk upgrade --no-cache

View File

@@ -213,6 +213,7 @@ logs: ## display app-dev logs (follow mode)
.PHONY: logs
run-backend: ## Start only the backend application and all needed services
@$(COMPOSE) up --force-recreate -d docspec
@$(COMPOSE) up --force-recreate -d celery-dev
@$(COMPOSE) up --force-recreate -d y-provider-development
@$(COMPOSE) up --force-recreate -d nginx

View File

@@ -8,6 +8,7 @@ docker_build(
dockerfile='../Dockerfile',
only=['./src/backend', './src/mail', './docker'],
target = 'backend-production',
build_args={'DOCKER_USER': '1000:1000'},
live_update=[
sync('../src/backend', '/app'),
run(
@@ -23,6 +24,7 @@ docker_build(
dockerfile='../src/frontend/servers/y-provider/Dockerfile',
only=['./src/frontend/', './docker/', './.dockerignore'],
target = 'y-provider',
build_args={'DOCKER_USER': '1000:1000'},
live_update=[
sync('../src/frontend/servers/y-provider/src', '/home/frontend/servers/y-provider/src'),
]
@@ -34,6 +36,7 @@ docker_build(
dockerfile='../src/frontend/Dockerfile',
only=['./src/frontend', './docker', './.dockerignore'],
target = 'impress',
build_args={'DOCKER_USER': '1000:1000'},
live_update=[
sync('../src/frontend', '/home/frontend'),
]

View File

@@ -231,6 +231,11 @@ services:
condition: service_healthy
restart: true
docspec:
image: ghcr.io/docspecio/api:2.6.3
ports:
- "4000:4000"
networks:
lasuite:
name: lasuite-network

BIN
docs/assets/waffle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -1,4 +1,6 @@
# Runtime Theming 🎨
# Customization Guide 🛠
## Runtime Theming 🎨
### How to Use
@@ -32,7 +34,7 @@ Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom
----
# Runtime JavaScript Injection 🚀
## Runtime JavaScript Injection 🚀
### How to Use
@@ -87,7 +89,7 @@ Then, set the `FRONTEND_JS_URL` environment variable to the URL of your custom J
----
# **Your Docs icon** 📝
## **Your Docs icon** 📝
You can add your own Docs icon in the header from the theme customization file.
@@ -105,7 +107,7 @@ This configuration is optional. If not set, the default icon will be used.
----
# **Footer Configuration** 📝
## **Footer Configuration** 📝
The footer is configurable from the theme customization file.
@@ -128,7 +130,7 @@ Below is a visual example of a configured footer ⬇️:
----
# **Custom Translations** 📝
## **Custom Translations** 📝
The translations can be partially overridden from the theme customization file.
@@ -140,4 +142,36 @@ THEME_CUSTOMIZATION_FILE_PATH=<path>
### Example of JSON
The json must follow some rules: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
The json must follow some rules: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
----
## **Waffle Configuration** 🧇
The Waffle (La Gaufre) is a widget that displays a grid of services.
![Waffle Configuration Example](./assets/waffle.png)
### Settings 🔧
```shellscript
THEME_CUSTOMIZATION_FILE_PATH=<path>
```
### Configuration
The Waffle can be configured in the theme customization file with the `waffle` key.
### Available Properties
See: [LaGaufreV2Props](https://github.com/suitenumerique/ui-kit/blob/main/src/components/la-gaufre/LaGaufreV2.tsx#L49)
### Complete Example
From the theme customization file: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
### Behavior
- If `data.services` is provided, the Waffle will display those services statically
- If no data is provided, services can be fetched dynamically from an API endpoint thanks to the `apiUrl` property

View File

@@ -11,6 +11,7 @@ These are the environment variables you can set for the `impress-backend` contai
| 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_MODEL | AI Model to use | |
| ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true |
@@ -32,6 +33,8 @@ These are the environment variables you can set for the `impress-backend` contai
| CONVERSION_API_ENDPOINT | Conversion API endpoint | convert |
| 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"]
| 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 |
@@ -54,16 +57,21 @@ 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_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 | |
| DOCUMENT_IMAGE_MAX_SIZE | Maximum size of document in bytes | 10485760 |
| FRONTEND_CSS_URL | To add a external css file to the app | |
| FRONTEND_JS_URL | To add a external js file to the app | |
| FRONTEND_HOMEPAGE_FEATURE_ENABLED | Frontend feature flag to display the homepage | false |
| FRONTEND_THEME | Frontend theme to use | |
| LANGUAGE_CODE | Default language | en-us |
| 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_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 |

View File

@@ -27,6 +27,7 @@ backend:
DJANGO_EMAIL_HOST: "mailcatcher"
DJANGO_EMAIL_LOGO_IMG: https://docs.127.0.0.1.nip.io/assets/logo-suite-numerique.png
DJANGO_EMAIL_PORT: 1025
DJANGO_EMAIL_URL_APP: https://docs.127.0.0.1.nip.io
DJANGO_EMAIL_USE_SSL: False
LOGGING_LEVEL_HANDLERS_CONSOLE: ERROR
LOGGING_LEVEL_LOGGERS_ROOT: INFO

View File

@@ -127,6 +127,7 @@ DJANGO_EMAIL_FROM=<your email address>
DJANGO_EMAIL_BRAND_NAME=<brand name used in email templates> # e.g. "La Suite Numérique"
DJANGO_EMAIL_LOGO_IMG=<logo image to use in email templates.> # e.g. "https://docs.yourdomain.tld/assets/logo-suite-numerique.png"
DJANGO_EMAIL_URL_APP=<url used in email templates to go to the app> # e.g. "https://docs.yourdomain.tld"
```
### AI

View File

@@ -0,0 +1,180 @@
# Language Configuration (2025-12)
This document explains how to configure and override the available languages in the Docs application.
## Default Languages
By default, the application supports the following languages (in priority order):
- English (en-us)
- French (fr-fr)
- German (de-de)
- Dutch (nl-nl)
- Spanish (es-es)
The default configuration is defined in `src/backend/impress/settings.py`:
```python
LANGUAGES = values.SingleNestedTupleValue(
(
("en-us", "English"),
("fr-fr", "Français"),
("de-de", "Deutsch"),
("nl-nl", "Nederlands"),
("es-es", "Español"),
)
)
```
## Overriding Languages
### Using Environment Variables
You can override the available languages by setting the `DJANGO_LANGUAGES` environment variable. This is the recommended approach for customizing language support without modifying the source code.
#### Format
The `DJANGO_LANGUAGES` variable expects a semicolon-separated list of language configurations, where each language is defined as `code,Display Name`:
```
DJANGO_LANGUAGES=code1,Name1;code2,Name2;code3,Name3
```
#### Example Configurations
**Example 1: English and French only**
```bash
DJANGO_LANGUAGES=en-us,English;fr-fr,Français
```
**Example 2: Add Italian and Chinese**
```bash
DJANGO_LANGUAGES=en-us,English;fr-fr,Français;de-de,Deutsch;it-it,Italiano;zh-cn,中文
```
**Example 3: Custom subset of languages**
```bash
DJANGO_LANGUAGES=fr-fr,Français;de-de,Deutsch;es-es,Español
```
### Configuration Files
#### Development Environment
For local development, you can set the `DJANGO_LANGUAGES` variable in your environment configuration file:
**File:** `env.d/development/common.local`
```bash
DJANGO_LANGUAGES=en-us,English;fr-fr,Français;de-de,Deutsch;it-it,Italiano;zh-cn,中文;
```
#### Production Environment
For production deployments, add the variable to your production environment configuration:
**File:** `env.d/production.dist/common`
```bash
DJANGO_LANGUAGES=en-us,English;fr-fr,Français
```
#### Docker Compose
When using Docker Compose, you can set the environment variable in your `compose.yml` or `compose.override.yml` file:
```yaml
services:
app:
environment:
- DJANGO_LANGUAGES=en-us,English;fr-fr,Français;de-de,Deutsch
```
## Important Considerations
### Language Codes
- Use standard language codes (ISO 639-1 with optional region codes)
- Format: `language-region` (e.g., `en-us`, `fr-fr`, `de-de`)
- Use lowercase for language codes and region identifiers
### Priority Order
Languages are listed in priority order. The first language in the list is used as the fallback language throughout the application when a specific translation is not available.
### Translation Availability
Before adding a new language, ensure that:
1. Translation files exist for that language in the `src/backend/locale/` directory
2. The frontend application has corresponding translation files
3. All required messages have been translated
#### Available Languages
The following languages have translation files available in `src/backend/locale/`:
- `br_FR` - Breton (France)
- `cn_CN` - Chinese (China) - *Note: Use `zh-cn` in DJANGO_LANGUAGES*
- `de_DE` - German (Germany) - Use `de-de`
- `en_US` - English (United States) - Use `en-us`
- `es_ES` - Spanish (Spain) - Use `es-es`
- `fr_FR` - French (France) - Use `fr-fr`
- `it_IT` - Italian (Italy) - Use `it-it`
- `nl_NL` - Dutch (Netherlands) - Use `nl-nl`
- `pt_PT` - Portuguese (Portugal) - Use `pt-pt`
- `ru_RU` - Russian (Russia) - Use `ru-ru`
- `sl_SI` - Slovenian (Slovenia) - Use `sl-si`
- `sv_SE` - Swedish (Sweden) - Use `sv-se`
- `tr_TR` - Turkish (Turkey) - Use `tr-tr`
- `uk_UA` - Ukrainian (Ukraine) - Use `uk-ua`
- `zh_CN` - Chinese (China) - Use `zh-cn`
**Note:** When configuring `DJANGO_LANGUAGES`, use lowercase with hyphens (e.g., `pt-pt`, `ru-ru`) rather than the directory name format.
### Translation Management
We use [Crowdin](https://crowdin.com/) to manage translations for the Docs application. Crowdin allows our community to contribute translations and helps maintain consistency across all supported languages.
**Want to add a new language or improve existing translations?**
If you would like us to support a new language or want to contribute to translations, please get in touch with the project maintainers. We can add new languages to our Crowdin project and coordinate translation efforts with the community.
### Cookie and Session
The application stores the user's language preference in a cookie named `docs_language`. The cookie path is set to `/` by default.
## Testing Language Configuration
After changing the language configuration:
1. Restart the application services
2. Verify the language selector displays the correct languages
3. Test switching between different languages
4. Confirm that content is displayed in the selected language
## Troubleshooting
### Languages not appearing
- Verify the environment variable is correctly formatted (semicolon-separated, comma between code and name)
- Check that there are no trailing spaces in language codes or names
- Ensure the application was restarted after changing the configuration
### Missing translations
If you add a new language but see untranslated text:
1. Check if translation files exist in `src/backend/locale/<language_code>/LC_MESSAGES/`
2. Run Django's `makemessages` and `compilemessages` commands to generate/update translations
3. Verify frontend translation files are available
## Related Configuration
- `LANGUAGE_CODE`: Default language code (default: `en-us`)
- `LANGUAGE_COOKIE_NAME`: Cookie name for storing user language preference (default: `docs_language`)
- `LANGUAGE_COOKIE_PATH`: Cookie path (default: `/`)

View File

@@ -20,6 +20,7 @@ DJANGO_EMAIL_BRAND_NAME="La Suite Numérique"
DJANGO_EMAIL_HOST="mailcatcher"
DJANGO_EMAIL_LOGO_IMG="http://localhost:3000/assets/logo-suite-numerique.png"
DJANGO_EMAIL_PORT=1025
DJANGO_EMAIL_URL_APP="http://localhost:3000"
# Backend url
IMPRESS_BASE_URL="http://localhost:8072"
@@ -76,6 +77,8 @@ DJANGO_SERVER_TO_SERVER_API_TOKENS=server-api-token
Y_PROVIDER_API_BASE_URL=http://y-provider-development:4444/api/
Y_PROVIDER_API_KEY=yprovider-api-key
DOCSPEC_API_URL=http://docspec:4000/conversion
# Theme customization
THEME_CUSTOMIZATION_CACHE_TIMEOUT=15

View File

@@ -6,4 +6,4 @@ Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/
# Throttle
API_DOCUMENT_THROTTLE_RATE=1000/min
API_CONFIG_THROTTLE_RATE=1000/min
API_CONFIG_THROTTLE_RATE=1000/min

View File

@@ -24,7 +24,8 @@ DJANGO_EMAIL_FROM=<your email address>
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
DJANGO_EMAIL_BRAND_NAME="La Suite Numérique"
DJANGO_EMAIL_LOGO_IMG="https://${DOCS_HOST}/assets/logo-suite-numerique.png"
DJANGO_EMAIL_LOGO_IMG="https://${DOCS_HOST}/assets/logo-suite-numerique.png"
DJANGO_EMAIL_URL_APP="https://${DOCS_HOST}"
# Media
AWS_S3_ENDPOINT_URL=https://${S3_HOST}

View File

@@ -25,6 +25,19 @@
"matchPackageNames": ["pylint"],
"allowedVersions": "<4.0.0"
},
{
"groupName": "allowed django versions",
"matchManagers": ["pep621"],
"matchPackageNames": ["django"],
"allowedVersions": "<6.0.0"
},
{
"groupName": "allowed celery versions",
"matchManagers": ["pep621"],
"matchPackageNames": ["celery"],
"allowedVersions": "<5.6.0"
},
{
"enabled": false,
"groupName": "ignored js dependencies",

View File

@@ -9,14 +9,6 @@ from treebeard.admin import TreeAdmin
from . import models
class TemplateAccessInline(admin.TabularInline):
"""Inline admin class for template accesses."""
autocomplete_fields = ["user"]
model = models.TemplateAccess
extra = 0
@admin.register(models.User)
class UserAdmin(auth_admin.UserAdmin):
"""Admin class for the User model"""
@@ -69,7 +61,6 @@ class UserAdmin(auth_admin.UserAdmin):
},
),
)
inlines = (TemplateAccessInline,)
list_display = (
"id",
"sub",
@@ -104,15 +95,8 @@ class UserAdmin(auth_admin.UserAdmin):
search_fields = ("id", "sub", "admin_email", "email", "full_name")
@admin.register(models.Template)
class TemplateAdmin(admin.ModelAdmin):
"""Template admin interface declaration."""
inlines = (TemplateAccessInline,)
class DocumentAccessInline(admin.TabularInline):
"""Inline admin class for template accesses."""
"""Inline admin class for document accesses."""
autocomplete_fields = ["user"]
model = models.DocumentAccess

View File

@@ -98,10 +98,10 @@ class CanCreateInvitationPermission(permissions.BasePermission):
class ResourceWithAccessPermission(permissions.BasePermission):
"""A permission class for templates and invitations."""
"""A permission class for invitations."""
def has_permission(self, request, view):
"""check create permission for templates."""
"""check create permission."""
return request.user.is_authenticated or view.action != "create"
def has_object_permission(self, request, view, obj):

View File

@@ -4,6 +4,7 @@
import binascii
import mimetypes
from base64 import b64decode
from os.path import splitext
from django.conf import settings
from django.db.models import Q
@@ -15,10 +16,10 @@ import magic
from rest_framework import serializers
from core import choices, enums, models, utils, validators
from core.services.ai_services import AI_ACTIONS
from core.services import mime_types
from core.services.converter_services import (
ConversionError,
YdocConverter,
Converter,
)
@@ -59,30 +60,6 @@ class UserLightSerializer(UserSerializer):
read_only_fields = ["full_name", "short_name"]
class TemplateAccessSerializer(serializers.ModelSerializer):
"""Serialize template accesses."""
abilities = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.TemplateAccess
resource_field_name = "template"
fields = ["id", "user", "team", "role", "abilities"]
read_only_fields = ["id", "abilities"]
def get_abilities(self, instance) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
return instance.get_abilities(request.user)
return {}
def update(self, instance, validated_data):
"""Make "user" field is readonly but only on update."""
validated_data.pop("user", None)
return super().update(instance, validated_data)
class ListDocumentSerializer(serializers.ModelSerializer):
"""Serialize documents with limited fields for display in lists."""
@@ -188,6 +165,9 @@ class DocumentSerializer(ListDocumentSerializer):
content = serializers.CharField(required=False)
websocket = serializers.BooleanField(required=False, write_only=True)
file = serializers.FileField(
required=False, write_only=True, allow_null=True, max_length=255
)
class Meta:
model = models.Document
@@ -204,6 +184,7 @@ class DocumentSerializer(ListDocumentSerializer):
"deleted_at",
"depth",
"excerpt",
"file",
"is_favorite",
"link_role",
"link_reach",
@@ -273,6 +254,30 @@ class DocumentSerializer(ListDocumentSerializer):
return value
def validate_file(self, file):
"""Add file size and type constraints as defined in settings."""
if not file:
return None
# Validate file size
if file.size > settings.CONVERSION_FILE_MAX_SIZE:
max_size = settings.CONVERSION_FILE_MAX_SIZE // (1024 * 1024)
raise serializers.ValidationError(
f"File size exceeds the maximum limit of {max_size:d} MB."
)
_name, extension = splitext(file.name)
if extension.lower() not in settings.CONVERSION_FILE_EXTENSIONS_ALLOWED:
raise serializers.ValidationError(
(
f"File extension {extension} is not allowed. Allowed extensions"
f" are: {settings.CONVERSION_FILE_EXTENSIONS_ALLOWED}."
)
)
return file
def save(self, **kwargs):
"""
Process the content field to extract attachment keys and update the document's
@@ -461,7 +466,9 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
language = user.language or language
try:
document_content = YdocConverter().convert(validated_data["content"])
document_content = Converter().convert(
validated_data["content"], mime_types.MARKDOWN, mime_types.YJS
)
except ConversionError as err:
raise serializers.ValidationError(
{"content": ["Could not convert content"]}
@@ -660,52 +667,6 @@ class FileUploadSerializer(serializers.Serializer):
return attrs
class TemplateSerializer(serializers.ModelSerializer):
"""Serialize templates."""
abilities = serializers.SerializerMethodField(read_only=True)
accesses = TemplateAccessSerializer(many=True, read_only=True)
class Meta:
model = models.Template
fields = [
"id",
"title",
"accesses",
"abilities",
"css",
"code",
"is_public",
]
read_only_fields = ["id", "accesses", "abilities"]
def get_abilities(self, document) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
return document.get_abilities(request.user)
return {}
# pylint: disable=abstract-method
class DocumentGenerationSerializer(serializers.Serializer):
"""Serializer to receive a request to generate a document on a template."""
body = serializers.CharField(label=_("Body"))
body_type = serializers.ChoiceField(
choices=["html", "markdown"],
label=_("Body type"),
required=False,
default="html",
)
format = serializers.ChoiceField(
choices=["pdf", "docx"],
label=_("Format"),
required=False,
default="pdf",
)
class InvitationSerializer(serializers.ModelSerializer):
"""Serialize invitations."""
@@ -830,33 +791,38 @@ class VersionFilterSerializer(serializers.Serializer):
)
class AITransformSerializer(serializers.Serializer):
"""Serializer for AI transform requests."""
class AIProxySerializer(serializers.Serializer):
"""Serializer for AI proxy requests."""
action = serializers.ChoiceField(choices=AI_ACTIONS, required=True)
text = serializers.CharField(required=True)
def validate_text(self, value):
"""Ensure the text field is not empty."""
if len(value.strip()) == 0:
raise serializers.ValidationError("Text field cannot be empty.")
return value
class AITranslateSerializer(serializers.Serializer):
"""Serializer for AI translate requests."""
language = serializers.ChoiceField(
choices=tuple(enums.ALL_LANGUAGES.items()), required=True
messages = serializers.ListField(
required=True,
child=serializers.DictField(
child=serializers.CharField(required=True),
),
allow_empty=False,
)
text = serializers.CharField(required=True)
model = serializers.CharField(required=True)
def validate_text(self, value):
"""Ensure the text field is not empty."""
def validate_messages(self, messages):
"""Validate messages structure."""
# Ensure each message has the required fields
for message in messages:
if (
not isinstance(message, dict)
or "role" not in message
or "content" not in message
):
raise serializers.ValidationError(
"Each message must have 'role' and 'content' fields"
)
return messages
def validate_model(self, value):
"""Validate model value is the same than settings.AI_MODEL"""
if value != settings.AI_MODEL:
raise serializers.ValidationError(f"{value} is not a valid model")
if len(value.strip()) == 0:
raise serializers.ValidationError("Text field cannot be empty.")
return value

View File

@@ -3,8 +3,10 @@
# pylint: disable=too-many-lines
import base64
import ipaddress
import json
import logging
import socket
import uuid
from collections import defaultdict
from urllib.parse import unquote, urlencode, urlparse
@@ -41,17 +43,19 @@ from rest_framework.permissions import AllowAny
from core import authentication, choices, enums, models
from core.api.filters import remove_accents
from core.services import mime_types
from core.services.ai_services import AIService
from core.services.collaboration_services import CollaborationService
from core.services.converter_services import (
ConversionError,
Converter,
)
from core.services.converter_services import (
ServiceUnavailableError as YProviderServiceUnavailableError,
)
from core.services.converter_services import (
ValidationError as YProviderValidationError,
)
from core.services.converter_services import (
YdocConverter,
)
from core.services.search_indexers import (
get_document_indexer,
get_visited_document_ids_of,
@@ -334,21 +338,8 @@ class DocumentViewSet(
9. **Media Auth**: Authorize access to document media.
Example: GET /documents/media-auth/
10. **AI Transform**: Apply a transformation action on a piece of text with AI.
Example: POST /documents/{id}/ai-transform/
Expected data:
- text (str): The input text.
- action (str): The transformation type, one of [prompt, correct, rephrase, summarize].
Returns: JSON response with the processed text.
Throttled by: AIDocumentRateThrottle, AIUserRateThrottle.
11. **AI Translate**: Translate a piece of text with AI.
Example: POST /documents/{id}/ai-translate/
Expected data:
- text (str): The input text.
- language (str): The target language, chosen from settings.LANGUAGES.
Returns: JSON response with the translated text.
Throttled by: AIDocumentRateThrottle, AIUserRateThrottle.
10. **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
@@ -387,7 +378,7 @@ class DocumentViewSet(
throttle_scope = "document"
queryset = models.Document.objects.select_related("creator").all()
serializer_class = serializers.DocumentSerializer
ai_translate_serializer_class = serializers.AITranslateSerializer
all_serializer_class = serializers.ListDocumentSerializer
children_serializer_class = serializers.ListDocumentSerializer
descendants_serializer_class = serializers.ListDocumentSerializer
list_serializer_class = serializers.ListDocumentSerializer
@@ -524,6 +515,28 @@ class DocumentViewSet(
"IN SHARE ROW EXCLUSIVE MODE;"
)
# Remove file from validated_data as it's not a model field
# Process it if present
uploaded_file = serializer.validated_data.pop("file", None)
# If a file is uploaded, convert it to Yjs format and set as content
if uploaded_file:
try:
file_content = uploaded_file.read()
converter = Converter()
converted_content = converter.convert(
file_content,
content_type=uploaded_file.content_type,
accept=mime_types.YJS,
)
serializer.validated_data["content"] = converted_content
serializer.validated_data["title"] = uploaded_file.name
except ConversionError as err:
raise drf.exceptions.ValidationError(
{"file": ["Could not convert file content"]}
) from err
obj = models.Document.add_root(
creator=self.request.user,
**serializer.validated_data,
@@ -625,12 +638,29 @@ class DocumentViewSet(
"""Get list of favorite documents for the current user."""
user = request.user
queryset = self.get_queryset()
# Among the results, we may have documents that are ancestors/descendants
# of each other. In this case we want to keep only the highest ancestors.
root_paths = utils.filter_root_paths(
queryset.order_by("path").values_list("path", flat=True),
skip_sorting=True,
)
path_list = db.Q()
for path in root_paths:
path_list |= db.Q(path__startswith=path)
favorite_documents_ids = models.DocumentFavorite.objects.filter(
user=user
).values_list("document_id", flat=True)
queryset = self.filter_queryset(self.get_queryset())
queryset = self.queryset.filter(path_list)
queryset = queryset.filter(id__in=favorite_documents_ids)
queryset = queryset.annotate_user_roles(user)
queryset = queryset.annotate(
is_favorite=db.Value(True, output_field=db.BooleanField())
)
return self.get_response_for_queryset(queryset)
@drf.decorators.action(
@@ -858,6 +888,60 @@ class DocumentViewSet(
},
)
@drf.decorators.action(
detail=False,
methods=["get"],
)
def all(self, request, *args, **kwargs):
"""
Returns all documents (including descendants) that the user has access to.
Unlike the list endpoint which only returns top-level documents, this endpoint
returns all documents including children, grandchildren, etc.
"""
user = self.request.user
accessible_documents = self.get_queryset()
accessible_paths = list(accessible_documents.values_list("path", flat=True))
if not accessible_paths:
return self.get_response_for_queryset(self.queryset.none())
# Build query to include all descendants using path prefix matching
descendants_clause = db.Q()
for path in accessible_paths:
descendants_clause |= db.Q(path__startswith=path)
queryset = self.queryset.filter(
descendants_clause, ancestors_deleted_at__isnull=True
)
# Apply existing filters
filterset = ListDocumentFilter(
self.request.GET, queryset=queryset, request=self.request
)
if not filterset.is_valid():
raise drf.exceptions.ValidationError(filterset.errors)
filter_data = filterset.form.cleaned_data
# Filter as early as possible on fields that are available on the model
for field in ["is_creator_me", "title"]:
queryset = filterset.filters[field].filter(queryset, filter_data[field])
queryset = queryset.annotate_user_roles(user)
# Annotate favorite status and filter if applicable as late as possible
queryset = queryset.annotate_is_favorite(user)
for field in ["is_favorite", "is_masked"]:
queryset = filterset.filters[field].filter(queryset, filter_data[field])
# Apply ordering only now that everything is filtered and annotated
queryset = filters.OrderingFilter().filter_queryset(
self.request, queryset, self
)
return self.get_response_for_queryset(queryset)
@drf.decorators.action(
detail=True,
methods=["get"],
@@ -1547,58 +1631,137 @@ class DocumentViewSet(
@drf.decorators.action(
detail=True,
methods=["post"],
name="Apply a transformation action on a piece of text with AI",
url_path="ai-transform",
throttle_classes=[utils.AIDocumentRateThrottle, utils.AIUserRateThrottle],
name="Proxy AI requests to the AI provider",
url_path="ai-proxy",
# throttle_classes=[utils.AIDocumentRateThrottle, utils.AIUserRateThrottle],
)
def ai_transform(self, request, *args, **kwargs):
def ai_proxy(self, request, *args, **kwargs):
"""
POST /api/v1.0/documents/<resource_id>/ai-transform
with expected data:
- text: str
- action: str [prompt, correct, rephrase, summarize]
Return JSON response with the processed text.
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()
serializer = serializers.AITransformSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
if not settings.AI_FEATURE_ENABLED:
raise ValidationError("AI feature is not enabled.")
text = serializer.validated_data["text"]
action = serializer.validated_data["action"]
ai_service = AIService()
response = AIService().transform(text, action)
if settings.AI_STREAM:
stream_gen = ai_service.stream_proxy(
url=settings.AI_BASE_URL.rstrip("/") + "/chat/completions",
method="POST",
headers={"Content-Type": "application/json"},
body=json.dumps(request.data, ensure_ascii=False).encode("utf-8"),
)
return drf.response.Response(response, status=drf.status.HTTP_200_OK)
resp = StreamingHttpResponse(
streaming_content=stream_gen,
content_type="text/event-stream",
status=200,
)
resp["X-Accel-Buffering"] = "no"
resp["Cache-Control"] = "no-cache"
return resp
@drf.decorators.action(
detail=True,
methods=["post"],
name="Translate a piece of text with AI",
url_path="ai-translate",
throttle_classes=[utils.AIDocumentRateThrottle, utils.AIUserRateThrottle],
)
def ai_translate(self, request, *args, **kwargs):
def _reject_invalid_ips(self, ips):
"""
POST /api/v1.0/documents/<resource_id>/ai-translate
with expected data:
- text: str
- language: str [settings.LANGUAGES]
Return JSON response with the translated text.
Check if an IP address is safe from SSRF attacks.
Raises:
drf.exceptions.ValidationError: If the IP is unsafe
"""
# Check permissions first
self.get_object()
for ip in ips:
# Block loopback addresses (check before private,
# as 127.0.0.1 might be considered private)
if ip.is_loopback:
raise drf.exceptions.ValidationError(
"Access to loopback addresses is not allowed"
)
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
# Block link-local addresses (169.254.0.0/16) - check before private
if ip.is_link_local:
raise drf.exceptions.ValidationError(
"Access to link-local addresses is not allowed"
)
text = serializer.validated_data["text"]
language = serializer.validated_data["language"]
# Block private IP ranges
if ip.is_private:
raise drf.exceptions.ValidationError(
"Access to private IP addresses is not allowed"
)
response = AIService().translate(text, language)
# Block multicast addresses
if ip.is_multicast:
raise drf.exceptions.ValidationError(
"Access to multicast addresses is not allowed"
)
return drf.response.Response(response, status=drf.status.HTTP_200_OK)
# Block reserved addresses (including 0.0.0.0)
if ip.is_reserved:
raise drf.exceptions.ValidationError(
"Access to reserved IP addresses is not allowed"
)
def _validate_url_against_ssrf(self, url):
"""
Validate that a URL is safe from SSRF (Server-Side Request Forgery) attacks.
Blocks:
- localhost and its variations
- Private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
- Link-local addresses (169.254.0.0/16)
- Loopback addresses
Raises:
drf.exceptions.ValidationError: If the URL is unsafe
"""
parsed = urlparse(url)
hostname = parsed.hostname
if not hostname:
raise drf.exceptions.ValidationError("Invalid hostname")
# Resolve hostname to IP address(es)
# Check all resolved IPs to prevent DNS rebinding attacks
try:
# Try to parse as IP address first (if hostname is already an IP)
try:
ip = ipaddress.ip_address(hostname)
resolved_ips = [ip]
except ValueError:
# Resolve hostname to IP addresses (supports both IPv4 and IPv6)
resolved_ips = []
try:
# Get all address info (IPv4 and IPv6)
addr_info = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC)
for family, _, _, _, sockaddr in addr_info:
if family == socket.AF_INET:
# IPv4
ip = ipaddress.ip_address(sockaddr[0])
resolved_ips.append(ip)
elif family == socket.AF_INET6:
# IPv6
ip = ipaddress.ip_address(sockaddr[0])
resolved_ips.append(ip)
except (socket.gaierror, OSError) as e:
raise drf.exceptions.ValidationError(
f"Failed to resolve hostname: {str(e)}"
) from e
if not resolved_ips:
raise drf.exceptions.ValidationError(
"No IP addresses found for hostname"
) from None
except ValueError as e:
raise drf.exceptions.ValidationError(f"Invalid IP address: {str(e)}") from e
# Check all resolved IPs to ensure none are private/internal
self._reject_invalid_ips(resolved_ips)
@drf.decorators.action(
detail=True,
@@ -1633,6 +1796,16 @@ class DocumentViewSet(
status=drf.status.HTTP_400_BAD_REQUEST,
)
# Validate URL against SSRF attacks
try:
self._validate_url_against_ssrf(url)
except drf.exceptions.ValidationError as e:
logger.error("Potential SSRF attack detected: %s", e)
return drf.response.Response(
{"detail": "Invalid URL used."},
status=drf.status.HTTP_400_BAD_REQUEST,
)
try:
response = requests.get(
url,
@@ -1641,13 +1814,15 @@ class DocumentViewSet(
"User-Agent": request.headers.get("User-Agent", ""),
"Accept": request.headers.get("Accept", ""),
},
allow_redirects=False,
timeout=10,
)
response.raise_for_status()
content_type = response.headers.get("Content-Type", "")
if not content_type.startswith("image/"):
return drf.response.Response(
status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE
{"detail": "Invalid URL used."}, status=status.HTTP_400_BAD_REQUEST
)
# Use StreamingHttpResponse with the response's iter_content to properly stream the data
@@ -1665,7 +1840,7 @@ class DocumentViewSet(
except requests.RequestException as e:
logger.exception(e)
return drf.response.Response(
{"error": f"Failed to fetch resource from {url}"},
{"detail": "Invalid URL used."},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -1700,14 +1875,14 @@ class DocumentViewSet(
if base64_content is not None:
# Convert using the y-provider service
try:
yprovider = YdocConverter()
yprovider = Converter()
result = yprovider.convert(
base64.b64decode(base64_content),
"application/vnd.yjs.doc",
mime_types.YJS,
{
"markdown": "text/markdown",
"html": "text/html",
"json": "application/json",
"markdown": mime_types.MARKDOWN,
"html": mime_types.HTML,
"json": mime_types.JSON,
}[content_format],
)
content = result
@@ -1928,64 +2103,6 @@ class DocumentAccessViewSet(
)
class TemplateViewSet(
drf.mixins.RetrieveModelMixin,
viewsets.GenericViewSet,
):
"""Template ViewSet"""
filter_backends = [drf.filters.OrderingFilter]
permission_classes = [
permissions.IsAuthenticatedOrSafe,
permissions.ResourceWithAccessPermission,
]
throttle_scope = "template"
ordering = ["-created_at"]
ordering_fields = ["created_at", "updated_at", "title"]
serializer_class = serializers.TemplateSerializer
queryset = models.Template.objects.all()
def get_queryset(self):
"""Custom queryset to get user related templates."""
queryset = super().get_queryset()
user = self.request.user
if not user.is_authenticated:
return queryset
user_roles_query = (
models.TemplateAccess.objects.filter(
db.Q(user=user) | db.Q(team__in=user.teams),
template_id=db.OuterRef("pk"),
)
.values("template")
.annotate(roles_array=ArrayAgg("role"))
.values("roles_array")
)
return queryset.annotate(user_roles=db.Subquery(user_roles_query)).distinct()
def list(self, request, *args, **kwargs):
"""Restrict templates returned by the list endpoint"""
queryset = self.filter_queryset(self.get_queryset())
user = self.request.user
if user.is_authenticated:
queryset = queryset.filter(
db.Q(accesses__user=user)
| db.Q(accesses__team__in=user.teams)
| db.Q(is_public=True)
)
else:
queryset = queryset.filter(is_public=True)
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return drf.response.Response(serializer.data)
class InvitationViewset(
drf.mixins.CreateModelMixin,
drf.mixins.ListModelMixin,
@@ -2190,9 +2307,14 @@ class ConfigView(drf.views.APIView):
Return a dictionary of public settings.
"""
array_settings = [
"AI_BOT",
"AI_FEATURE_ENABLED",
"AI_MODEL",
"AI_STREAM",
"COLLABORATION_WS_URL",
"COLLABORATION_WS_NOT_CONNECTED_READY_ONLY",
"CONVERSION_FILE_EXTENSIONS_ALLOWED",
"CONVERSION_FILE_MAX_SIZE",
"CRISP_WEBSITE_ID",
"ENVIRONMENT",
"FRONTEND_CSS_URL",

View File

@@ -53,15 +53,6 @@ class UserFactory(factory.django.DjangoModelFactory):
if create and (extracted is True):
UserDocumentAccessFactory(user=self, role="owner")
@factory.post_generation
def with_owned_template(self, create, extracted, **kwargs):
"""
Create a template for which the user is owner to check
that there is no interference
"""
if create and (extracted is True):
UserTemplateAccessFactory(user=self, role="owner")
class ParentNodeFactory(factory.declarations.ParameteredAttribute):
"""Custom factory attribute for setting the parent node."""
@@ -202,50 +193,6 @@ class DocumentAskForAccessFactory(factory.django.DjangoModelFactory):
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
class TemplateFactory(factory.django.DjangoModelFactory):
"""A factory to create templates"""
class Meta:
model = models.Template
django_get_or_create = ("title",)
skip_postgeneration_save = True
title = factory.Sequence(lambda n: f"template{n}")
is_public = factory.Faker("boolean")
@factory.post_generation
def users(self, create, extracted, **kwargs):
"""Add users to template from a given list of users with or without roles."""
if create and extracted:
for item in extracted:
if isinstance(item, models.User):
UserTemplateAccessFactory(template=self, user=item)
else:
UserTemplateAccessFactory(template=self, user=item[0], role=item[1])
class UserTemplateAccessFactory(factory.django.DjangoModelFactory):
"""Create fake template user accesses for testing."""
class Meta:
model = models.TemplateAccess
template = factory.SubFactory(TemplateFactory)
user = factory.SubFactory(UserFactory)
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
class TeamTemplateAccessFactory(factory.django.DjangoModelFactory):
"""Create fake template team accesses for testing."""
class Meta:
model = models.TemplateAccess
template = factory.SubFactory(TemplateFactory)
team = factory.Sequence(lambda n: f"team{n}")
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
class InvitationFactory(factory.django.DjangoModelFactory):
"""A factory to create invitations for a user"""

View File

@@ -0,0 +1,26 @@
# Generated by Django 5.2.9 on 2026-01-09 14:18
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("core", "0027_auto_20251120_0956"),
]
operations = [
migrations.RemoveField(
model_name="templateaccess",
name="template",
),
migrations.RemoveField(
model_name="templateaccess",
name="user",
),
migrations.DeleteModel(
name="Template",
),
migrations.DeleteModel(
name="TemplateAccess",
),
]

View File

@@ -1,6 +1,7 @@
"""
Declare and configure the models for the impress core application
"""
# pylint: disable=too-many-lines
import hashlib
@@ -782,8 +783,7 @@ class Document(MP_Node, BaseModel):
return {
"accesses_manage": is_owner_or_admin,
"accesses_view": has_access_role,
"ai_transform": ai_access,
"ai_translate": ai_access,
"ai_proxy": ai_access,
"attachment_upload": can_update,
"media_check": can_get,
"can_edit": can_update,
@@ -816,7 +816,7 @@ class Document(MP_Node, BaseModel):
def send_email(self, subject, emails, context=None, language=None):
"""Generate and send email from a template."""
context = context or {}
domain = Site.objects.get_current().domain
domain = settings.EMAIL_URL_APP or Site.objects.get_current().domain
language = language or get_language()
context.update(
{
@@ -824,7 +824,8 @@ class Document(MP_Node, BaseModel):
"document": self,
"domain": domain,
"link": f"{domain}/docs/{self.id}/",
"document_title": self.title or str(_("Untitled Document")),
"link_label": self.title or str(_("Untitled Document")),
"button_label": _("Open"),
"logo_img": settings.EMAIL_LOGO_IMG,
}
)
@@ -1428,163 +1429,6 @@ class Reaction(BaseModel):
return f"Reaction {self.emoji} on comment {self.comment.id}"
class Template(BaseModel):
"""HTML and CSS code used for formatting the print around the MarkDown body."""
title = models.CharField(_("title"), max_length=255)
description = models.TextField(_("description"), blank=True)
code = models.TextField(_("code"), blank=True)
css = models.TextField(_("css"), blank=True)
is_public = models.BooleanField(
_("public"),
default=False,
help_text=_("Whether this template is public for anyone to use."),
)
class Meta:
db_table = "impress_template"
ordering = ("title",)
verbose_name = _("Template")
verbose_name_plural = _("Templates")
def __str__(self):
return self.title
def get_role(self, user):
"""Return the roles a user has on a resource as an iterable."""
if not user.is_authenticated:
return None
try:
roles = self.user_roles or []
except AttributeError:
try:
roles = self.accesses.filter(
models.Q(user=user) | models.Q(team__in=user.teams),
).values_list("role", flat=True)
except (models.ObjectDoesNotExist, IndexError):
roles = []
return RoleChoices.max(*roles)
def get_abilities(self, user):
"""
Compute and return abilities for a given user on the template.
"""
role = self.get_role(user)
is_owner_or_admin = role in PRIVILEGED_ROLES
can_get = self.is_public or bool(role)
can_update = is_owner_or_admin or role == RoleChoices.EDITOR
return {
"destroy": role == RoleChoices.OWNER,
"generate_document": can_get,
"accesses_manage": is_owner_or_admin,
"update": can_update,
"partial_update": can_update,
"retrieve": can_get,
}
class TemplateAccess(BaseAccess):
"""Relation model to give access to a template for a user or a team with a role."""
template = models.ForeignKey(
Template,
on_delete=models.CASCADE,
related_name="accesses",
)
class Meta:
db_table = "impress_template_access"
ordering = ("-created_at",)
verbose_name = _("Template/user relation")
verbose_name_plural = _("Template/user relations")
constraints = [
models.UniqueConstraint(
fields=["user", "template"],
condition=models.Q(user__isnull=False), # Exclude null users
name="unique_template_user",
violation_error_message=_("This user is already in this template."),
),
models.UniqueConstraint(
fields=["team", "template"],
condition=models.Q(team__gt=""), # Exclude empty string teams
name="unique_template_team",
violation_error_message=_("This team is already in this template."),
),
models.CheckConstraint(
condition=models.Q(user__isnull=False, team="")
| models.Q(user__isnull=True, team__gt=""),
name="check_template_access_either_user_or_team",
violation_error_message=_("Either user or team must be set, not both."),
),
]
def __str__(self):
return f"{self.user!s} is {self.role:s} in template {self.template!s}"
def get_role(self, user):
"""
Get the role a user has on a resource.
"""
if not user.is_authenticated:
return None
try:
roles = self.user_roles or []
except AttributeError:
teams = user.teams
try:
roles = self.template.accesses.filter(
models.Q(user=user) | models.Q(team__in=teams),
).values_list("role", flat=True)
except (Template.DoesNotExist, IndexError):
roles = []
return RoleChoices.max(*roles)
def get_abilities(self, user):
"""
Compute and return abilities for a given user on the template access.
"""
role = self.get_role(user)
is_owner_or_admin = role in PRIVILEGED_ROLES
if self.role == RoleChoices.OWNER:
can_delete = (role == RoleChoices.OWNER) and self.template.accesses.filter(
role=RoleChoices.OWNER
).count() > 1
set_role_to = (
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
if can_delete
else []
)
else:
can_delete = is_owner_or_admin
set_role_to = []
if role == RoleChoices.OWNER:
set_role_to.append(RoleChoices.OWNER)
if is_owner_or_admin:
set_role_to.extend(
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
)
# Remove the current role as we don't want to propose it as an option
try:
set_role_to.remove(self.role)
except ValueError:
pass
return {
"destroy": can_delete,
"update": bool(set_role_to),
"partial_update": bool(set_role_to),
"retrieve": bool(role),
"set_role_to": set_role_to,
}
class Invitation(BaseModel):
"""User invitation to a document."""

View File

@@ -1,94 +1,168 @@
"""AI services."""
# core/services/ai_services.py
from __future__ import annotations
import json
from typing import Any, Dict, Generator
from urllib.parse import urlparse
import httpx
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from openai import OpenAI
from core import enums
BLOCKNOTE_TOOL_STRICT_PROMPT = """You are editing a BlockNote document via the tool applyDocumentOperations.
AI_ACTIONS = {
"prompt": (
"Answer the prompt using markdown formatting for structure and emphasis. "
"Return the content directly without wrapping it in code blocks or markdown delimiters. "
"Preserve the language and markdown formatting. "
"Do not provide any other information. "
"Preserve the language."
),
"correct": (
"Correct grammar and spelling of the markdown text, "
"preserving language and markdown formatting. "
"Do not provide any other information. "
"Preserve the language."
),
"rephrase": (
"Rephrase the given markdown text, "
"preserving language and markdown formatting. "
"Do not provide any other information. "
"Preserve the language."
),
"summarize": (
"Summarize the markdown text, preserving language and markdown formatting. "
"Do not provide any other information. "
"Preserve the language."
),
"beautify": (
"Add formatting to the text to make it more readable. "
"Do not provide any other information. "
"Preserve the language."
),
"emojify": (
"Add emojis to the important parts of the text. "
"Do not provide any other information. "
"Preserve the language."
),
}
You MUST respond ONLY by calling applyDocumentOperations.
The tool input MUST be valid JSON:
{ "operations": [ ... ] }
AI_TRANSLATE = (
"Keep the same html structure and formatting. "
"Translate the content in the html to the specified language {language:s}. "
"Check the translation for accuracy and make any necessary corrections. "
"Do not provide any other information."
)
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.
"""
def _drop_nones(obj: Any) -> Any:
if isinstance(obj, dict):
return {k: _drop_nones(v) for k, v in obj.items() if v is not None}
if isinstance(obj, list):
return [_drop_nones(v) for v in obj]
return obj
class AIService:
"""Service class for AI-related operations."""
"""
Backward-compatible proxy service for your existing viewset:
def __init__(self):
"""Ensure that the AI configuration is set properly."""
if (
settings.AI_BASE_URL is None
or settings.AI_API_KEY is None
or settings.AI_MODEL is None
):
raise ImproperlyConfigured("AI configuration not set")
self.client = OpenAI(base_url=settings.AI_BASE_URL, api_key=settings.AI_API_KEY)
stream_proxy(provider, url, method, headers, body) -> yields bytes
def call_ai_api(self, system_content, text):
"""Helper method to call the OpenAI API and process the response."""
response = self.client.chat.completions.create(
model=settings.AI_MODEL,
messages=[
{"role": "system", "content": system_content},
{"role": "user", "content": text},
],
)
Plus: hardening payload so BlockNote tool calls are valid.
"""
content = response.choices[0].message.content
def __init__(self) -> None:
if not settings.AI_BASE_URL or not settings.AI_API_KEY:
raise ImproperlyConfigured("AI_BASE_URL and AI_API_KEY must be set")
if not content:
raise RuntimeError("AI response does not contain an answer")
self.base_url = str(settings.AI_BASE_URL).rstrip("/")
self.api_key = str(settings.AI_API_KEY)
self.allowed_host = urlparse(self.base_url).netloc
return {"answer": content}
def _assert_allowed_target(self, target_url: str) -> None:
t = urlparse(target_url)
if t.scheme not in ("http", "https"):
raise ValueError("Target URL not allowed")
if t.netloc != self.allowed_host:
raise ValueError("Target URL not allowed")
def transform(self, text, action):
"""Transform text based on specified action."""
system_content = AI_ACTIONS[action]
return self.call_ai_api(system_content, text)
def _filtered_headers(self, incoming_headers: Dict[str, str]) -> Dict[str, str]:
hop_by_hop = {"host", "connection", "content-length", "accept-encoding"}
out: Dict[str, str] = {}
for k, v in incoming_headers.items():
lk = k.lower()
if lk in hop_by_hop:
continue
if lk == "authorization":
# Client auth is for Django only, not upstream
continue
out[k] = v
def translate(self, text, language):
"""Translate text to a specified language."""
language_display = enums.ALL_LANGUAGES.get(language, language)
system_content = AI_TRANSLATE.format(language=language_display)
return self.call_ai_api(system_content, text)
out["Authorization"] = f"Bearer {self.api_key}"
return out
def _normalize_tools(self, tools: list) -> list:
normalized = []
for tool in tools:
if isinstance(tool, dict) and tool.get("type") == "function":
fn = tool.get("function") or {}
if isinstance(fn, dict) and not fn.get("description"):
fn["description"] = f"Tool {fn.get('name', 'unknown')}."
tool["function"] = fn
normalized.append(_drop_nones(tool))
return normalized
def _harden_payload(self, payload: Dict[str, Any]) -> Dict[str, Any]:
payload = dict(payload)
# Enforce server model (important with Albert routing)
if getattr(settings, "AI_MODEL", None):
payload["model"] = settings.AI_MODEL
# Compliance
payload["temperature"] = 0
# Tools normalization
if isinstance(payload.get("tools"), list):
payload["tools"] = self._normalize_tools(payload["tools"])
# Force tool call if tools exist
if payload.get("tools"):
payload["tool_choice"] = {"type": "function", "function": {"name": "applyDocumentOperations"}}
# Convert non-standard "required"
if payload.get("tool_choice") == "required":
payload["tool_choice"] = {"type": "function", "function": {"name": "applyDocumentOperations"}}
# Inject strict system prompt once
msgs = payload.get("messages")
if isinstance(msgs, list):
need = True
if msgs and isinstance(msgs[0], dict) and msgs[0].get("role") == "system":
c = msgs[0].get("content") or ""
if isinstance(c, str) and "applyDocumentOperations" in c and "blocks" in c:
need = False
if need:
payload["messages"] = [{"role": "system", "content": BLOCKNOTE_TOOL_STRICT_PROMPT}] + msgs
return _drop_nones(payload)
def _maybe_harden_json_body(self, body: bytes, headers: Dict[str, str]) -> bytes:
ct = (headers.get("Content-Type") or headers.get("content-type") or "").lower()
if "application/json" not in ct:
return body
try:
payload = json.loads(body.decode("utf-8"))
except Exception:
return body
if isinstance(payload, dict):
payload = self._harden_payload(payload)
return json.dumps(payload, ensure_ascii=False).encode("utf-8")
return body
def stream_proxy(
self,
*,
url: str,
method: str,
headers: Dict[str, str],
body: bytes,
) -> Generator[bytes, None, None]:
self._assert_allowed_target(url)
req_headers = self._filtered_headers(dict(headers))
req_body = self._maybe_harden_json_body(body, req_headers)
timeout = httpx.Timeout(connect=10.0, read=300.0, write=60.0, pool=10.0)
with httpx.Client(timeout=timeout, follow_redirects=False) as client:
with client.stream(method.upper(), url, headers=req_headers, content=req_body) as r:
for chunk in r.iter_bytes():
if chunk:
yield chunk

View File

@@ -1,11 +1,17 @@
"""Y-Provider API services."""
import logging
import typing
from base64 import b64encode
from django.conf import settings
import requests
from core.services import mime_types
logger = logging.getLogger(__name__)
class ConversionError(Exception):
"""Base exception for conversion-related errors."""
@@ -19,8 +25,81 @@ class ServiceUnavailableError(ConversionError):
"""Raised when the conversion service is unavailable."""
class ConverterProtocol(typing.Protocol):
"""Protocol for converter classes."""
def convert(self, data, content_type, accept):
"""Convert content from one format to another."""
class Converter:
"""Orchestrates conversion between different formats using specialized converters."""
docspec: ConverterProtocol
ydoc: ConverterProtocol
def __init__(self):
self.docspec = DocSpecConverter()
self.ydoc = YdocConverter()
def convert(self, data, content_type, accept):
"""Convert input into other formats using external microservices."""
if content_type == mime_types.DOCX and accept == mime_types.YJS:
blocknote_data = self.docspec.convert(
data, mime_types.DOCX, mime_types.BLOCKNOTE
)
return self.ydoc.convert(
blocknote_data, mime_types.BLOCKNOTE, mime_types.YJS
)
return self.ydoc.convert(data, content_type, accept)
class DocSpecConverter:
"""Service class for DocSpec conversion-related operations."""
def _request(self, url, data, content_type):
"""Make a request to the DocSpec API."""
response = requests.post(
url,
headers={"Accept": mime_types.BLOCKNOTE},
files={"file": ("document.docx", data, content_type)},
timeout=settings.CONVERSION_API_TIMEOUT,
verify=settings.CONVERSION_API_SECURE,
)
if not response.ok:
logger.error(
"DocSpec API error: url=%s, status=%d, response=%s",
url,
response.status_code,
response.text[:200] if response.text else "empty",
)
response.raise_for_status()
return response
def convert(self, data, content_type, accept):
"""Convert a Document to BlockNote."""
if not data:
raise ValidationError("Input data cannot be empty")
if content_type != mime_types.DOCX or accept != mime_types.BLOCKNOTE:
raise ValidationError(
f"Conversion from {content_type} to {accept} is not supported."
)
try:
return self._request(settings.DOCSPEC_API_URL, data, content_type).content
except requests.RequestException as err:
logger.exception("DocSpec service error: url=%s", settings.DOCSPEC_API_URL)
raise ServiceUnavailableError(
"Failed to connect to DocSpec conversion service",
) from err
class YdocConverter:
"""Service class for conversion-related operations."""
"""Service class for YDoc conversion-related operations."""
@property
def auth_header(self):
@@ -41,32 +120,34 @@ class YdocConverter:
timeout=settings.CONVERSION_API_TIMEOUT,
verify=settings.CONVERSION_API_SECURE,
)
if not response.ok:
logger.error(
"Y-Provider API error: url=%s, status=%d, response=%s",
url,
response.status_code,
response.text[:200] if response.text else "empty",
)
response.raise_for_status()
return response
def convert(
self, text, content_type="text/markdown", accept="application/vnd.yjs.doc"
):
def convert(self, data, content_type=mime_types.MARKDOWN, accept=mime_types.YJS):
"""Convert a Markdown text into our internal format using an external microservice."""
if not text:
raise ValidationError("Input text cannot be empty")
if not data:
raise ValidationError("Input data cannot be empty")
url = f"{settings.Y_PROVIDER_API_BASE_URL}{settings.CONVERSION_API_ENDPOINT}/"
try:
response = self._request(
f"{settings.Y_PROVIDER_API_BASE_URL}{settings.CONVERSION_API_ENDPOINT}/",
text,
content_type,
accept,
)
if accept == "application/vnd.yjs.doc":
response = self._request(url, data, content_type, accept)
if accept == mime_types.YJS:
return b64encode(response.content).decode("utf-8")
if accept in {"text/markdown", "text/html"}:
if accept in {mime_types.MARKDOWN, "text/html"}:
return response.text
if accept == "application/json":
if accept == mime_types.JSON:
return response.json()
raise ValidationError("Unsupported format")
except requests.RequestException as err:
logger.exception("Y-Provider service error: url=%s", url)
raise ServiceUnavailableError(
"Failed to connect to conversion service",
f"Failed to connect to YDoc conversion service {content_type}, {accept}",
) from err

View File

@@ -0,0 +1,8 @@
"""MIME type constants for document conversion."""
BLOCKNOTE = "application/vnd.blocknote+json"
YJS = "application/vnd.yjs.doc"
MARKDOWN = "text/markdown"
JSON = "application/json"
DOCX = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
HTML = "text/html"

View File

@@ -1,14 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Generate Document</title>
</head>
<body>
<h2>Generate Document</h2>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Generate PDF</button>
</form>
</body>
</html>

View File

@@ -1,4 +1,4 @@
"""Custom template tags for the core application of People."""
"""Custom template tags for the core application of Docs."""
import base64

View File

@@ -5,7 +5,6 @@ import re
from unittest import mock
from django.core.exceptions import SuspiciousOperation
from django.test.utils import override_settings
import pytest
import responses
@@ -323,85 +322,6 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
assert models.User.objects.count() == 1
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
@responses.activate
def test_authentication_get_userinfo_json_response():
"""Test get_userinfo method with a JSON response."""
responses.add(
responses.GET,
re.compile(r".*/userinfo"),
json={
"first_name": "John",
"last_name": "Doe",
"email": "john.doe@example.com",
},
status=200,
)
oidc_backend = OIDCAuthenticationBackend()
result = oidc_backend.get_userinfo("fake_access_token", None, None)
assert result["first_name"] == "John"
assert result["last_name"] == "Doe"
assert result["email"] == "john.doe@example.com"
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
@responses.activate
def test_authentication_get_userinfo_token_response(monkeypatch, settings):
"""Test get_userinfo method with a token response."""
settings.OIDC_RP_SIGN_ALGO = "HS256" # disable JWKS URL call
responses.add(
responses.GET,
re.compile(r".*/userinfo"),
body="fake.jwt.token",
status=200,
content_type="application/jwt",
)
def mock_verify_token(self, token): # pylint: disable=unused-argument
return {
"first_name": "Jane",
"last_name": "Doe",
"email": "jane.doe@example.com",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "verify_token", mock_verify_token)
oidc_backend = OIDCAuthenticationBackend()
result = oidc_backend.get_userinfo("fake_access_token", None, None)
assert result["first_name"] == "Jane"
assert result["last_name"] == "Doe"
assert result["email"] == "jane.doe@example.com"
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
@responses.activate
def test_authentication_get_userinfo_invalid_response(settings):
"""
Test get_userinfo method with an invalid JWT response that
causes verify_token to raise an error.
"""
settings.OIDC_RP_SIGN_ALGO = "HS256" # disable JWKS URL call
responses.add(
responses.GET,
re.compile(r".*/userinfo"),
body="fake.jwt.token",
status=200,
content_type="application/jwt",
)
oidc_backend = OIDCAuthenticationBackend()
with pytest.raises(
SuspiciousOperation,
match="User info response was not valid JWT",
):
oidc_backend.get_userinfo("fake_access_token", None, None)
def test_authentication_getter_existing_disabled_user_via_sub(
django_assert_num_queries, monkeypatch
):

View File

@@ -0,0 +1,686 @@
"""
Test AI proxy API endpoint for users in impress's core app.
"""
import random
from unittest.mock import MagicMock, patch
from django.test import override_settings
import pytest
from rest_framework.test import APIClient
from core import factories
from core.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://example.com"
settings.AI_API_KEY = "test-key"
settings.AI_FEATURE_ENABLED = True
@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"}],
"model": "llama",
},
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
@override_settings(AI_ALLOW_REACH_FROM="public")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_proxy_anonymous_success(mock_create):
"""
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_response = MagicMock()
mock_response.model_dump.return_value = {
"id": "chatcmpl-123",
"object": "chat.completion",
"created": 1677652288,
"model": "llama",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "Hello! How can I help you?",
},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 9, "completion_tokens": 12, "total_tokens": 21},
}
mock_create.return_value = mock_response
url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/"
response = APIClient().post(
url,
{
"messages": [{"role": "user", "content": "Hello"}],
"model": "llama",
},
format="json",
)
assert response.status_code == 200
response_data = response.json()
assert response_data["id"] == "chatcmpl-123"
assert response_data["model"] == "llama"
assert len(response_data["choices"]) == 1
assert (
response_data["choices"][0]["message"]["content"]
== "Hello! How can I help you?"
)
mock_create.assert_called_once_with(
messages=[{"role": "user", "content": "Hello"}],
model="llama",
stream=False,
)
@override_settings(AI_ALLOW_REACH_FROM=random.choice(["authenticated", "restricted"]))
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_proxy_anonymous_limited_by_setting(mock_create):
"""
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")
mock_response = MagicMock()
mock_response.model_dump.return_value = {"content": "Hello!"}
mock_create.return_value = mock_response
url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/"
response = APIClient().post(
url,
{
"messages": [{"role": "user", "content": "Hello"}],
"model": "llama",
},
format="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,
{
"messages": [{"role": "user", "content": "Hello"}],
"model": "llama",
},
format="json",
)
assert response.status_code == 403
@pytest.mark.parametrize(
"reach, role",
[
("authenticated", "editor"),
("public", "editor"),
],
)
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_proxy_authenticated_success(mock_create, 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_response = MagicMock()
mock_response.model_dump.return_value = {
"id": "chatcmpl-456",
"object": "chat.completion",
"model": "llama",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "Hi there!"},
"finish_reason": "stop",
}
],
}
mock_create.return_value = mock_response
url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/"
response = client.post(
url,
{
"messages": [{"role": "user", "content": "Hello"}],
"model": "llama",
},
format="json",
)
assert response.status_code == 200
response_data = response.json()
assert response_data["id"] == "chatcmpl-456"
assert response_data["choices"][0]["message"]["content"] == "Hi there!"
mock_create.assert_called_once_with(
messages=[{"role": "user", "content": "Hello"}],
model="llama",
stream=False,
)
@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,
{
"messages": [{"role": "user", "content": "Hello"}],
"model": "llama",
},
format="json",
)
assert response.status_code == 403
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
@pytest.mark.parametrize("via", VIA)
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_proxy_success(mock_create, 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_response = MagicMock()
mock_response.model_dump.return_value = {
"id": "chatcmpl-789",
"object": "chat.completion",
"model": "llama",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "Success!"},
"finish_reason": "stop",
}
],
}
mock_create.return_value = mock_response
url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/"
response = client.post(
url,
{
"messages": [{"role": "user", "content": "Test message"}],
"model": "llama",
},
format="json",
)
assert response.status_code == 200
response_data = response.json()
assert response_data["id"] == "chatcmpl-789"
assert response_data["choices"][0]["message"]["content"] == "Success!"
mock_create.assert_called_once_with(
messages=[{"role": "user", "content": "Test message"}],
model="llama",
stream=False,
)
def test_api_documents_ai_proxy_empty_messages():
"""The messages should not be empty when requesting AI proxy."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="public", link_role="editor")
url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/"
response = client.post(url, {"messages": [], "model": "llama"}, format="json")
assert response.status_code == 400
assert response.json() == {"messages": ["This list may not be empty."]}
def test_api_documents_ai_proxy_missing_model():
"""The model should be required when requesting AI proxy."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="public", link_role="editor")
url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/"
response = client.post(
url, {"messages": [{"role": "user", "content": "Hello"}]}, format="json"
)
assert response.status_code == 400
assert response.json() == {"model": ["This field is required."]}
def test_api_documents_ai_proxy_invalid_message_format():
"""Messages should have the correct format when requesting AI proxy."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="public", link_role="editor")
url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/"
# Test with invalid message format (missing role)
response = client.post(
url,
{
"messages": [{"content": "Hello"}],
"model": "llama",
},
format="json",
)
assert response.status_code == 400
assert response.json() == {
"messages": ["Each message must have 'role' and 'content' fields"]
}
# Test with invalid message format (missing content)
response = client.post(
url,
{
"messages": [{"role": "user"}],
"model": "llama",
},
format="json",
)
assert response.status_code == 400
assert response.json() == {
"messages": ["Each message must have 'role' and 'content' fields"]
}
# Test with non-dict message
response = client.post(
url,
{
"messages": ["invalid"],
"model": "llama",
},
format="json",
)
assert response.status_code == 400
assert response.json() == {
"messages": {"0": ['Expected a dictionary of items but got type "str".']}
}
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_proxy_stream_disabled(mock_create):
"""Stream should be automatically disabled in AI proxy requests."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="public", link_role="editor")
mock_response = MagicMock()
mock_response.model_dump.return_value = {"content": "Success!"}
mock_create.return_value = mock_response
url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/"
response = client.post(
url,
{
"messages": [{"role": "user", "content": "Hello"}],
"model": "llama",
"stream": True, # This should be overridden to False
},
format="json",
)
assert response.status_code == 200
# Verify that stream was set to False
mock_create.assert_called_once_with(
messages=[{"role": "user", "content": "Hello"}],
model="llama",
stream=False,
)
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_proxy_additional_parameters(mock_create):
"""AI proxy should pass through additional parameters to the AI service."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="public", link_role="editor")
mock_response = MagicMock()
mock_response.model_dump.return_value = {"content": "Success!"}
mock_create.return_value = mock_response
url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/"
response = client.post(
url,
{
"messages": [{"role": "user", "content": "Hello"}],
"model": "llama",
"temperature": 0.7,
"max_tokens": 100,
"top_p": 0.9,
},
format="json",
)
assert response.status_code == 200
# Verify that additional parameters were passed through
mock_create.assert_called_once_with(
messages=[{"role": "user", "content": "Hello"}],
model="llama",
temperature=0.7,
max_tokens=100,
top_p=0.9,
stream=False,
)
@override_settings(AI_DOCUMENT_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_proxy_throttling_document(mock_create):
"""
Throttling per document should be triggered on the AI transform 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_response = MagicMock()
mock_response.model_dump.return_value = {"content": "Success!"}
mock_create.return_value = mock_response
url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/"
for _ in range(3):
user = factories.UserFactory()
client.force_login(user)
response = client.post(
url,
{
"messages": [{"role": "user", "content": "Test message"}],
"model": "llama",
},
format="json",
)
assert response.status_code == 200
assert response.json() == {"content": "Success!"}
user = factories.UserFactory()
client.force_login(user)
response = client.post(
url,
{
"messages": [{"role": "user", "content": "Test message"}],
"model": "llama",
},
)
assert response.status_code == 429
assert response.json() == {
"detail": "Request was throttled. Expected available in 60 seconds."
}
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_proxy_complex_conversation(mock_create):
"""AI proxy should handle complex conversations with multiple messages."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="public", link_role="editor")
mock_response = MagicMock()
mock_response.model_dump.return_value = {
"id": "chatcmpl-complex",
"object": "chat.completion",
"model": "llama",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "I understand your question about Python.",
},
"finish_reason": "stop",
}
],
}
mock_create.return_value = mock_response
complex_messages = [
{"role": "system", "content": "You are a helpful programming assistant."},
{"role": "user", "content": "How do I write a for loop in Python?"},
{
"role": "assistant",
"content": "You can write a for loop using: for item in iterable:",
},
{"role": "user", "content": "Can you give me a concrete example?"},
]
url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/"
response = client.post(
url,
{
"messages": complex_messages,
"model": "llama",
},
format="json",
)
assert response.status_code == 200
response_data = response.json()
assert response_data["id"] == "chatcmpl-complex"
assert (
response_data["choices"][0]["message"]["content"]
== "I understand your question about Python."
)
mock_create.assert_called_once_with(
messages=complex_messages,
model="llama",
stream=False,
)
@override_settings(AI_USER_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_proxy_throttling_user(mock_create):
"""
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)
mock_response = MagicMock()
mock_response.model_dump.return_value = {"content": "Success!"}
mock_create.return_value = mock_response
for _ in range(3):
document = factories.DocumentFactory(link_reach="public", link_role="editor")
url = f"/api/v1.0/documents/{document.id!s}/ai-proxy/"
response = client.post(
url,
{
"messages": [{"role": "user", "content": "Hello"}],
"model": "llama",
},
format="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,
{
"messages": [{"role": "user", "content": "Hello"}],
"model": "llama",
},
format="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": 10, "hour": 6, "day": 10})
def test_api_documents_ai_proxy_different_models():
"""AI proxy should work with different AI models."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="public", link_role="editor")
models_to_test = ["gpt-3.5-turbo", "gpt-4", "claude-3", "llama-2"]
for model_name in models_to_test:
response = client.post(
f"/api/v1.0/documents/{document.id!s}/ai-proxy/",
{
"messages": [{"role": "user", "content": "Hello"}],
"model": model_name,
},
format="json",
)
assert response.status_code == 400
assert response.json() == {"model": [f"{model_name} is not a valid model"]}
def test_api_documents_ai_proxy_ai_feature_disabled(settings):
"""When the settings AI_FEATURE_ENABLED is set to False, the endpoint is not reachable."""
settings.AI_FEATURE_ENABLED = 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/",
{
"messages": [{"role": "user", "content": "Hello"}],
"model": "llama",
},
format="json",
)
assert response.status_code == 400
assert response.json() == ["AI feature is not enabled."]

View File

@@ -1,362 +0,0 @@
"""
Test AI transform API endpoint for users in impress's core app.
"""
import random
from unittest.mock import MagicMock, patch
from django.test import override_settings
import pytest
from rest_framework.test import APIClient
from core import factories
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
@pytest.fixture
def ai_settings():
"""Fixture to set AI settings."""
with override_settings(
AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="llama"
):
yield
@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_transform_anonymous_forbidden(reach, role):
"""
Anonymous users should not be able to request AI transform 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-transform/"
response = APIClient().post(url, {"text": "hello", "action": "prompt"})
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
@override_settings(AI_ALLOW_REACH_FROM="public")
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_transform_anonymous_success(mock_create):
"""
Anonymous users should be able to request AI transform to a document
if the link reach and role permit it.
"""
document = factories.DocumentFactory(link_reach="public", link_role="editor")
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content="Salut"))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
response = APIClient().post(url, {"text": "Hello", "action": "summarize"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
mock_create.assert_called_once_with(
model="llama",
messages=[
{
"role": "system",
"content": (
"Summarize the markdown text, preserving language and markdown formatting. "
"Do not provide any other information. Preserve the language."
),
},
{"role": "user", "content": "Hello"},
],
)
@override_settings(AI_ALLOW_REACH_FROM=random.choice(["authenticated", "restricted"]))
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_transform_anonymous_limited_by_setting(mock_create):
"""
Anonymous users should be able to request AI transform to a document
if the link reach and role permit it.
"""
document = factories.DocumentFactory(link_reach="public", link_role="editor")
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
response = APIClient().post(url, {"text": "Hello", "action": "summarize"})
assert response.status_code == 401
@pytest.mark.parametrize(
"reach, role",
[
("restricted", "reader"),
("restricted", "editor"),
("authenticated", "reader"),
("public", "reader"),
],
)
def test_api_documents_ai_transform_authenticated_forbidden(reach, role):
"""
Users who are not related to a document can't request AI transform 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-transform/"
response = client.post(url, {"text": "Hello", "action": "prompt"})
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
@pytest.mark.parametrize(
"reach, role",
[
("authenticated", "editor"),
("public", "editor"),
],
)
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_transform_authenticated_success(mock_create, reach, role):
"""
Authenticated who are not related to a document should be able to request AI transform
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_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content="Salut"))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
response = client.post(url, {"text": "Hello", "action": "prompt"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
mock_create.assert_called_once_with(
model="llama",
messages=[
{
"role": "system",
"content": (
"Answer the prompt using markdown formatting for structure and emphasis. "
"Return the content directly without wrapping it in code blocks or markdown delimiters. "
"Preserve the language and markdown formatting. "
"Do not provide any other information. "
"Preserve the language."
),
},
{"role": "user", "content": "Hello"},
],
)
@pytest.mark.parametrize("via", VIA)
def test_api_documents_ai_transform_reader(via, mock_user_teams):
"""
Users who are simple readers on a document should not be allowed to request AI transform.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_role="reader")
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-transform/"
response = client.post(url, {"text": "Hello", "action": "prompt"})
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
@pytest.mark.parametrize("via", VIA)
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_transform_success(mock_create, via, role, mock_user_teams):
"""
Editors, administrators and owners of a document should be able to request AI transform.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
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_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content="Salut"))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
response = client.post(url, {"text": "Hello", "action": "prompt"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
mock_create.assert_called_once_with(
model="llama",
messages=[
{
"role": "system",
"content": (
"Answer the prompt using markdown formatting for structure and emphasis. "
"Return the content directly without wrapping it in code blocks or markdown delimiters. "
"Preserve the language and markdown formatting. "
"Do not provide any other information. "
"Preserve the language."
),
},
{"role": "user", "content": "Hello"},
],
)
def test_api_documents_ai_transform_empty_text():
"""The text should not be empty when requesting AI transform."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="public", link_role="editor")
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
response = client.post(url, {"text": " ", "action": "prompt"})
assert response.status_code == 400
assert response.json() == {"text": ["This field may not be blank."]}
def test_api_documents_ai_transform_invalid_action():
"""The action should valid when requesting AI transform."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="public", link_role="editor")
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
response = client.post(url, {"text": "Hello", "action": "invalid"})
assert response.status_code == 400
assert response.json() == {"action": ['"invalid" is not a valid choice.']}
@override_settings(AI_DOCUMENT_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_transform_throttling_document(mock_create):
"""
Throttling per document should be triggered on the AI transform 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_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content="Salut"))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
for _ in range(3):
user = factories.UserFactory()
client.force_login(user)
response = client.post(url, {"text": "Hello", "action": "summarize"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
user = factories.UserFactory()
client.force_login(user)
response = client.post(url, {"text": "Hello", "action": "summarize"})
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})
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_transform_throttling_user(mock_create):
"""
Throttling per user should be triggered on the AI transform endpoint.
For full throttle class test see: `test_api_utils_ai_user_rate_throttles`
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content="Salut"))]
)
for _ in range(3):
document = factories.DocumentFactory(link_reach="public", link_role="editor")
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
response = client.post(url, {"text": "Hello", "action": "summarize"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
document = factories.DocumentFactory(link_reach="public", link_role="editor")
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
response = client.post(url, {"text": "Hello", "action": "summarize"})
assert response.status_code == 429
assert response.json() == {
"detail": "Request was throttled. Expected available in 60 seconds."
}

View File

@@ -1,384 +0,0 @@
"""
Test AI translate API endpoint for users in impress's core app.
"""
import random
from unittest.mock import MagicMock, patch
from django.test import override_settings
import pytest
from rest_framework.test import APIClient
from core import factories
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
@pytest.fixture
def ai_settings():
"""Fixture to set AI settings."""
with override_settings(
AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="llama"
):
yield
def test_api_documents_ai_translate_viewset_options_metadata():
"""The documents endpoint should give us the list of available languages."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory(link_reach="public", link_role="editor")
response = APIClient().options("/api/v1.0/documents/")
assert response.status_code == 200
metadata = response.json()
assert metadata["name"] == "Document List"
assert metadata["actions"]["POST"]["language"]["choices"][0] == {
"value": "af",
"display_name": "Afrikaans",
}
@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_translate_anonymous_forbidden(reach, role):
"""
Anonymous users should not be able to request AI translate 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-translate/"
response = APIClient().post(url, {"text": "hello", "language": "es"})
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
@override_settings(AI_ALLOW_REACH_FROM="public")
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_translate_anonymous_success(mock_create):
"""
Anonymous users should be able to request AI translate to a document
if the link reach and role permit it.
"""
document = factories.DocumentFactory(link_reach="public", link_role="editor")
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content="Ola"))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
response = APIClient().post(url, {"text": "Hello", "language": "es"})
assert response.status_code == 200
assert response.json() == {"answer": "Ola"}
mock_create.assert_called_once_with(
model="llama",
messages=[
{
"role": "system",
"content": (
"Keep the same html structure and formatting. "
"Translate the content in the html to the specified language Spanish. "
"Check the translation for accuracy and make any necessary corrections. "
"Do not provide any other information."
),
},
{"role": "user", "content": "Hello"},
],
)
@override_settings(AI_ALLOW_REACH_FROM=random.choice(["authenticated", "restricted"]))
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_translate_anonymous_limited_by_setting(mock_create):
"""
Anonymous users should be able to request AI translate to a document
if the link reach and role permit it.
"""
document = factories.DocumentFactory(link_reach="public", link_role="editor")
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
response = APIClient().post(url, {"text": "Hello", "language": "es"})
assert response.status_code == 401
@pytest.mark.parametrize(
"reach, role",
[
("restricted", "reader"),
("restricted", "editor"),
("authenticated", "reader"),
("public", "reader"),
],
)
def test_api_documents_ai_translate_authenticated_forbidden(reach, role):
"""
Users who are not related to a document can't request AI translate 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-translate/"
response = client.post(url, {"text": "Hello", "language": "es"})
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
@pytest.mark.parametrize(
"reach, role",
[
("authenticated", "editor"),
("public", "editor"),
],
)
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_translate_authenticated_success(mock_create, reach, role):
"""
Authenticated who are not related to a document should be able to request AI translate
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_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content="Salut"))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
response = client.post(url, {"text": "Hello", "language": "es-co"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
mock_create.assert_called_once_with(
model="llama",
messages=[
{
"role": "system",
"content": (
"Keep the same html structure and formatting. "
"Translate the content in the html to the "
"specified language Colombian Spanish. "
"Check the translation for accuracy and make any necessary corrections. "
"Do not provide any other information."
),
},
{"role": "user", "content": "Hello"},
],
)
@pytest.mark.parametrize("via", VIA)
def test_api_documents_ai_translate_reader(via, mock_user_teams):
"""
Users who are simple readers on a document should not be allowed to request AI translate.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_role="reader")
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-translate/"
response = client.post(url, {"text": "Hello", "language": "es"})
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
@pytest.mark.parametrize("via", VIA)
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_translate_success(mock_create, via, role, mock_user_teams):
"""
Editors, administrators and owners of a document should be able to request AI translate.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
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_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content="Salut"))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
response = client.post(url, {"text": "Hello", "language": "es-co"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
mock_create.assert_called_once_with(
model="llama",
messages=[
{
"role": "system",
"content": (
"Keep the same html structure and formatting. "
"Translate the content in the html to the "
"specified language Colombian Spanish. "
"Check the translation for accuracy and make any necessary corrections. "
"Do not provide any other information."
),
},
{"role": "user", "content": "Hello"},
],
)
def test_api_documents_ai_translate_empty_text():
"""The text should not be empty when requesting AI translate."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="public", link_role="editor")
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
response = client.post(url, {"text": " ", "language": "es"})
assert response.status_code == 400
assert response.json() == {"text": ["This field may not be blank."]}
def test_api_documents_ai_translate_invalid_action():
"""The action should valid when requesting AI translate."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="public", link_role="editor")
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
response = client.post(url, {"text": "Hello", "language": "invalid"})
assert response.status_code == 400
assert response.json() == {"language": ['"invalid" is not a valid choice.']}
@override_settings(AI_DOCUMENT_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_translate_throttling_document(mock_create):
"""
Throttling per document should be triggered on the AI translate 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_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content="Salut"))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
for _ in range(3):
user = factories.UserFactory()
client.force_login(user)
response = client.post(url, {"text": "Hello", "language": "es"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
user = factories.UserFactory()
client.force_login(user)
response = client.post(url, {"text": "Hello", "language": "es"})
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})
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_translate_throttling_user(mock_create):
"""
Throttling per user should be triggered on the AI translate endpoint.
For full throttle class test see: `test_api_utils_ai_user_rate_throttles`
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content="Salut"))]
)
for _ in range(3):
document = factories.DocumentFactory(link_reach="public", link_role="editor")
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
response = client.post(url, {"text": "Hello", "language": "es"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
document = factories.DocumentFactory(link_reach="public", link_role="editor")
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
response = client.post(url, {"text": "Hello", "language": "es"})
assert response.status_code == 429
assert response.json() == {
"detail": "Request was throttled. Expected available in 60 seconds."
}

View File

@@ -0,0 +1,427 @@
"""
Tests for Documents API endpoint in impress's core app: all
The 'all' endpoint returns ALL documents (including descendants) that the user has access to.
This is different from the 'list' endpoint which only returns top-level documents.
"""
from datetime import timedelta
from unittest import mock
from django.utils import timezone
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_api_documents_all_anonymous(reach, role):
"""
Anonymous users should not be able to list any documents via the all endpoint
whatever the link reach and link role.
"""
parent = factories.DocumentFactory(link_reach=reach, link_role=role)
factories.DocumentFactory(parent=parent, link_reach=reach, link_role=role)
response = APIClient().get("/api/v1.0/documents/all/")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 0
def test_api_documents_all_authenticated_with_children():
"""
Authenticated users should see all documents including children,
even though children don't have DocumentAccess records.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Create a document tree: parent -> child -> grandchild
parent = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=parent, user=user, role="owner")
child = factories.DocumentFactory(parent=parent)
grandchild = factories.DocumentFactory(parent=child)
# Verify setup
assert models.DocumentAccess.objects.filter(document=parent).count() == 1
assert models.DocumentAccess.objects.filter(document=child).count() == 0
assert models.DocumentAccess.objects.filter(document=grandchild).count() == 0
response = client.get("/api/v1.0/documents/all/")
assert response.status_code == 200
results = response.json()["results"]
# All three documents should be returned (parent + child + grandchild)
assert len(results) == 3
results_ids = {result["id"] for result in results}
assert results_ids == {str(parent.id), str(child.id), str(grandchild.id)}
depths = {result["depth"] for result in results}
assert depths == {1, 2, 3}
def test_api_documents_all_authenticated_multiple_trees():
"""
Users should see all accessible documents from multiple document trees.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Tree 1: User has access
tree1_parent = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=tree1_parent, user=user)
tree1_child = factories.DocumentFactory(parent=tree1_parent)
# Tree 2: User has access
tree2_parent = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=tree2_parent, user=user)
tree2_child1 = factories.DocumentFactory(parent=tree2_parent)
tree2_child2 = factories.DocumentFactory(parent=tree2_parent)
# Tree 3: User does NOT have access
tree3_parent = factories.DocumentFactory()
factories.DocumentFactory(parent=tree3_parent)
response = client.get("/api/v1.0/documents/all/")
assert response.status_code == 200
results = response.json()["results"]
# Should return 5 documents (tree1: 2, tree2: 3, tree3: 0)
assert len(results) == 5
results_ids = {result["id"] for result in results}
expected_ids = {
str(tree1_parent.id),
str(tree1_child.id),
str(tree2_parent.id),
str(tree2_child1.id),
str(tree2_child2.id),
}
assert results_ids == expected_ids
def test_api_documents_all_authenticated_explicit_access_to_parent_and_child():
"""
When a user has explicit DocumentAccess to both parent AND child,
both should appear in the 'all' endpoint results (unlike 'list' which deduplicates).
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Parent with explicit access
parent = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=parent, user=user)
# Child also has explicit access (e.g., shared separately)
child = factories.DocumentFactory(parent=parent)
factories.UserDocumentAccessFactory(document=child, user=user)
# Grandchild has no explicit access
grandchild = factories.DocumentFactory(parent=child)
# Verify setup
assert models.DocumentAccess.objects.filter(document=parent).count() == 1
assert models.DocumentAccess.objects.filter(document=child).count() == 1
assert models.DocumentAccess.objects.filter(document=grandchild).count() == 0
response = client.get("/api/v1.0/documents/all/")
assert response.status_code == 200
results = response.json()["results"]
# All three should appear
assert len(results) == 3
results_ids = {result["id"] for result in results}
assert results_ids == {str(parent.id), str(child.id), str(grandchild.id)}
# Each document should appear exactly once (no duplicates)
results_ids_list = [result["id"] for result in results]
assert len(results_ids_list) == len(set(results_ids_list)) # No duplicates
def test_api_documents_all_authenticated_via_team(mock_user_teams):
"""
Users should see all documents (including descendants) for documents accessed via teams.
"""
mock_user_teams.return_value = ["team1", "team2"]
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Document tree via team1
parent1 = factories.DocumentFactory()
factories.TeamDocumentAccessFactory(document=parent1, team="team1")
child1 = factories.DocumentFactory(parent=parent1)
# Document tree via team2
parent2 = factories.DocumentFactory()
factories.TeamDocumentAccessFactory(document=parent2, team="team2")
child2 = factories.DocumentFactory(parent=parent2)
# Document tree via unknown team
parent3 = factories.DocumentFactory()
factories.TeamDocumentAccessFactory(document=parent3, team="team3")
factories.DocumentFactory(parent=parent3)
response = client.get("/api/v1.0/documents/all/")
assert response.status_code == 200
results = response.json()["results"]
# Should return 4 documents (team1: 2, team2: 2, team3: 0)
assert len(results) == 4
results_ids = {result["id"] for result in results}
expected_ids = {
str(parent1.id),
str(child1.id),
str(parent2.id),
str(child2.id),
}
assert results_ids == expected_ids
def test_api_documents_all_authenticated_soft_deleted():
"""
Soft-deleted documents and their descendants should not be included.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Active tree
active_parent = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=active_parent, user=user)
active_child = factories.DocumentFactory(parent=active_parent)
# Soft-deleted tree
deleted_parent = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=deleted_parent, user=user)
_deleted_child = factories.DocumentFactory(parent=deleted_parent)
deleted_parent.soft_delete()
response = client.get("/api/v1.0/documents/all/")
assert response.status_code == 200
results = response.json()["results"]
# Should only return active documents
assert len(results) == 2
results_ids = {result["id"] for result in results}
assert results_ids == {str(active_parent.id), str(active_child.id)}
def test_api_documents_all_authenticated_permanently_deleted():
"""
Permanently deleted documents should not be included.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Active tree
active_parent = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=active_parent, user=user)
active_child = factories.DocumentFactory(parent=active_parent)
# Permanently deleted tree (deleted > 30 days ago)
deleted_parent = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=deleted_parent, user=user)
_deleted_child = factories.DocumentFactory(parent=deleted_parent)
fourty_days_ago = timezone.now() - timedelta(days=40)
with mock.patch("django.utils.timezone.now", return_value=fourty_days_ago):
deleted_parent.soft_delete()
response = client.get("/api/v1.0/documents/all/")
assert response.status_code == 200
results = response.json()["results"]
# Should only return active documents
assert len(results) == 2
results_ids = {result["id"] for result in results}
assert results_ids == {str(active_parent.id), str(active_child.id)}
def test_api_documents_all_authenticated_link_reach_restricted():
"""
Documents with link_reach=restricted accessed via LinkTrace should not appear
in the all endpoint results.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Document with direct access (should appear)
parent_with_access = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=parent_with_access, user=user)
child_with_access = factories.DocumentFactory(parent=parent_with_access)
# Document with only LinkTrace and restricted reach (should NOT appear)
parent_restricted = factories.DocumentFactory(
link_reach="restricted", link_traces=[user]
)
factories.DocumentFactory(parent=parent_restricted)
response = client.get("/api/v1.0/documents/all/")
assert response.status_code == 200
results = response.json()["results"]
# Only documents with direct access should appear
assert len(results) == 2
results_ids = {result["id"] for result in results}
assert results_ids == {str(parent_with_access.id), str(child_with_access.id)}
@pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_all_authenticated_link_reach_public_or_authenticated(reach):
"""
Documents with link_reach=public or authenticated accessed via LinkTrace
should appear with all their descendants.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Document accessed via LinkTrace with non-restricted reach
parent = factories.DocumentFactory(link_reach=reach, link_traces=[user])
child = factories.DocumentFactory(parent=parent)
grandchild = factories.DocumentFactory(parent=child)
response = client.get("/api/v1.0/documents/all/")
assert response.status_code == 200
results = response.json()["results"]
# All descendants should be included
assert len(results) == 3
results_ids = {result["id"] for result in results}
assert results_ids == {str(parent.id), str(child.id), str(grandchild.id)}
def test_api_documents_all_format():
"""Validate the format of documents as returned by the all endpoint."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
access = factories.UserDocumentAccessFactory(document=document, user=user)
child = factories.DocumentFactory(parent=document)
response = client.get("/api/v1.0/documents/all/")
assert response.status_code == 200
content = response.json()
results = content.pop("results")
# Check pagination structure
assert content == {
"count": 2,
"next": None,
"previous": None,
}
# Verify parent document format
parent_result = [r for r in results if r["id"] == str(document.id)][0]
assert parent_result == {
"id": str(document.id),
"abilities": document.get_abilities(user),
"ancestors_link_reach": None,
"ancestors_link_role": None,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"deleted_at": None,
"depth": 1,
"excerpt": document.excerpt,
"is_favorite": False,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 1,
"numchild": 1,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": access.role,
}
# Verify child document format
child_result = [r for r in results if r["id"] == str(child.id)][0]
assert child_result["depth"] == 2
assert child_result["user_role"] == access.role # Inherited from parent
assert child_result["nb_accesses_direct"] == 0 # No direct access on child
def test_api_documents_all_distinct():
"""
A document should only appear once even if the user has multiple access paths to it.
"""
user = factories.UserFactory()
other_user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Document with multiple accesses for the same user
document = factories.DocumentFactory(users=[user, other_user])
child = factories.DocumentFactory(parent=document)
response = client.get("/api/v1.0/documents/all/")
assert response.status_code == 200
results = response.json()["results"]
# Should return 2 documents (parent + child), each appearing once
assert len(results) == 2
results_ids = [result["id"] for result in results]
assert results_ids.count(str(document.id)) == 1
assert results_ids.count(str(child.id)) == 1
def test_api_documents_all_comparison_with_list():
"""
The 'all' endpoint should return more documents than 'list' when there are children.
'list' returns only top-level documents, 'all' returns all descendants.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Create a document tree
parent = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=parent, user=user)
child = factories.DocumentFactory(parent=parent)
grandchild = factories.DocumentFactory(parent=child)
# Call list endpoint
list_response = client.get("/api/v1.0/documents/")
list_results = list_response.json()["results"]
# Call all endpoint
all_response = client.get("/api/v1.0/documents/all/")
all_results = all_response.json()["results"]
# list should return only parent
assert len(list_results) == 1
assert list_results[0]["id"] == str(parent.id)
# all should return parent + child + grandchild
assert len(all_results) == 3
all_ids = {result["id"] for result in all_results}
assert all_ids == {str(parent.id), str(child.id), str(grandchild.id)}

View File

@@ -1,5 +1,8 @@
"""Test on the CORS proxy API for documents."""
import socket
import unittest.mock
import pytest
import responses
from requests.exceptions import RequestException
@@ -10,11 +13,17 @@ from core import factories
pytestmark = pytest.mark.django_db
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
@responses.activate
def test_api_docs_cors_proxy_valid_url():
def test_api_docs_cors_proxy_valid_url(mock_getaddrinfo):
"""Test the CORS proxy API for documents with a valid URL."""
document = factories.DocumentFactory(link_reach="public")
# Mock DNS resolution to return a public IP address
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
]
client = APIClient()
url_to_fetch = "https://external-url.com/assets/logo-gouv.png"
responses.get(url_to_fetch, body=b"", status=200, content_type="image/png")
@@ -56,11 +65,17 @@ def test_api_docs_cors_proxy_without_url_query_string():
assert response.json() == {"detail": "Missing 'url' query parameter"}
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
@responses.activate
def test_api_docs_cors_proxy_anonymous_document_not_public():
def test_api_docs_cors_proxy_anonymous_document_not_public(mock_getaddrinfo):
"""Test the CORS proxy API for documents with an anonymous user and a non-public document."""
document = factories.DocumentFactory(link_reach="authenticated")
# Mock DNS resolution to return a public IP address
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
]
client = APIClient()
url_to_fetch = "https://external-url.com/assets/logo-gouv.png"
responses.get(url_to_fetch, body=b"", status=200, content_type="image/png")
@@ -73,14 +88,22 @@ def test_api_docs_cors_proxy_anonymous_document_not_public():
}
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
@responses.activate
def test_api_docs_cors_proxy_authenticated_user_accessing_protected_doc():
def test_api_docs_cors_proxy_authenticated_user_accessing_protected_doc(
mock_getaddrinfo,
):
"""
Test the CORS proxy API for documents with an authenticated user accessing a protected
document.
"""
document = factories.DocumentFactory(link_reach="authenticated")
# Mock DNS resolution to return a public IP address
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
]
user = factories.UserFactory()
client = APIClient()
@@ -115,14 +138,22 @@ def test_api_docs_cors_proxy_authenticated_user_accessing_protected_doc():
assert response.streaming_content
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
@responses.activate
def test_api_docs_cors_proxy_authenticated_not_accessing_restricted_doc():
def test_api_docs_cors_proxy_authenticated_not_accessing_restricted_doc(
mock_getaddrinfo,
):
"""
Test the CORS proxy API for documents with an authenticated user not accessing a restricted
document.
"""
document = factories.DocumentFactory(link_reach="restricted")
# Mock DNS resolution to return a public IP address
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
]
user = factories.UserFactory()
client = APIClient()
@@ -138,18 +169,72 @@ def test_api_docs_cors_proxy_authenticated_not_accessing_restricted_doc():
}
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
@responses.activate
def test_api_docs_cors_proxy_unsupported_media_type():
def test_api_docs_cors_proxy_unsupported_media_type(mock_getaddrinfo):
"""Test the CORS proxy API for documents with an unsupported media type."""
document = factories.DocumentFactory(link_reach="public")
# Mock DNS resolution to return a public IP address
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
]
client = APIClient()
url_to_fetch = "https://external-url.com/assets/index.html"
responses.get(url_to_fetch, body=b"", status=200, content_type="text/html")
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 415
assert response.status_code == 400
assert response.json() == {"detail": "Invalid URL used."}
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
@responses.activate
def test_api_docs_cors_proxy_redirect(mock_getaddrinfo):
"""Test the CORS proxy API for documents with a redirect."""
document = factories.DocumentFactory(link_reach="public")
# Mock DNS resolution to return a public IP address
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
]
client = APIClient()
url_to_fetch = "https://external-url.com/assets/index.html"
responses.get(
url_to_fetch,
body=b"",
status=302,
headers={"Location": "https://external-url.com/other/assets/index.html"},
)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json() == {"detail": "Invalid URL used."}
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
@responses.activate
def test_api_docs_cors_proxy_url_not_returning_200(mock_getaddrinfo):
"""Test the CORS proxy API for documents with a URL that does not return 200."""
document = factories.DocumentFactory(link_reach="public")
# Mock DNS resolution to return a public IP address
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
]
client = APIClient()
url_to_fetch = "https://external-url.com/assets/index.html"
responses.get(url_to_fetch, body=b"", status=404)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json() == {"detail": "Invalid URL used."}
@pytest.mark.parametrize(
@@ -173,11 +258,17 @@ def test_api_docs_cors_proxy_invalid_url(url_to_fetch):
assert response.json() == ["Enter a valid URL."]
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
@responses.activate
def test_api_docs_cors_proxy_request_failed():
def test_api_docs_cors_proxy_request_failed(mock_getaddrinfo):
"""Test the CORS proxy API for documents with a request failed."""
document = factories.DocumentFactory(link_reach="public")
# Mock DNS resolution to return a public IP address
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0))
]
client = APIClient()
url_to_fetch = "https://external-url.com/assets/index.html"
responses.get(url_to_fetch, body=RequestException("Connection refused"))
@@ -185,6 +276,164 @@ def test_api_docs_cors_proxy_request_failed():
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json() == {
"error": "Failed to fetch resource from https://external-url.com/assets/index.html"
}
assert response.json() == {"detail": "Invalid URL used."}
@pytest.mark.parametrize(
"url_to_fetch",
[
"http://localhost/image.png",
"https://localhost/image.png",
"http://127.0.0.1/image.png",
"https://127.0.0.1/image.png",
"http://0.0.0.0/image.png",
"https://0.0.0.0/image.png",
"http://[::1]/image.png",
"https://[::1]/image.png",
"http://[0:0:0:0:0:0:0:1]/image.png",
"https://[0:0:0:0:0:0:0:1]/image.png",
],
)
def test_api_docs_cors_proxy_blocks_localhost(url_to_fetch):
"""Test that the CORS proxy API blocks localhost variations."""
document = factories.DocumentFactory(link_reach="public")
client = APIClient()
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json()["detail"] == "Invalid URL used."
@pytest.mark.parametrize(
"url_to_fetch",
[
"http://10.0.0.1/image.png",
"https://10.0.0.1/image.png",
"http://172.16.0.1/image.png",
"https://172.16.0.1/image.png",
"http://192.168.1.1/image.png",
"https://192.168.1.1/image.png",
"http://10.255.255.255/image.png",
"https://10.255.255.255/image.png",
"http://172.31.255.255/image.png",
"https://172.31.255.255/image.png",
"http://192.168.255.255/image.png",
"https://192.168.255.255/image.png",
],
)
def test_api_docs_cors_proxy_blocks_private_ips(url_to_fetch):
"""Test that the CORS proxy API blocks private IP addresses."""
document = factories.DocumentFactory(link_reach="public")
client = APIClient()
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json()["detail"] == "Invalid URL used."
@pytest.mark.parametrize(
"url_to_fetch",
[
"http://169.254.1.1/image.png",
"https://169.254.1.1/image.png",
"http://169.254.255.255/image.png",
"https://169.254.255.255/image.png",
],
)
def test_api_docs_cors_proxy_blocks_link_local(url_to_fetch):
"""Test that the CORS proxy API blocks link-local addresses."""
document = factories.DocumentFactory(link_reach="public")
client = APIClient()
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json()["detail"] == "Invalid URL used."
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
@responses.activate
def test_api_docs_cors_proxy_blocks_dns_rebinding_to_private_ip(mock_getaddrinfo):
"""Test that the CORS proxy API blocks DNS rebinding attacks to private IPs."""
document = factories.DocumentFactory(link_reach="public")
# Mock DNS resolution to return a private IP address
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.1", 0))
]
client = APIClient()
url_to_fetch = "https://malicious-domain.com/image.png"
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json()["detail"] == "Invalid URL used."
mock_getaddrinfo.assert_called_once()
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
@responses.activate
def test_api_docs_cors_proxy_blocks_dns_rebinding_to_localhost(mock_getaddrinfo):
"""Test that the CORS proxy API blocks DNS rebinding attacks to localhost."""
document = factories.DocumentFactory(link_reach="public")
# Mock DNS resolution to return localhost
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("127.0.0.1", 0))
]
client = APIClient()
url_to_fetch = "https://malicious-domain.com/image.png"
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json()["detail"] == "Invalid URL used."
mock_getaddrinfo.assert_called_once()
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
def test_api_docs_cors_proxy_handles_dns_resolution_failure(mock_getaddrinfo):
"""Test that the CORS proxy API handles DNS resolution failures gracefully."""
document = factories.DocumentFactory(link_reach="public")
# Mock DNS resolution to fail
mock_getaddrinfo.side_effect = socket.gaierror("Name or service not known")
client = APIClient()
url_to_fetch = "https://nonexistent-domain-12345.com/image.png"
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json()["detail"] == "Invalid URL used."
mock_getaddrinfo.assert_called_once()
@unittest.mock.patch("core.api.viewsets.socket.getaddrinfo")
def test_api_docs_cors_proxy_blocks_multiple_resolved_ips_if_any_private(
mock_getaddrinfo,
):
"""Test that the CORS proxy API blocks if any resolved IP is private."""
document = factories.DocumentFactory(link_reach="public")
# Mock DNS resolution to return both public and private IPs
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("8.8.8.8", 0)),
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.1", 0)),
]
client = APIClient()
url_to_fetch = "https://example.com/image.png"
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json()["detail"] == "Invalid URL used."
mock_getaddrinfo.assert_called_once()

View File

@@ -16,6 +16,7 @@ from rest_framework.test import APIClient
from core import factories
from core.api.serializers import ServerCreateDocumentSerializer
from core.models import Document, Invitation, User
from core.services import mime_types
from core.services.converter_services import ConversionError, YdocConverter
pytestmark = pytest.mark.django_db
@@ -191,7 +192,9 @@ def test_api_documents_create_for_owner_existing(mock_convert_md):
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
mock_convert_md.assert_called_once_with(
"Document content", mime_types.MARKDOWN, mime_types.YJS
)
document = Document.objects.get()
assert response.json() == {"id": str(document.id)}
@@ -236,7 +239,9 @@ def test_api_documents_create_for_owner_new_user(mock_convert_md):
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
mock_convert_md.assert_called_once_with(
"Document content", mime_types.MARKDOWN, mime_types.YJS
)
document = Document.objects.get()
assert response.json() == {"id": str(document.id)}
@@ -297,7 +302,9 @@ def test_api_documents_create_for_owner_existing_user_email_no_sub_with_fallback
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
mock_convert_md.assert_called_once_with(
"Document content", mime_types.MARKDOWN, mime_types.YJS
)
document = Document.objects.get()
assert response.json() == {"id": str(document.id)}
@@ -393,7 +400,9 @@ def test_api_documents_create_for_owner_new_user_no_sub_no_fallback_allow_duplic
HTTP_AUTHORIZATION="Bearer DummyToken",
)
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
mock_convert_md.assert_called_once_with(
"Document content", mime_types.MARKDOWN, mime_types.YJS
)
document = Document.objects.get()
assert response.json() == {"id": str(document.id)}
@@ -474,7 +483,9 @@ def test_api_documents_create_for_owner_with_default_language(
)
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
mock_convert_md.assert_called_once_with(
"Document content", mime_types.MARKDOWN, mime_types.YJS
)
assert mock_send.call_args[0][3] == "de-de"
@@ -501,7 +512,9 @@ def test_api_documents_create_for_owner_with_custom_language(mock_convert_md):
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
mock_convert_md.assert_called_once_with(
"Document content", mime_types.MARKDOWN, mime_types.YJS
)
assert len(mail.outbox) == 1
email = mail.outbox[0]
@@ -537,7 +550,9 @@ def test_api_documents_create_for_owner_with_custom_subject_and_message(
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
mock_convert_md.assert_called_once_with(
"Document content", mime_types.MARKDOWN, mime_types.YJS
)
assert len(mail.outbox) == 1
email = mail.outbox[0]
@@ -571,7 +586,9 @@ def test_api_documents_create_for_owner_with_converter_exception(
format="json",
HTTP_AUTHORIZATION="Bearer DummyToken",
)
mock_convert_md.assert_called_once_with("Document content")
mock_convert_md.assert_called_once_with(
"Document content", mime_types.MARKDOWN, mime_types.YJS
)
assert response.status_code == 400
assert response.json() == {"content": ["Could not convert content"]}

View File

@@ -0,0 +1,413 @@
"""
Tests for Documents API endpoint in impress's core app: create with file upload
"""
from base64 import b64decode, binascii
from io import BytesIO
from unittest.mock import patch
import pytest
from rest_framework.test import APIClient
from core import factories
from core.models import Document
from core.services import mime_types
from core.services.converter_services import (
ConversionError,
ServiceUnavailableError,
)
pytestmark = pytest.mark.django_db
def test_api_documents_create_with_file_anonymous():
"""Anonymous users should not be allowed to create documents with file upload."""
# Create a fake DOCX file
file_content = b"fake docx content"
file = BytesIO(file_content)
file.name = "test_document.docx"
response = APIClient().post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 401
assert not Document.objects.exists()
@patch("core.services.converter_services.Converter.convert")
def test_api_documents_create_with_docx_file_success(mock_convert):
"""
Authenticated users should be able to create documents by uploading a DOCX file.
The file should be converted to YJS format and the title should be set from filename.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Mock the conversion
converted_yjs = "base64encodedyjscontent"
mock_convert.return_value = converted_yjs
# Create a fake DOCX file
file_content = b"fake docx content"
file = BytesIO(file_content)
file.name = "My Important Document.docx"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 201
document = Document.objects.get()
assert document.title == "My Important Document.docx"
assert document.content == converted_yjs
assert document.accesses.filter(role="owner", user=user).exists()
# Verify the converter was called correctly
mock_convert.assert_called_once_with(
file_content,
content_type=mime_types.DOCX,
accept=mime_types.YJS,
)
@patch("core.services.converter_services.Converter.convert")
def test_api_documents_create_with_markdown_file_success(mock_convert):
"""
Authenticated users should be able to create documents by uploading a Markdown file.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Mock the conversion
converted_yjs = "base64encodedyjscontent"
mock_convert.return_value = converted_yjs
# Create a fake Markdown file
file_content = b"# Test Document\n\nThis is a test."
file = BytesIO(file_content)
file.name = "readme.md"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 201
document = Document.objects.get()
assert document.title == "readme.md"
assert document.content == converted_yjs
assert document.accesses.filter(role="owner", user=user).exists()
# Verify the converter was called correctly
mock_convert.assert_called_once_with(
file_content,
content_type=mime_types.MARKDOWN,
accept=mime_types.YJS,
)
@patch("core.services.converter_services.Converter.convert")
def test_api_documents_create_with_file_and_explicit_title(mock_convert):
"""
When both file and title are provided, the filename should override the title.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Mock the conversion
converted_yjs = "base64encodedyjscontent"
mock_convert.return_value = converted_yjs
# Create a fake DOCX file
file_content = b"fake docx content"
file = BytesIO(file_content)
file.name = "Uploaded Document.docx"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
"title": "This should be overridden",
},
format="multipart",
)
assert response.status_code == 201
document = Document.objects.get()
# The filename should take precedence
assert document.title == "Uploaded Document.docx"
def test_api_documents_create_with_empty_file():
"""
Creating a document with an empty file should fail with a validation error.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Create an empty file
file = BytesIO(b"")
file.name = "empty.docx"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 400
assert response.json() == {"file": ["The submitted file is empty."]}
assert not Document.objects.exists()
@patch("core.services.converter_services.Converter.convert")
def test_api_documents_create_with_file_conversion_error(mock_convert):
"""
When conversion fails, the API should return a 400 error with appropriate message.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Mock the conversion to raise an error
mock_convert.side_effect = ConversionError("Failed to convert document")
# Create a fake DOCX file
file_content = b"fake invalid docx content"
file = BytesIO(file_content)
file.name = "corrupted.docx"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 400
assert response.json() == {"file": ["Could not convert file content"]}
assert not Document.objects.exists()
@patch("core.services.converter_services.Converter.convert")
def test_api_documents_create_with_file_service_unavailable(mock_convert):
"""
When the conversion service is unavailable, appropriate error should be returned.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Mock the conversion to raise ServiceUnavailableError
mock_convert.side_effect = ServiceUnavailableError(
"Failed to connect to conversion service"
)
# Create a fake DOCX file
file_content = b"fake docx content"
file = BytesIO(file_content)
file.name = "document.docx"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 400
assert response.json() == {"file": ["Could not convert file content"]}
assert not Document.objects.exists()
def test_api_documents_create_without_file_still_works():
"""
Creating a document without a file should still work as before (backward compatibility).
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/documents/",
{
"title": "Regular document without file",
},
format="json",
)
assert response.status_code == 201
document = Document.objects.get()
assert document.title == "Regular document without file"
assert document.content is None
assert document.accesses.filter(role="owner", user=user).exists()
@patch("core.services.converter_services.Converter.convert")
def test_api_documents_create_with_file_null_value(mock_convert):
"""
Passing file=null should be treated as no file upload.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/documents/",
{
"title": "Document with null file",
"file": None,
},
format="json",
)
assert response.status_code == 201
document = Document.objects.get()
assert document.title == "Document with null file"
# Converter should not have been called
mock_convert.assert_not_called()
@patch("core.services.converter_services.Converter.convert")
def test_api_documents_create_with_file_preserves_content_format(mock_convert):
"""
Verify that the converted content is stored correctly in the document.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Mock the conversion with realistic base64-encoded YJS data
converted_yjs = "AQMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICA="
mock_convert.return_value = converted_yjs
# Create a fake DOCX file
file_content = b"fake docx with complex formatting"
file = BytesIO(file_content)
file.name = "complex_document.docx"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 201
document = Document.objects.get()
# Verify the content is stored as returned by the converter
assert document.content == converted_yjs
# Verify it's valid base64 (can be decoded)
try:
b64decode(converted_yjs)
except binascii.Error:
pytest.fail("Content should be valid base64-encoded data")
@patch("core.services.converter_services.Converter.convert")
def test_api_documents_create_with_file_unicode_filename(mock_convert):
"""
Test that Unicode characters in filenames are handled correctly.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Mock the conversion
converted_yjs = "base64encodedyjscontent"
mock_convert.return_value = converted_yjs
# Create a file with Unicode characters in the name
file_content = b"fake docx content"
file = BytesIO(file_content)
file.name = "文档-télécharger-документ.docx"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 201
document = Document.objects.get()
assert document.title == "文档-télécharger-документ.docx"
def test_api_documents_create_with_file_max_size_exceeded(settings):
"""
The uploaded file should not exceed the maximum size in settings.
"""
settings.CONVERSION_FILE_MAX_SIZE = 1 # 1 byte for test
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = BytesIO(b"a" * (10))
file.name = "test.docx"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 400
assert response.json() == {"file": ["File size exceeds the maximum limit of 0 MB."]}
def test_api_documents_create_with_file_extension_not_allowed(settings):
"""
The uploaded file should not have an allowed extension.
"""
settings.CONVERSION_FILE_EXTENSIONS_ALLOWED = [".docx"]
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = BytesIO(b"fake docx content")
file.name = "test.md"
response = client.post(
"/api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 400
assert response.json() == {
"file": [
"File extension .md is not allowed. Allowed extensions are: ['.docx']."
]
}

View File

@@ -83,3 +83,34 @@ def test_api_document_favorite_list_authenticated_with_favorite():
}
],
}
def test_api_document_favorite_list_with_favorite_children():
"""Authenticated users should receive their favorite documents, including children."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
root = factories.DocumentFactory(creator=user, users=[user])
children = factories.DocumentFactory.create_batch(
2, parent=root, favorited_by=[user]
)
access = factories.UserDocumentAccessFactory(
user=user, role=models.RoleChoices.READER, document__favorited_by=[user]
)
other_root = factories.DocumentFactory(creator=user, users=[user])
factories.DocumentFactory.create_batch(2, parent=other_root)
response = client.get("/api/v1.0/documents/favorite_list/")
assert response.status_code == 200
assert response.json()["count"] == 3
content = response.json()["results"]
assert content[0]["id"] == str(children[0].id)
assert content[1]["id"] == str(children[1].id)
assert content[2]["id"] == str(access.document.id)

View File

@@ -29,8 +29,7 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"abilities": {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": False,
"ai_translate": False,
"ai_proxy": False,
"attachment_upload": document.link_role == "editor",
"can_edit": document.link_role == "editor",
"children_create": False,
@@ -107,8 +106,7 @@ def test_api_documents_retrieve_anonymous_public_parent():
"abilities": {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": False,
"ai_translate": False,
"ai_proxy": False,
"attachment_upload": grand_parent.link_role == "editor",
"can_edit": grand_parent.link_role == "editor",
"children_create": False,
@@ -215,8 +213,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"abilities": {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": document.link_role == "editor",
"ai_translate": document.link_role == "editor",
"ai_proxy": document.link_role == "editor",
"attachment_upload": document.link_role == "editor",
"can_edit": document.link_role == "editor",
"children_create": document.link_role == "editor",
@@ -300,8 +297,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
"abilities": {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": grand_parent.link_role == "editor",
"ai_translate": grand_parent.link_role == "editor",
"ai_proxy": grand_parent.link_role == "editor",
"attachment_upload": grand_parent.link_role == "editor",
"can_edit": grand_parent.link_role == "editor",
"children_create": grand_parent.link_role == "editor",
@@ -498,6 +494,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"],

View File

@@ -72,8 +72,7 @@ def test_api_documents_trashbin_format():
"abilities": {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": False,
"ai_translate": False,
"ai_proxy": False,
"attachment_upload": False,
"can_edit": False,
"children_create": False,

View File

@@ -1,46 +0,0 @@
"""
Tests for Templates API endpoint in impress's core app: create
"""
import pytest
from rest_framework.test import APIClient
from core import factories
from core.models import Template
pytestmark = pytest.mark.django_db
def test_api_templates_create_anonymous():
"""Anonymous users should not be allowed to create templates."""
response = APIClient().post(
"/api/v1.0/templates/",
{
"title": "my template",
},
)
assert response.status_code == 401
assert not Template.objects.exists()
def test_api_templates_create_authenticated():
"""
Authenticated users should be able to create templates and should automatically be declared
as the owner of the newly created template.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/templates/",
{
"title": "my template",
},
format="json",
)
assert response.status_code == 405
assert not Template.objects.exists()

View File

@@ -1,45 +0,0 @@
"""
Tests for Templates API endpoint in impress's core app: delete
"""
import random
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
def test_api_templates_delete_anonymous():
"""Anonymous users should not be allowed to destroy a template."""
template = factories.TemplateFactory()
response = APIClient().delete(
f"/api/v1.0/templates/{template.id!s}/",
)
assert response.status_code == 401
assert models.Template.objects.count() == 1
def test_api_templates_delete_not_implemented():
"""
Authenticated users should not be allowed to delete a template to which they are not
related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
is_public = random.choice([True, False])
template = factories.TemplateFactory(is_public=is_public, users=[(user, "owner")])
response = client.delete(
f"/api/v1.0/templates/{template.id!s}/",
)
assert response.status_code == 405
assert models.Template.objects.count() == 1

View File

@@ -1,237 +0,0 @@
"""
Tests for Templates API endpoint in impress's core app: list
"""
from unittest import mock
import pytest
from rest_framework.pagination import PageNumberPagination
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
def test_api_templates_list_anonymous():
"""Anonymous users should only be able to list public templates."""
factories.TemplateFactory.create_batch(2, is_public=False)
public_templates = factories.TemplateFactory.create_batch(2, is_public=True)
expected_ids = {str(template.id) for template in public_templates}
response = APIClient().get("/api/v1.0/templates/")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 2
results_id = {result["id"] for result in results}
assert expected_ids == results_id
def test_api_templates_list_authenticated_direct():
"""
Authenticated users should be able to list templates they are a direct
owner/administrator/member of or that are public.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
related_templates = [
access.template
for access in factories.UserTemplateAccessFactory.create_batch(5, user=user)
]
public_templates = factories.TemplateFactory.create_batch(2, is_public=True)
factories.TemplateFactory.create_batch(2, is_public=False)
expected_ids = {
str(template.id) for template in related_templates + public_templates
}
response = client.get(
"/api/v1.0/templates/",
)
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 7
results_id = {result["id"] for result in results}
assert expected_ids == results_id
def test_api_templates_list_authenticated_via_team(mock_user_teams):
"""
Authenticated users should be able to list templates they are a
owner/administrator/member of via a team or that are public.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
mock_user_teams.return_value = ["team1", "team2", "unknown"]
templates_team1 = [
access.template
for access in factories.TeamTemplateAccessFactory.create_batch(2, team="team1")
]
templates_team2 = [
access.template
for access in factories.TeamTemplateAccessFactory.create_batch(3, team="team2")
]
public_templates = factories.TemplateFactory.create_batch(2, is_public=True)
factories.TemplateFactory.create_batch(2, is_public=False)
expected_ids = {
str(template.id)
for template in templates_team1 + templates_team2 + public_templates
}
response = client.get("/api/v1.0/templates/")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 7
results_id = {result["id"] for result in results}
assert expected_ids == results_id
@mock.patch.object(PageNumberPagination, "get_page_size", return_value=2)
def test_api_templates_list_pagination(
_mock_page_size,
):
"""Pagination should work as expected."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template_ids = [
str(access.template_id)
for access in factories.UserTemplateAccessFactory.create_batch(3, user=user)
]
# Get page 1
response = client.get(
"/api/v1.0/templates/",
)
assert response.status_code == 200
content = response.json()
assert content["count"] == 3
assert content["next"] == "http://testserver/api/v1.0/templates/?page=2"
assert content["previous"] is None
assert len(content["results"]) == 2
for item in content["results"]:
template_ids.remove(item["id"])
# Get page 2
response = client.get(
"/api/v1.0/templates/?page=2",
)
assert response.status_code == 200
content = response.json()
assert content["count"] == 3
assert content["next"] is None
assert content["previous"] == "http://testserver/api/v1.0/templates/"
assert len(content["results"]) == 1
template_ids.remove(content["results"][0]["id"])
assert template_ids == []
def test_api_templates_list_authenticated_distinct():
"""A template with several related users should only be listed once."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
template = factories.TemplateFactory(users=[user, other_user], is_public=True)
response = client.get(
"/api/v1.0/templates/",
)
assert response.status_code == 200
content = response.json()
assert len(content["results"]) == 1
assert content["results"][0]["id"] == str(template.id)
def test_api_templates_list_order_default():
"""The templates list should be sorted by 'created_at' in descending order by default."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template_ids = [
str(access.template.id)
for access in factories.UserTemplateAccessFactory.create_batch(5, user=user)
]
response = client.get(
"/api/v1.0/templates/",
)
assert response.status_code == 200
response_data = response.json()
response_template_ids = [template["id"] for template in response_data["results"]]
template_ids.reverse()
assert response_template_ids == template_ids, (
"created_at values are not sorted from newest to oldest"
)
def test_api_templates_list_order_param():
"""
The templates list is sorted by 'created_at' in ascending order when setting
the "ordering" query parameter.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
templates_ids = [
str(access.template.id)
for access in factories.UserTemplateAccessFactory.create_batch(5, user=user)
]
response = client.get(
"/api/v1.0/templates/?ordering=created_at",
)
assert response.status_code == 200
response_data = response.json()
response_template_ids = [template["id"] for template in response_data["results"]]
assert response_template_ids == templates_ids, (
"created_at values are not sorted from oldest to newest"
)
def test_api_template_throttling(settings):
"""Test api template throttling."""
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["template"]
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["template"] = "2/minute"
client = APIClient()
for _i in range(2):
response = client.get("/api/v1.0/templates/")
assert response.status_code == 200
with mock.patch("core.api.throttling.capture_message") as mock_capture_message:
response = client.get("/api/v1.0/templates/")
assert response.status_code == 429
mock_capture_message.assert_called_once_with(
"Rate limit exceeded for scope template", "warning"
)
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["template"] = current_rate

View File

@@ -1,522 +0,0 @@
"""
Tests for Templates API endpoint in impress's core app: retrieve
"""
import pytest
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
def test_api_templates_retrieve_anonymous_public():
"""Anonymous users should be allowed to retrieve public templates."""
template = factories.TemplateFactory(is_public=True)
response = APIClient().get(f"/api/v1.0/templates/{template.id!s}/")
assert response.status_code == 200
assert response.json() == {
"id": str(template.id),
"abilities": {
"destroy": False,
"generate_document": True,
"accesses_manage": False,
"partial_update": False,
"retrieve": True,
"update": False,
},
"accesses": [],
"title": template.title,
"is_public": True,
"code": template.code,
"css": template.css,
}
def test_api_templates_retrieve_anonymous_not_public():
"""Anonymous users should not be able to retrieve a template that is not public."""
template = factories.TemplateFactory(is_public=False)
response = APIClient().get(f"/api/v1.0/templates/{template.id!s}/")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_templates_retrieve_authenticated_unrelated_public():
"""
Authenticated users should be able to retrieve a public template to which they are
not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory(is_public=True)
response = client.get(
f"/api/v1.0/templates/{template.id!s}/",
)
assert response.status_code == 200
assert response.json() == {
"id": str(template.id),
"abilities": {
"destroy": False,
"generate_document": True,
"accesses_manage": False,
"partial_update": False,
"retrieve": True,
"update": False,
},
"accesses": [],
"title": template.title,
"is_public": True,
"code": template.code,
"css": template.css,
}
def test_api_templates_retrieve_authenticated_unrelated_not_public():
"""
Authenticated users should not be allowed to retrieve a template that is not public and
to which they are not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory(is_public=False)
response = client.get(
f"/api/v1.0/templates/{template.id!s}/",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
def test_api_templates_retrieve_authenticated_related_direct():
"""
Authenticated users should be allowed to retrieve a template to which they
are directly related whatever the role.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
access1 = factories.UserTemplateAccessFactory(template=template, user=user)
access2 = factories.UserTemplateAccessFactory(template=template)
response = client.get(
f"/api/v1.0/templates/{template.id!s}/",
)
assert response.status_code == 200
content = response.json()
assert sorted(content.pop("accesses"), key=lambda x: x["user"]) == sorted(
[
{
"id": str(access1.id),
"user": str(user.id),
"team": "",
"role": access1.role,
"abilities": access1.get_abilities(user),
},
{
"id": str(access2.id),
"user": str(access2.user.id),
"team": "",
"role": access2.role,
"abilities": access2.get_abilities(user),
},
],
key=lambda x: x["user"],
)
assert response.json() == {
"id": str(template.id),
"title": template.title,
"abilities": template.get_abilities(user),
"is_public": template.is_public,
"code": template.code,
"css": template.css,
}
def test_api_templates_retrieve_authenticated_related_team_none(mock_user_teams):
"""
Authenticated users should not be able to retrieve a template related to teams in
which the user is not.
"""
mock_user_teams.return_value = []
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory(is_public=False)
factories.TeamTemplateAccessFactory(
template=template, team="readers", role="reader"
)
factories.TeamTemplateAccessFactory(
template=template, team="editors", role="editor"
)
factories.TeamTemplateAccessFactory(
template=template, team="administrators", role="administrator"
)
factories.TeamTemplateAccessFactory(template=template, team="owners", role="owner")
factories.TeamTemplateAccessFactory(template=template)
factories.TeamTemplateAccessFactory()
response = client.get(f"/api/v1.0/templates/{template.id!s}/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
@pytest.mark.parametrize(
"teams",
[
["readers"],
["unknown", "readers"],
["editors"],
["unknown", "editors"],
],
)
def test_api_templates_retrieve_authenticated_related_team_readers_or_editors(
teams, mock_user_teams
):
"""
Authenticated users should be allowed to retrieve a template to which they
are related via a team whatever the role and see all its accesses.
"""
mock_user_teams.return_value = teams
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory(is_public=False)
access_reader = factories.TeamTemplateAccessFactory(
template=template, team="readers", role="reader"
)
access_editor = factories.TeamTemplateAccessFactory(
template=template, team="editors", role="editor"
)
access_administrator = factories.TeamTemplateAccessFactory(
template=template, team="administrators", role="administrator"
)
access_owner = factories.TeamTemplateAccessFactory(
template=template, team="owners", role="owner"
)
other_access = factories.TeamTemplateAccessFactory(template=template)
factories.TeamTemplateAccessFactory()
response = client.get(f"/api/v1.0/templates/{template.id!s}/")
assert response.status_code == 200
content = response.json()
expected_abilities = {
"destroy": False,
"retrieve": True,
"set_role_to": [],
"update": False,
"partial_update": False,
}
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
[
{
"id": str(access_reader.id),
"user": None,
"team": "readers",
"role": access_reader.role,
"abilities": expected_abilities,
},
{
"id": str(access_editor.id),
"user": None,
"team": "editors",
"role": access_editor.role,
"abilities": expected_abilities,
},
{
"id": str(access_administrator.id),
"user": None,
"team": "administrators",
"role": access_administrator.role,
"abilities": expected_abilities,
},
{
"id": str(access_owner.id),
"user": None,
"team": "owners",
"role": access_owner.role,
"abilities": expected_abilities,
},
{
"id": str(other_access.id),
"user": None,
"team": other_access.team,
"role": other_access.role,
"abilities": expected_abilities,
},
],
key=lambda x: x["id"],
)
assert response.json() == {
"id": str(template.id),
"title": template.title,
"abilities": template.get_abilities(user),
"is_public": False,
"code": template.code,
"css": template.css,
}
@pytest.mark.parametrize(
"teams",
[
["administrators"],
["members", "administrators"],
["unknown", "administrators"],
],
)
def test_api_templates_retrieve_authenticated_related_team_administrators(
teams, mock_user_teams
):
"""
Authenticated users should be allowed to retrieve a template to which they
are related via a team whatever the role and see all its accesses.
"""
mock_user_teams.return_value = teams
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory(is_public=False)
access_reader = factories.TeamTemplateAccessFactory(
template=template, team="readers", role="reader"
)
access_editor = factories.TeamTemplateAccessFactory(
template=template, team="editors", role="editor"
)
access_administrator = factories.TeamTemplateAccessFactory(
template=template, team="administrators", role="administrator"
)
access_owner = factories.TeamTemplateAccessFactory(
template=template, team="owners", role="owner"
)
other_access = factories.TeamTemplateAccessFactory(template=template)
factories.TeamTemplateAccessFactory()
response = client.get(f"/api/v1.0/templates/{template.id!s}/")
assert response.status_code == 200
content = response.json()
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
[
{
"id": str(access_reader.id),
"user": None,
"team": "readers",
"role": "reader",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["administrator", "editor"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_editor.id),
"user": None,
"team": "editors",
"role": "editor",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["administrator", "reader"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_administrator.id),
"user": None,
"team": "administrators",
"role": "administrator",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["editor", "reader"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_owner.id),
"user": None,
"team": "owners",
"role": "owner",
"abilities": {
"destroy": False,
"retrieve": True,
"set_role_to": [],
"update": False,
"partial_update": False,
},
},
{
"id": str(other_access.id),
"user": None,
"team": other_access.team,
"role": other_access.role,
"abilities": other_access.get_abilities(user),
},
],
key=lambda x: x["id"],
)
assert response.json() == {
"id": str(template.id),
"title": template.title,
"abilities": template.get_abilities(user),
"is_public": False,
"code": template.code,
"css": template.css,
}
@pytest.mark.parametrize(
"teams",
[
["owners"],
["owners", "administrators"],
["members", "administrators", "owners"],
["unknown", "owners"],
],
)
def test_api_templates_retrieve_authenticated_related_team_owners(
teams, mock_user_teams
):
"""
Authenticated users should be allowed to retrieve a template to which they
are related via a team whatever the role and see all its accesses.
"""
mock_user_teams.return_value = teams
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory(is_public=False)
access_reader = factories.TeamTemplateAccessFactory(
template=template, team="readers", role="reader"
)
access_editor = factories.TeamTemplateAccessFactory(
template=template, team="editors", role="editor"
)
access_administrator = factories.TeamTemplateAccessFactory(
template=template, team="administrators", role="administrator"
)
access_owner = factories.TeamTemplateAccessFactory(
template=template, team="owners", role="owner"
)
other_access = factories.TeamTemplateAccessFactory(template=template)
factories.TeamTemplateAccessFactory()
response = client.get(f"/api/v1.0/templates/{template.id!s}/")
assert response.status_code == 200
content = response.json()
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
[
{
"id": str(access_reader.id),
"user": None,
"team": "readers",
"role": "reader",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["owner", "administrator", "editor"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_editor.id),
"user": None,
"team": "editors",
"role": "editor",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["owner", "administrator", "reader"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_administrator.id),
"user": None,
"team": "administrators",
"role": "administrator",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["owner", "editor", "reader"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_owner.id),
"user": None,
"team": "owners",
"role": "owner",
"abilities": {
# editable only if there is another owner role than the user's team...
"destroy": other_access.role == "owner",
"retrieve": True,
"set_role_to": ["administrator", "editor", "reader"]
if other_access.role == "owner"
else [],
"update": other_access.role == "owner",
"partial_update": other_access.role == "owner",
},
},
{
"id": str(other_access.id),
"user": None,
"team": other_access.team,
"role": other_access.role,
"abilities": other_access.get_abilities(user),
},
],
key=lambda x: x["id"],
)
assert response.json() == {
"id": str(template.id),
"title": template.title,
"abilities": template.get_abilities(user),
"is_public": False,
"code": template.code,
"css": template.css,
}

View File

@@ -1,54 +0,0 @@
"""
Tests for Templates API endpoint in impress's core app: update
"""
import pytest
from rest_framework.test import APIClient
from core import factories
from core.api import serializers
pytestmark = pytest.mark.django_db
def test_api_templates_update_anonymous():
"""Anonymous users should not be allowed to update a template."""
template = factories.TemplateFactory()
new_template_values = serializers.TemplateSerializer(
instance=factories.TemplateFactory()
).data
response = APIClient().put(
f"/api/v1.0/templates/{template.id!s}/",
new_template_values,
format="json",
)
assert response.status_code == 401
def test_api_templates_update_not_implemented():
"""
Authenticated users should not be allowed to update a template.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory(users=[(user, "owner")])
new_template_values = serializers.TemplateSerializer(
instance=factories.TemplateFactory()
).data
response = client.put(
f"/api/v1.0/templates/{template.id!s}/", new_template_values, format="json"
)
assert response.status_code == 405
response = client.patch(
f"/api/v1.0/templates/{template.id!s}/", new_template_values, format="json"
)
assert response.status_code == 405

View File

@@ -19,7 +19,10 @@ pytestmark = pytest.mark.django_db
@override_settings(
AI_BOT={"name": "Test Bot", "color": "#000000"},
AI_FEATURE_ENABLED=False,
AI_MODEL="test-model",
AI_STREAM=False,
COLLABORATION_WS_URL="http://testcollab/",
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY=True,
CRISP_WEBSITE_ID="123",
@@ -43,9 +46,15 @@ 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_MODEL": "test-model",
"AI_FEATURE_ENABLED": False,
"AI_STREAM": False,
"COLLABORATION_WS_URL": "http://testcollab/",
"COLLABORATION_WS_NOT_CONNECTED_READY_ONLY": True,
"CONVERSION_FILE_EXTENSIONS_ALLOWED": [".docx", ".md"],
"CONVERSION_FILE_MAX_SIZE": 20971520,
"CRISP_WEBSITE_ID": "123",
"ENVIRONMENT": "test",
"FRONTEND_CSS_URL": "http://testcss/",

View File

@@ -311,7 +311,7 @@ def test_api_users_list_query_short_queries():
"""
Queries shorter than 5 characters should return an empty result set.
"""
user = factories.UserFactory(email="paul@example.com")
user = factories.UserFactory(email="paul@example.com", full_name="Paul")
client = APIClient()
client.force_login(user)

View File

@@ -155,8 +155,7 @@ def test_models_documents_get_abilities_forbidden(
expected_abilities = {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": False,
"ai_translate": False,
"ai_proxy": False,
"attachment_upload": False,
"can_edit": False,
"children_create": False,
@@ -220,8 +219,7 @@ def test_models_documents_get_abilities_reader(
expected_abilities = {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": False,
"ai_translate": False,
"ai_proxy": False,
"attachment_upload": False,
"can_edit": False,
"children_create": False,
@@ -357,8 +355,7 @@ def test_models_documents_get_abilities_editor(
expected_abilities = {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": is_authenticated,
"ai_translate": is_authenticated,
"ai_proxy": is_authenticated,
"attachment_upload": True,
"can_edit": True,
"children_create": is_authenticated,
@@ -413,8 +410,7 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
expected_abilities = {
"accesses_manage": True,
"accesses_view": True,
"ai_transform": True,
"ai_translate": True,
"ai_proxy": True,
"attachment_upload": True,
"can_edit": True,
"children_create": True,
@@ -501,8 +497,7 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
expected_abilities = {
"accesses_manage": True,
"accesses_view": True,
"ai_transform": True,
"ai_translate": True,
"ai_proxy": True,
"attachment_upload": True,
"can_edit": True,
"children_create": True,
@@ -557,8 +552,7 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
expected_abilities = {
"accesses_manage": False,
"accesses_view": True,
"ai_transform": True,
"ai_translate": True,
"ai_proxy": True,
"attachment_upload": True,
"can_edit": True,
"children_create": True,
@@ -620,8 +614,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_transform": access_from_link and ai_access_setting != "restricted",
"ai_translate": access_from_link and ai_access_setting != "restricted",
"ai_proxy": access_from_link and ai_access_setting != "restricted",
"attachment_upload": access_from_link,
"can_edit": access_from_link,
"children_create": access_from_link,
@@ -747,8 +740,7 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
assert abilities == {
"accesses_manage": False,
"accesses_view": True,
"ai_transform": False,
"ai_translate": False,
"ai_proxy": False,
"attachment_upload": False,
"can_edit": False,
"children_create": False,
@@ -878,8 +870,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_transform"] is True
assert abilities["ai_translate"] is True
assert abilities["ai_proxy"] is True
@override_settings(AI_ALLOW_REACH_FROM="authenticated")
@@ -897,8 +888,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_transform"] == is_authenticated
assert abilities["ai_translate"] == is_authenticated
assert abilities["ai_proxy"] == is_authenticated
def test_models_documents_get_versions_slice_pagination(settings):
@@ -1024,6 +1014,39 @@ def test_models_documents__email_invitation__success():
assert f"docs/{document.id}/" in email_content
@pytest.mark.parametrize(
"email_url_app",
[
"https://test-example.com", # Test with EMAIL_URL_APP set
None, # Test fallback to Site domain
],
)
def test_models_documents__email_invitation__url_app_param(email_url_app):
"""
Test that email invitation uses EMAIL_URL_APP when set, or falls back to Site domain.
"""
with override_settings(EMAIL_URL_APP=email_url_app):
document = factories.DocumentFactory()
sender = factories.UserFactory(
full_name="Test Sender", email="sender@example.com"
)
document.send_invitation_email(
"guest@example.com", models.RoleChoices.EDITOR, sender, "en"
)
# pylint: disable-next=no-member
email = mail.outbox[0]
email_content = " ".join(email.body.split())
# Determine expected domain
if email_url_app:
assert f"https://test-example.com/docs/{document.id}/" in email_content
else:
# Default Site domain is example.com
assert f"example.com/docs/{document.id}/" in email_content
def test_models_documents__email_invitation__success_empty_title():
"""
The email invitation is sent successfully.
@@ -1393,7 +1416,7 @@ def test_models_documents_restore_complex(django_assert_num_queries):
assert child2.ancestors_deleted_at == document.deleted_at
# Restore the item
with django_assert_num_queries(13):
with django_assert_num_queries(14):
document.restore()
document.refresh_from_db()
child1.refresh_from_db()

View File

@@ -1,419 +0,0 @@
"""
Unit tests for the TemplateAccess model
"""
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
import pytest
from core import factories
pytestmark = pytest.mark.django_db
def test_models_template_accesses_str():
"""
The str representation should include user email, template title and role.
"""
user = factories.UserFactory(email="david.bowman@example.com")
access = factories.UserTemplateAccessFactory(
role="reader",
user=user,
template__title="admins",
)
assert str(access) == "david.bowman@example.com is reader in template admins"
def test_models_template_accesses_unique_user():
"""Template accesses should be unique for a given couple of user and template."""
access = factories.UserTemplateAccessFactory()
with pytest.raises(
ValidationError,
match="This user is already in this template.",
):
factories.UserTemplateAccessFactory(user=access.user, template=access.template)
def test_models_template_accesses_several_empty_teams():
"""A template can have several template accesses with an empty team."""
access = factories.UserTemplateAccessFactory()
factories.UserTemplateAccessFactory(template=access.template)
def test_models_template_accesses_unique_team():
"""Template accesses should be unique for a given couple of team and template."""
access = factories.TeamTemplateAccessFactory()
with pytest.raises(
ValidationError,
match="This team is already in this template.",
):
factories.TeamTemplateAccessFactory(team=access.team, template=access.template)
def test_models_template_accesses_several_null_users():
"""A template can have several template accesses with a null user."""
access = factories.TeamTemplateAccessFactory()
factories.TeamTemplateAccessFactory(template=access.template)
def test_models_template_accesses_user_and_team_set():
"""User and team can't both be set on a template access."""
with pytest.raises(
ValidationError,
match="Either user or team must be set, not both.",
):
factories.UserTemplateAccessFactory(team="my-team")
def test_models_template_accesses_user_and_team_empty():
"""User and team can't both be empty on a template access."""
with pytest.raises(
ValidationError,
match="Either user or team must be set, not both.",
):
factories.UserTemplateAccessFactory(user=None)
# get_abilities
def test_models_template_access_get_abilities_anonymous():
"""Check abilities returned for an anonymous user."""
access = factories.UserTemplateAccessFactory()
abilities = access.get_abilities(AnonymousUser())
assert abilities == {
"destroy": False,
"retrieve": False,
"update": False,
"partial_update": False,
"set_role_to": [],
}
def test_models_template_access_get_abilities_authenticated():
"""Check abilities returned for an authenticated user."""
access = factories.UserTemplateAccessFactory()
user = factories.UserFactory()
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": False,
"update": False,
"partial_update": False,
"set_role_to": [],
}
# - for owner
def test_models_template_access_get_abilities_for_owner_of_self_allowed():
"""
Check abilities of self access for the owner of a template when
there is more than one owner left.
"""
access = factories.UserTemplateAccessFactory(role="owner")
factories.UserTemplateAccessFactory(template=access.template, role="owner")
abilities = access.get_abilities(access.user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["administrator", "editor", "reader"],
}
def test_models_template_access_get_abilities_for_owner_of_self_last():
"""
Check abilities of self access for the owner of a template when there is only one owner left.
"""
access = factories.UserTemplateAccessFactory(role="owner")
abilities = access.get_abilities(access.user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
def test_models_template_access_get_abilities_for_owner_of_owner():
"""Check abilities of owner access for the owner of a template."""
access = factories.UserTemplateAccessFactory(role="owner")
factories.UserTemplateAccessFactory(template=access.template) # another one
user = factories.UserTemplateAccessFactory(
template=access.template, role="owner"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["administrator", "editor", "reader"],
}
def test_models_template_access_get_abilities_for_owner_of_administrator():
"""Check abilities of administrator access for the owner of a template."""
access = factories.UserTemplateAccessFactory(role="administrator")
factories.UserTemplateAccessFactory(template=access.template) # another one
user = factories.UserTemplateAccessFactory(
template=access.template, role="owner"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["owner", "editor", "reader"],
}
def test_models_template_access_get_abilities_for_owner_of_editor():
"""Check abilities of editor access for the owner of a template."""
access = factories.UserTemplateAccessFactory(role="editor")
factories.UserTemplateAccessFactory(template=access.template) # another one
user = factories.UserTemplateAccessFactory(
template=access.template, role="owner"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["owner", "administrator", "reader"],
}
def test_models_template_access_get_abilities_for_owner_of_reader():
"""Check abilities of reader access for the owner of a template."""
access = factories.UserTemplateAccessFactory(role="reader")
factories.UserTemplateAccessFactory(template=access.template) # another one
user = factories.UserTemplateAccessFactory(
template=access.template, role="owner"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["owner", "administrator", "editor"],
}
# - for administrator
def test_models_template_access_get_abilities_for_administrator_of_owner():
"""Check abilities of owner access for the administrator of a template."""
access = factories.UserTemplateAccessFactory(role="owner")
factories.UserTemplateAccessFactory(template=access.template) # another one
user = factories.UserTemplateAccessFactory(
template=access.template, role="administrator"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
def test_models_template_access_get_abilities_for_administrator_of_administrator():
"""Check abilities of administrator access for the administrator of a template."""
access = factories.UserTemplateAccessFactory(role="administrator")
factories.UserTemplateAccessFactory(template=access.template) # another one
user = factories.UserTemplateAccessFactory(
template=access.template, role="administrator"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["editor", "reader"],
}
def test_models_template_access_get_abilities_for_administrator_of_editor():
"""Check abilities of editor access for the administrator of a template."""
access = factories.UserTemplateAccessFactory(role="editor")
factories.UserTemplateAccessFactory(template=access.template) # another one
user = factories.UserTemplateAccessFactory(
template=access.template, role="administrator"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["administrator", "reader"],
}
def test_models_template_access_get_abilities_for_administrator_of_reader():
"""Check abilities of reader access for the administrator of a template."""
access = factories.UserTemplateAccessFactory(role="reader")
factories.UserTemplateAccessFactory(template=access.template) # another one
user = factories.UserTemplateAccessFactory(
template=access.template, role="administrator"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["administrator", "editor"],
}
# - For editor
def test_models_template_access_get_abilities_for_editor_of_owner():
"""Check abilities of owner access for the editor of a template."""
access = factories.UserTemplateAccessFactory(role="owner")
factories.UserTemplateAccessFactory(template=access.template) # another one
user = factories.UserTemplateAccessFactory(
template=access.template, role="editor"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
def test_models_template_access_get_abilities_for_editor_of_administrator():
"""Check abilities of administrator access for the editor of a template."""
access = factories.UserTemplateAccessFactory(role="administrator")
factories.UserTemplateAccessFactory(template=access.template) # another one
user = factories.UserTemplateAccessFactory(
template=access.template, role="editor"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
def test_models_template_access_get_abilities_for_editor_of_editor_user(
django_assert_num_queries,
):
"""Check abilities of editor access for the editor of a template."""
access = factories.UserTemplateAccessFactory(role="editor")
factories.UserTemplateAccessFactory(template=access.template) # another one
user = factories.UserTemplateAccessFactory(
template=access.template, role="editor"
).user
with django_assert_num_queries(1):
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
# - For reader
def test_models_template_access_get_abilities_for_reader_of_owner():
"""Check abilities of owner access for the reader of a template."""
access = factories.UserTemplateAccessFactory(role="owner")
factories.UserTemplateAccessFactory(template=access.template) # another one
user = factories.UserTemplateAccessFactory(
template=access.template, role="reader"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
def test_models_template_access_get_abilities_for_reader_of_administrator():
"""Check abilities of administrator access for the reader of a template."""
access = factories.UserTemplateAccessFactory(role="administrator")
factories.UserTemplateAccessFactory(template=access.template) # another one
user = factories.UserTemplateAccessFactory(
template=access.template, role="reader"
).user
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
def test_models_template_access_get_abilities_for_reader_of_reader_user(
django_assert_num_queries,
):
"""Check abilities of reader access for the reader of a template."""
access = factories.UserTemplateAccessFactory(role="reader")
factories.UserTemplateAccessFactory(template=access.template) # another one
user = factories.UserTemplateAccessFactory(
template=access.template, role="reader"
).user
with django_assert_num_queries(1):
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}
def test_models_template_access_get_abilities_preset_role(django_assert_num_queries):
"""No query is done if the role is preset, e.g., with a query annotation."""
access = factories.UserTemplateAccessFactory(role="reader")
user = factories.UserTemplateAccessFactory(
template=access.template, role="reader"
).user
access.user_roles = ["reader"]
with django_assert_num_queries(0):
abilities = access.get_abilities(user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
}

View File

@@ -1,187 +0,0 @@
"""
Unit tests for the Template model
"""
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
import pytest
from core import factories, models
pytestmark = pytest.mark.django_db
def test_models_templates_str():
"""The str representation should be the title of the template."""
template = factories.TemplateFactory(title="admins")
assert str(template) == "admins"
def test_models_templates_id_unique():
"""The "id" field should be unique."""
template = factories.TemplateFactory()
with pytest.raises(ValidationError, match="Template with this Id already exists."):
factories.TemplateFactory(id=template.id)
def test_models_templates_title_null():
"""The "title" field should not be null."""
with pytest.raises(ValidationError, match="This field cannot be null."):
models.Template.objects.create(title=None)
def test_models_templates_title_empty():
"""The "title" field should not be empty."""
with pytest.raises(ValidationError, match="This field cannot be blank."):
models.Template.objects.create(title="")
def test_models_templates_title_max_length():
"""The "title" field should be 100 characters maximum."""
factories.TemplateFactory(title="a" * 255)
with pytest.raises(
ValidationError,
match=r"Ensure this value has at most 255 characters \(it has 256\)\.",
):
factories.TemplateFactory(title="a" * 256)
# get_abilities
def test_models_templates_get_abilities_anonymous_public():
"""Check abilities returned for an anonymous user if the template is public."""
template = factories.TemplateFactory(is_public=True)
abilities = template.get_abilities(AnonymousUser())
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": True,
}
def test_models_templates_get_abilities_anonymous_not_public():
"""Check abilities returned for an anonymous user if the template is private."""
template = factories.TemplateFactory(is_public=False)
abilities = template.get_abilities(AnonymousUser())
assert abilities == {
"destroy": False,
"retrieve": False,
"update": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": False,
}
def test_models_templates_get_abilities_authenticated_public():
"""Check abilities returned for an authenticated user if the user is public."""
template = factories.TemplateFactory(is_public=True)
abilities = template.get_abilities(factories.UserFactory())
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": True,
}
def test_models_templates_get_abilities_authenticated_not_public():
"""Check abilities returned for an authenticated user if the template is private."""
template = factories.TemplateFactory(is_public=False)
abilities = template.get_abilities(factories.UserFactory())
assert abilities == {
"destroy": False,
"retrieve": False,
"update": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": False,
}
def test_models_templates_get_abilities_owner():
"""Check abilities returned for the owner of a template."""
user = factories.UserFactory()
access = factories.UserTemplateAccessFactory(role="owner", user=user)
abilities = access.template.get_abilities(access.user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"accesses_manage": True,
"partial_update": True,
"generate_document": True,
}
def test_models_templates_get_abilities_administrator():
"""Check abilities returned for the administrator of a template."""
access = factories.UserTemplateAccessFactory(role="administrator")
abilities = access.template.get_abilities(access.user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": True,
"accesses_manage": True,
"partial_update": True,
"generate_document": True,
}
def test_models_templates_get_abilities_editor_user(django_assert_num_queries):
"""Check abilities returned for the editor of a template."""
access = factories.UserTemplateAccessFactory(role="editor")
with django_assert_num_queries(1):
abilities = access.template.get_abilities(access.user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": True,
"accesses_manage": False,
"partial_update": True,
"generate_document": True,
}
def test_models_templates_get_abilities_reader_user(django_assert_num_queries):
"""Check abilities returned for the reader of a template."""
access = factories.UserTemplateAccessFactory(role="reader")
with django_assert_num_queries(1):
abilities = access.template.get_abilities(access.user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": True,
}
def test_models_templates_get_abilities_preset_role(django_assert_num_queries):
"""No query is done if the role is preset e.g. with query annotation."""
access = factories.UserTemplateAccessFactory(role="reader")
access.template.user_roles = ["reader"]
with django_assert_num_queries(0):
abilities = access.template.get_abilities(access.user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": True,
}

View File

@@ -2,10 +2,9 @@
Test ai API endpoints in the impress core app.
"""
from unittest.mock import MagicMock, patch
from unittest.mock import patch
from django.core.exceptions import ImproperlyConfigured
from django.test.utils import override_settings
import pytest
from openai import OpenAIError
@@ -15,6 +14,15 @@ from core.services.ai_services import AIService
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
@pytest.mark.parametrize(
"setting_name, setting_value",
[
@@ -23,62 +31,105 @@ 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()
@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_proxy_client_error(mock_create):
"""Fail when the client raises an error"""
mock_create.side_effect = OpenAIError("Mocked client error")
with pytest.raises(
OpenAIError,
match="Mocked client error",
):
AIService().transform("hello", "prompt")
@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_invalid_response(mock_create):
"""Fail when the client response is invalid"""
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=None))]
)
with pytest.raises(
RuntimeError,
match="AI response does not contain an answer",
match="Failed to proxy AI request: Mocked client error",
):
AIService().transform("hello", "prompt")
AIService().proxy({"messages": [{"role": "user", "content": "hello"}]})
@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__success(mock_create):
def test_services_ai_proxy_success(mock_create):
"""The AI request should work as expect when called with valid arguments."""
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content="Salut"))]
mock_create.return_value = {
"id": "chatcmpl-test",
"object": "chat.completion",
"created": 1234567890,
"model": "test-model",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "Salut"},
"finish_reason": "stop",
}
],
}
response = AIService().proxy({"messages": [{"role": "user", "content": "hello"}]})
expected_response = {
"id": "chatcmpl-test",
"object": "chat.completion",
"created": 1234567890,
"model": "test-model",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "Salut"},
"finish_reason": "stop",
}
],
}
assert response == expected_response
mock_create.assert_called_once_with(
messages=[{"role": "user", "content": "hello"}], stream=False
)
response = AIService().transform("hello", "prompt")
assert response == {"answer": "Salut"}
@patch("openai.resources.chat.completions.Completions.create")
def test_services_ai_proxy_with_stream(mock_create):
"""The AI request should work as expect when called with valid arguments."""
mock_create.return_value = {
"id": "chatcmpl-test",
"object": "chat.completion",
"created": 1234567890,
"model": "test-model",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "Salut"},
"finish_reason": "stop",
}
],
}
response = AIService().proxy(
{"messages": [{"role": "user", "content": "hello"}]}, stream=True
)
expected_response = {
"id": "chatcmpl-test",
"object": "chat.completion",
"created": 1234567890,
"model": "test-model",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "Salut"},
"finish_reason": "stop",
}
],
}
assert response == expected_response
mock_create.assert_called_once_with(
messages=[{"role": "user", "content": "hello"}], stream=True
)

View File

@@ -0,0 +1,93 @@
"""Test Converter orchestration services."""
from unittest.mock import MagicMock, patch
from core.services import mime_types
from core.services.converter_services import Converter
@patch("core.services.converter_services.DocSpecConverter")
@patch("core.services.converter_services.YdocConverter")
def test_converter_docx_to_yjs_orchestration(mock_ydoc_class, mock_docspec_class):
"""Test that DOCX to YJS conversion uses both DocSpec and Ydoc converters."""
# Setup mocks
mock_docspec = MagicMock()
mock_ydoc = MagicMock()
mock_docspec_class.return_value = mock_docspec
mock_ydoc_class.return_value = mock_ydoc
# Mock the conversion chain: DOCX -> BlockNote -> YJS
blocknote_data = b'[{"type": "paragraph", "content": "test"}]'
yjs_data = "base64encodedyjs"
mock_docspec.convert.return_value = blocknote_data
mock_ydoc.convert.return_value = yjs_data
# Execute conversion
converter = Converter()
docx_data = b"fake docx data"
result = converter.convert(docx_data, mime_types.DOCX, mime_types.YJS)
# Verify the orchestration
mock_docspec.convert.assert_called_once_with(
docx_data, mime_types.DOCX, mime_types.BLOCKNOTE
)
mock_ydoc.convert.assert_called_once_with(
blocknote_data, mime_types.BLOCKNOTE, mime_types.YJS
)
assert result == yjs_data
@patch("core.services.converter_services.YdocConverter")
def test_converter_markdown_to_yjs_delegation(mock_ydoc_class):
"""Test that Markdown to YJS conversion is delegated to YdocConverter."""
mock_ydoc = MagicMock()
mock_ydoc_class.return_value = mock_ydoc
yjs_data = "base64encodedyjs"
mock_ydoc.convert.return_value = yjs_data
converter = Converter()
markdown_data = "# Test Document"
result = converter.convert(markdown_data, mime_types.MARKDOWN, mime_types.YJS)
mock_ydoc.convert.assert_called_once_with(
markdown_data, mime_types.MARKDOWN, mime_types.YJS
)
assert result == yjs_data
@patch("core.services.converter_services.YdocConverter")
def test_converter_yjs_to_html_delegation(mock_ydoc_class):
"""Test that YJS to HTML conversion is delegated to YdocConverter."""
mock_ydoc = MagicMock()
mock_ydoc_class.return_value = mock_ydoc
html_data = "<p>Test Document</p>"
mock_ydoc.convert.return_value = html_data
converter = Converter()
yjs_data = b"yjs binary data"
result = converter.convert(yjs_data, mime_types.YJS, mime_types.HTML)
mock_ydoc.convert.assert_called_once_with(yjs_data, mime_types.YJS, mime_types.HTML)
assert result == html_data
@patch("core.services.converter_services.YdocConverter")
def test_converter_blocknote_to_yjs_delegation(mock_ydoc_class):
"""Test that BlockNote to YJS conversion is delegated to YdocConverter."""
mock_ydoc = MagicMock()
mock_ydoc_class.return_value = mock_ydoc
yjs_data = "base64encodedyjs"
mock_ydoc.convert.return_value = yjs_data
converter = Converter()
blocknote_data = b'[{"type": "paragraph"}]'
result = converter.convert(blocknote_data, mime_types.BLOCKNOTE, mime_types.YJS)
mock_ydoc.convert.assert_called_once_with(
blocknote_data, mime_types.BLOCKNOTE, mime_types.YJS
)
assert result == yjs_data

View File

@@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch
import pytest
import requests
from core.services import mime_types
from core.services.converter_services import (
ServiceUnavailableError,
ValidationError,
@@ -21,9 +22,9 @@ def test_auth_header(settings):
def test_convert_empty_text():
"""Should raise ValidationError when text is empty."""
"""Should raise ValidationError when data is empty."""
converter = YdocConverter()
with pytest.raises(ValidationError, match="Input text cannot be empty"):
with pytest.raises(ValidationError, match="Input data cannot be empty"):
converter.convert("")
@@ -36,7 +37,7 @@ def test_convert_service_unavailable(mock_post):
with pytest.raises(
ServiceUnavailableError,
match="Failed to connect to conversion service",
match="Failed to connect to YDoc conversion service",
):
converter.convert("test text")
@@ -52,7 +53,7 @@ def test_convert_http_error(mock_post):
with pytest.raises(
ServiceUnavailableError,
match="Failed to connect to conversion service",
match="Failed to connect to YDoc conversion service",
):
converter.convert("test text")
@@ -83,8 +84,8 @@ def test_convert_full_integration(mock_post, settings):
data="test markdown",
headers={
"Authorization": "Bearer test-key",
"Content-Type": "text/markdown",
"Accept": "application/vnd.yjs.doc",
"Content-Type": mime_types.MARKDOWN,
"Accept": mime_types.YJS,
},
timeout=5,
verify=False,
@@ -108,9 +109,7 @@ def test_convert_full_integration_with_specific_headers(mock_post, settings):
mock_response.raise_for_status.return_value = None
mock_post.return_value = mock_response
result = converter.convert(
b"test_content", "application/vnd.yjs.doc", "text/markdown"
)
result = converter.convert(b"test_content", mime_types.YJS, mime_types.MARKDOWN)
assert result == expected_response
mock_post.assert_called_once_with(
@@ -118,8 +117,8 @@ def test_convert_full_integration_with_specific_headers(mock_post, settings):
data=b"test_content",
headers={
"Authorization": "Bearer test-key",
"Content-Type": "application/vnd.yjs.doc",
"Accept": "text/markdown",
"Content-Type": mime_types.YJS,
"Accept": mime_types.MARKDOWN,
},
timeout=5,
verify=False,
@@ -135,7 +134,7 @@ def test_convert_timeout(mock_post):
with pytest.raises(
ServiceUnavailableError,
match="Failed to connect to conversion service",
match="Failed to connect to YDoc conversion service",
):
converter.convert("test text")
@@ -144,5 +143,5 @@ def test_convert_none_input():
"""Should raise ValidationError when input is None."""
converter = YdocConverter()
with pytest.raises(ValidationError, match="Input text cannot be empty"):
with pytest.raises(ValidationError, match="Input data cannot be empty"):
converter.convert(None)

View File

@@ -0,0 +1,117 @@
"""Test DocSpec converter services."""
from unittest.mock import MagicMock, patch
import pytest
import requests
from core.services import mime_types
from core.services.converter_services import (
DocSpecConverter,
ServiceUnavailableError,
ValidationError,
)
def test_docspec_convert_empty_data():
"""Should raise ValidationError when data is empty."""
converter = DocSpecConverter()
with pytest.raises(ValidationError, match="Input data cannot be empty"):
converter.convert("", mime_types.DOCX, mime_types.BLOCKNOTE)
def test_docspec_convert_none_input():
"""Should raise ValidationError when input is None."""
converter = DocSpecConverter()
with pytest.raises(ValidationError, match="Input data cannot be empty"):
converter.convert(None, mime_types.DOCX, mime_types.BLOCKNOTE)
def test_docspec_convert_unsupported_content_type():
"""Should raise ValidationError when content type is not DOCX."""
converter = DocSpecConverter()
with pytest.raises(
ValidationError, match="Conversion from text/plain to .* is not supported"
):
converter.convert(b"test data", "text/plain", mime_types.BLOCKNOTE)
def test_docspec_convert_unsupported_accept():
"""Should raise ValidationError when accept type is not BLOCKNOTE."""
converter = DocSpecConverter()
with pytest.raises(
ValidationError,
match=f"Conversion from {mime_types.DOCX} to {mime_types.YJS} is not supported",
):
converter.convert(b"test data", mime_types.DOCX, mime_types.YJS)
@patch("requests.post")
def test_docspec_convert_service_unavailable(mock_post):
"""Should raise ServiceUnavailableError when service is unavailable."""
converter = DocSpecConverter()
mock_post.side_effect = requests.RequestException("Connection error")
with pytest.raises(
ServiceUnavailableError,
match="Failed to connect to DocSpec conversion service",
):
converter.convert(b"test data", mime_types.DOCX, mime_types.BLOCKNOTE)
@patch("requests.post")
def test_docspec_convert_http_error(mock_post):
"""Should raise ServiceUnavailableError when HTTP error occurs."""
converter = DocSpecConverter()
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = requests.HTTPError("HTTP Error")
mock_post.return_value = mock_response
with pytest.raises(
ServiceUnavailableError,
match="Failed to connect to DocSpec conversion service",
):
converter.convert(b"test data", mime_types.DOCX, mime_types.BLOCKNOTE)
@patch("requests.post")
def test_docspec_convert_timeout(mock_post):
"""Should raise ServiceUnavailableError when request times out."""
converter = DocSpecConverter()
mock_post.side_effect = requests.Timeout("Request timed out")
with pytest.raises(
ServiceUnavailableError,
match="Failed to connect to DocSpec conversion service",
):
converter.convert(b"test data", mime_types.DOCX, mime_types.BLOCKNOTE)
@patch("requests.post")
def test_docspec_convert_success(mock_post, settings):
"""Test successful DOCX to BlockNote conversion."""
settings.DOCSPEC_API_URL = "http://docspec.test/convert"
settings.CONVERSION_API_TIMEOUT = 5
settings.CONVERSION_API_SECURE = False
converter = DocSpecConverter()
expected_content = b'[{"type": "paragraph", "content": "test"}]'
mock_response = MagicMock()
mock_response.content = expected_content
mock_response.raise_for_status.return_value = None
mock_post.return_value = mock_response
docx_data = b"fake docx binary data"
result = converter.convert(docx_data, mime_types.DOCX, mime_types.BLOCKNOTE)
assert result == expected_content
# Verify the request was made correctly
mock_post.assert_called_once_with(
"http://docspec.test/convert",
headers={"Accept": mime_types.BLOCKNOTE},
files={"file": ("document.docx", docx_data, mime_types.DOCX)},
timeout=5,
verify=False,
)

View File

@@ -10,7 +10,6 @@ from core.api import viewsets
# - Main endpoints
router = DefaultRouter()
router.register("templates", viewsets.TemplateViewSet, basename="templates")
router.register("documents", viewsets.DocumentViewSet, basename="documents")
router.register("users", viewsets.UserViewSet, basename="users")

View File

@@ -1,2 +0,0 @@
<img width="200" src="http://localhost:3000/assets/logo-gouv.png" />
<br/>

View File

@@ -216,29 +216,6 @@ def create_demo(stdout):
queue.flush()
with Timeit(stdout, "Creating Template"):
with open(
file="demo/data/template/code.txt", mode="r", encoding="utf-8"
) as text_file:
code_data = text_file.read()
with open(
file="demo/data/template/css.txt", mode="r", encoding="utf-8"
) as text_file:
css_data = text_file.read()
queue.push(
models.Template(
id="baca9e2a-59fb-42ef-b5c6-6f6b05637111",
title="Demo Template",
description="This is the demo template",
code=code_data,
css=css_data,
is_public=True,
)
)
queue.flush()
class Command(BaseCommand):
"""A management command to create a demo database."""

View File

@@ -25,7 +25,6 @@ def test_commands_create_demo():
"""The create_demo management command should create objects as expected."""
call_command("create_demo")
assert models.Template.objects.count() == 1
assert models.User.objects.count() >= 10
assert models.Document.objects.count() >= 10
assert models.DocumentAccess.objects.count() > 10

View File

@@ -29,6 +29,10 @@ from sentry_sdk.integrations.logging import ignore_logger
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DATA_DIR = os.getenv("DATA_DIR", os.path.join("/", "data"))
KB = 1024
MB = KB * KB
GB = MB * KB
def get_release():
"""
@@ -168,7 +172,7 @@ class Base(Configuration):
# Document images
DOCUMENT_IMAGE_MAX_SIZE = values.IntegerValue(
10 * (2**20), # 10MB
10 * MB, # 10MB
environ_name="DOCUMENT_IMAGE_MAX_SIZE",
environ_prefix=None,
)
@@ -406,16 +410,6 @@ class Base(Configuration):
environ_name="API_DOCUMENT_ACCESS_THROTTLE_RATE",
environ_prefix=None,
),
"template": values.Value(
default="30/minute",
environ_name="API_TEMPLATE_THROTTLE_RATE",
environ_prefix=None,
),
"template_access": values.Value(
default="30/minute",
environ_name="API_TEMPLATE_ACCESS_THROTTLE_RATE",
environ_prefix=None,
),
"invitation": values.Value(
default="60/minute",
environ_name="API_INVITATION_THROTTLE_RATE",
@@ -453,7 +447,7 @@ class Base(Configuration):
"REDOC_DIST": "SIDECAR",
}
TRASHBIN_CUTOFF_DAYS = values.Value(
TRASHBIN_CUTOFF_DAYS = values.IntegerValue(
30, environ_name="TRASHBIN_CUTOFF_DAYS", environ_prefix=None
)
@@ -465,6 +459,7 @@ class Base(Configuration):
EMAIL_HOST_PASSWORD = SecretFileValue(None)
EMAIL_LOGO_IMG = values.Value(None)
EMAIL_PORT = values.PositiveIntegerValue(None)
EMAIL_URL_APP = values.Value(None)
EMAIL_USE_TLS = values.BooleanValue(False)
EMAIL_USE_SSL = values.BooleanValue(False)
EMAIL_FROM = values.Value("from@example.com")
@@ -675,30 +670,51 @@ 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,
}
AI_FEATURE_ENABLED = values.BooleanValue(
default=False, environ_name="AI_FEATURE_ENABLED", environ_prefix=None
)
AI_MODEL = values.Value(None, environ_name="AI_MODEL", environ_prefix=None)
AI_STREAM = values.BooleanValue(
default=False, environ_name="AI_STREAM", environ_prefix=None
)
AI_USER_RATE_THROTTLE_RATES = {
"minute": 3,
"hour": 50,
"day": 200,
}
LANGFUSE_SECRET_KEY = SecretFileValue(
None, environ_name="LANGFUSE_SECRET_KEY", environ_prefix=None
)
LANGFUSE_PUBLIC_KEY = values.Value(
None, environ_name="LANGFUSE_PUBLIC_KEY", environ_prefix=None
)
LANGFUSE_BASE_URL = values.Value(
None, environ_name="LANGFUSE_BASE_URL", environ_prefix=None
)
# Y provider microservice
Y_PROVIDER_API_KEY = SecretFileValue(
environ_name="Y_PROVIDER_API_KEY",
@@ -709,6 +725,22 @@ class Base(Configuration):
environ_prefix=None,
)
# DocSpec API microservice
DOCSPEC_API_URL = values.Value(environ_name="DOCSPEC_API_URL", environ_prefix=None)
# Imported file settings
CONVERSION_FILE_MAX_SIZE = values.IntegerValue(
20 * MB, # 10MB
environ_name="CONVERSION_FILE_MAX_SIZE",
environ_prefix=None,
)
CONVERSION_FILE_EXTENSIONS_ALLOWED = values.ListValue(
default=[".docx", ".md"],
environ_name="CONVERSION_FILE_EXTENSIONS_ALLOWED",
environ_prefix=None,
)
# Conversion endpoint
CONVERSION_API_ENDPOINT = values.Value(
default="convert",
@@ -1054,6 +1086,9 @@ class Production(Base):
# Privacy
SECURE_REFERRER_POLICY = "same-origin"
# Conversion API: Always verify SSL in production
CONVERSION_API_SECURE = True
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: Breton\n"
"Language: br_FR\n"
@@ -79,11 +79,15 @@ msgstr "Doare korf"
msgid "Format"
msgstr "Stumm"
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr "eilenn {title}"
#: build/lib/core/apps.py:12 core/apps.py:12
msgid "Impress core application"
msgstr ""
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
#: core/choices.py:43
msgid "Reader"
@@ -239,8 +243,8 @@ msgstr "implijer"
msgid "users"
msgstr "implijerien"
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
msgid "title"
msgstr "titl"
@@ -256,188 +260,188 @@ msgstr "Restr"
msgid "Documents"
msgstr "Restroù"
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
msgid "Untitled Document"
msgstr "Restr hep titl"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:862 core/models.py:862
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} en deus rannet ur restr ganeoc'h!"
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:866 core/models.py:866
#, 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:869 core/models.py:869
#: build/lib/core/models.py:872 core/models.py:872
#, 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:969 core/models.py:969
#: build/lib/core/models.py:973 core/models.py:973
msgid "Document/user link trace"
msgstr "Roud liamm ar restr/an implijer"
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:974 core/models.py:974
msgid "Document/user link traces"
msgstr "Roudoù liamm ar restr/an implijer"
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:980 core/models.py:980
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:999 core/models.py:999
#: build/lib/core/models.py:1003 core/models.py:1003
msgid "Document favorite"
msgstr "Restr muiañ-karet"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "Document favorites"
msgstr "Restroù muiañ-karet"
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1010 core/models.py:1010
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:1028 core/models.py:1028
#: build/lib/core/models.py:1032 core/models.py:1032
msgid "Document/user relation"
msgstr "Liamm restr/implijer"
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "Document/user relations"
msgstr "Liammoù restr/implijer"
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This user is already in this document."
msgstr "An implijer-mañ a zo dija er restr-mañ."
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "This team is already in this document."
msgstr "Ar skipailh-mañ a zo dija en restr-mañ."
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
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:1198 core/models.py:1198
#: build/lib/core/models.py:1202 core/models.py:1202
msgid "Document ask for access"
msgstr "Goulenn tizhout ar restr"
#: build/lib/core/models.py:1199 core/models.py:1199
#: build/lib/core/models.py:1203 core/models.py:1203
msgid "Document ask for accesses"
msgstr "Goulennoù tizhout ar restr"
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1209 core/models.py:1209
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:1262 core/models.py:1262
#: build/lib/core/models.py:1266 core/models.py:1266
#, 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:1266 core/models.py:1266
#: build/lib/core/models.py:1270 core/models.py:1270
#, 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:1272 core/models.py:1272
#: build/lib/core/models.py:1276 core/models.py:1276
#, 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:1314 core/models.py:1314
#: build/lib/core/models.py:1318 core/models.py:1318
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1315 core/models.py:1315
#: build/lib/core/models.py:1319 core/models.py:1319
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1365 core/models.py:1365
#: build/lib/core/models.py:1369 core/models.py:1369
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1366 core/models.py:1366
#: build/lib/core/models.py:1370 core/models.py:1370
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1415 core/models.py:1415
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1419 core/models.py:1419
#: build/lib/core/models.py:1423 core/models.py:1423
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1420 core/models.py:1420
#: build/lib/core/models.py:1424 core/models.py:1424
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1431 core/models.py:1431
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr "deskrivadur"
#: build/lib/core/models.py:1432 core/models.py:1432
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr "kod"
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1435 core/models.py:1435
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr "publik"
#: build/lib/core/models.py:1437 core/models.py:1437
#: 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:1443 core/models.py:1443
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr "Patrom"
#: build/lib/core/models.py:1444 core/models.py:1444
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr "Patromoù"
#: build/lib/core/models.py:1497 core/models.py:1497
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr "Liamm patrom/implijer"
#: build/lib/core/models.py:1498 core/models.py:1498
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr "Liammoù patrom/implijer"
#: build/lib/core/models.py:1504 core/models.py:1504
#: 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:1510 core/models.py:1510
#: 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:1587 core/models.py:1587
#: build/lib/core/models.py:1591 core/models.py:1591
msgid "email address"
msgstr "postel"
#: build/lib/core/models.py:1606 core/models.py:1606
#: build/lib/core/models.py:1610 core/models.py:1610
msgid "Document invitation"
msgstr "Pedadenn d'ur restr"
#: build/lib/core/models.py:1607 core/models.py:1607
#: build/lib/core/models.py:1611 core/models.py:1611
msgid "Document invitations"
msgstr "Pedadennoù d'ur restr"
#: build/lib/core/models.py:1627 core/models.py:1627
#: build/lib/core/models.py:1631 core/models.py:1631
msgid "This email is already associated to a registered user."
msgstr "Ar postel-mañ a zo liammet ouzh un implijer enskrivet."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de_DE\n"
@@ -79,11 +79,15 @@ msgstr "Typ"
msgid "Format"
msgstr "Format"
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr "Kopie von {title}"
#: build/lib/core/apps.py:12 core/apps.py:12
msgid "Impress core application"
msgstr ""
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
#: core/choices.py:43
msgid "Reader"
@@ -239,8 +243,8 @@ msgstr "Benutzer"
msgid "users"
msgstr "Benutzer"
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
msgid "title"
msgstr "Titel"
@@ -256,188 +260,188 @@ msgstr "Dokument"
msgid "Documents"
msgstr "Dokumente"
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
msgid "Untitled Document"
msgstr "Unbenanntes Dokument"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:862 core/models.py:862
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} hat ein Dokument mit Ihnen geteilt!"
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:866 core/models.py:866
#, 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:869 core/models.py:869
#: build/lib/core/models.py:872 core/models.py:872
#, 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:969 core/models.py:969
#: build/lib/core/models.py:973 core/models.py:973
msgid "Document/user link trace"
msgstr "Dokument/Benutzer Linkverfolgung"
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:974 core/models.py:974
msgid "Document/user link traces"
msgstr "Dokument/Benutzer Linkverfolgung"
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:980 core/models.py:980
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:999 core/models.py:999
#: build/lib/core/models.py:1003 core/models.py:1003
msgid "Document favorite"
msgstr "Dokumentenfavorit"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "Document favorites"
msgstr "Dokumentfavoriten"
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1010 core/models.py:1010
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:1028 core/models.py:1028
#: build/lib/core/models.py:1032 core/models.py:1032
msgid "Document/user relation"
msgstr "Dokument/Benutzerbeziehung"
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "Document/user relations"
msgstr "Dokument/Benutzerbeziehungen"
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This user is already in this document."
msgstr "Dieser Benutzer befindet sich bereits in diesem Dokument."
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "This team is already in this document."
msgstr "Dieses Team befindet sich bereits in diesem Dokument."
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
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:1198 core/models.py:1198
#: build/lib/core/models.py:1202 core/models.py:1202
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1199 core/models.py:1199
#: build/lib/core/models.py:1203 core/models.py:1203
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1209 core/models.py:1209
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1262 core/models.py:1262
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1266 core/models.py:1266
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1276 core/models.py:1276
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1314 core/models.py:1314
#: build/lib/core/models.py:1318 core/models.py:1318
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1315 core/models.py:1315
#: build/lib/core/models.py:1319 core/models.py:1319
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1365 core/models.py:1365
#: build/lib/core/models.py:1369 core/models.py:1369
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1366 core/models.py:1366
#: build/lib/core/models.py:1370 core/models.py:1370
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1415 core/models.py:1415
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1419 core/models.py:1419
#: build/lib/core/models.py:1423 core/models.py:1423
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1420 core/models.py:1420
#: build/lib/core/models.py:1424 core/models.py:1424
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1431 core/models.py:1431
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr "Beschreibung"
#: build/lib/core/models.py:1432 core/models.py:1432
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr "Code"
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr "CSS"
#: build/lib/core/models.py:1435 core/models.py:1435
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr "öffentlich"
#: build/lib/core/models.py:1437 core/models.py:1437
#: 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:1443 core/models.py:1443
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr "Vorlage"
#: build/lib/core/models.py:1444 core/models.py:1444
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr "Vorlagen"
#: build/lib/core/models.py:1497 core/models.py:1497
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr "Vorlage/Benutzer-Beziehung"
#: build/lib/core/models.py:1498 core/models.py:1498
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr "Vorlage/Benutzerbeziehungen"
#: build/lib/core/models.py:1504 core/models.py:1504
#: 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:1510 core/models.py:1510
#: 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:1587 core/models.py:1587
#: build/lib/core/models.py:1591 core/models.py:1591
msgid "email address"
msgstr "E-Mail-Adresse"
#: build/lib/core/models.py:1606 core/models.py:1606
#: build/lib/core/models.py:1610 core/models.py:1610
msgid "Document invitation"
msgstr "Einladung zum Dokument"
#: build/lib/core/models.py:1607 core/models.py:1607
#: build/lib/core/models.py:1611 core/models.py:1611
msgid "Document invitations"
msgstr "Dokumenteinladungen"
#: build/lib/core/models.py:1627 core/models.py:1627
#: build/lib/core/models.py:1631 core/models.py:1631
msgid "This email is already associated to a registered user."
msgstr "Diese E-Mail ist bereits einem registrierten Benutzer zugeordnet."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
@@ -79,11 +79,15 @@ msgstr ""
msgid "Format"
msgstr ""
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr ""
#: build/lib/core/apps.py:12 core/apps.py:12
msgid "Impress core application"
msgstr ""
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
#: core/choices.py:43
msgid "Reader"
@@ -239,8 +243,8 @@ msgstr ""
msgid "users"
msgstr ""
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
msgid "title"
msgstr ""
@@ -256,188 +260,188 @@ msgstr ""
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:862 core/models.py:862
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:866 core/models.py:866
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:869 core/models.py:869
#: build/lib/core/models.py:872 core/models.py:872
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:969 core/models.py:969
#: build/lib/core/models.py:973 core/models.py:973
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:974 core/models.py:974
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:980 core/models.py:980
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:999 core/models.py:999
#: build/lib/core/models.py:1003 core/models.py:1003
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1010 core/models.py:1010
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1028 core/models.py:1028
#: build/lib/core/models.py:1032 core/models.py:1032
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1202 core/models.py:1202
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1199 core/models.py:1199
#: build/lib/core/models.py:1203 core/models.py:1203
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1209 core/models.py:1209
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1262 core/models.py:1262
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1266 core/models.py:1266
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1276 core/models.py:1276
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1314 core/models.py:1314
#: build/lib/core/models.py:1318 core/models.py:1318
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1315 core/models.py:1315
#: build/lib/core/models.py:1319 core/models.py:1319
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1365 core/models.py:1365
#: build/lib/core/models.py:1369 core/models.py:1369
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1366 core/models.py:1366
#: build/lib/core/models.py:1370 core/models.py:1370
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1415 core/models.py:1415
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1419 core/models.py:1419
#: build/lib/core/models.py:1423 core/models.py:1423
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1420 core/models.py:1420
#: build/lib/core/models.py:1424 core/models.py:1424
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1431 core/models.py:1431
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr ""
#: build/lib/core/models.py:1432 core/models.py:1432
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr ""
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr ""
#: build/lib/core/models.py:1435 core/models.py:1435
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr ""
#: build/lib/core/models.py:1437 core/models.py:1437
#: 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:1443 core/models.py:1443
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1444 core/models.py:1444
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1497 core/models.py:1497
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1498 core/models.py:1498
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1504 core/models.py:1504
#: build/lib/core/models.py:1508 core/models.py:1508
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1510 core/models.py:1510
#: build/lib/core/models.py:1514 core/models.py:1514
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1587 core/models.py:1587
#: build/lib/core/models.py:1591 core/models.py:1591
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1606 core/models.py:1606
#: build/lib/core/models.py:1610 core/models.py:1610
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1607 core/models.py:1607
#: build/lib/core/models.py:1611 core/models.py:1611
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1627 core/models.py:1627
#: build/lib/core/models.py:1631 core/models.py:1631
msgid "This email is already associated to a registered user."
msgstr ""

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: Spanish\n"
"Language: es_ES\n"
@@ -79,11 +79,15 @@ msgstr "Tipo de Cuerpo"
msgid "Format"
msgstr "Formato"
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr "copia de {title}"
#: build/lib/core/apps.py:12 core/apps.py:12
msgid "Impress core application"
msgstr ""
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
#: core/choices.py:43
msgid "Reader"
@@ -239,8 +243,8 @@ msgstr "usuario"
msgid "users"
msgstr "usuarios"
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
msgid "title"
msgstr "título"
@@ -256,188 +260,188 @@ msgstr "Documento"
msgid "Documents"
msgstr "Documentos"
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
msgid "Untitled Document"
msgstr "Documento sin título"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:862 core/models.py:862
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "¡{name} ha compartido un documento contigo!"
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:866 core/models.py:866
#, 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:869 core/models.py:869
#: build/lib/core/models.py:872 core/models.py:872
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} ha compartido un documento contigo: {title}"
#: build/lib/core/models.py:969 core/models.py:969
#: build/lib/core/models.py:973 core/models.py:973
msgid "Document/user link trace"
msgstr "Traza del enlace de documento/usuario"
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:974 core/models.py:974
msgid "Document/user link traces"
msgstr "Trazas del enlace de documento/usuario"
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:980 core/models.py:980
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:999 core/models.py:999
#: build/lib/core/models.py:1003 core/models.py:1003
msgid "Document favorite"
msgstr "Documento favorito"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "Document favorites"
msgstr "Documentos favoritos"
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1010 core/models.py:1010
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:1028 core/models.py:1028
#: build/lib/core/models.py:1032 core/models.py:1032
msgid "Document/user relation"
msgstr "Relación documento/usuario"
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "Document/user relations"
msgstr "Relaciones documento/usuario"
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This user is already in this document."
msgstr "Este usuario ya forma parte del documento."
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "This team is already in this document."
msgstr "Este equipo ya forma parte del documento."
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
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:1198 core/models.py:1198
#: build/lib/core/models.py:1202 core/models.py:1202
msgid "Document ask for access"
msgstr "Solicitud de acceso"
#: build/lib/core/models.py:1199 core/models.py:1199
#: build/lib/core/models.py:1203 core/models.py:1203
msgid "Document ask for accesses"
msgstr "Solicitud de accesos"
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1209 core/models.py:1209
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:1262 core/models.py:1262
#: build/lib/core/models.py:1266 core/models.py:1266
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "¡{name} desea acceder a un documento!"
#: build/lib/core/models.py:1266 core/models.py:1266
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} desea acceso al siguiente documento:"
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1276 core/models.py:1276
#, 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:1314 core/models.py:1314
#: build/lib/core/models.py:1318 core/models.py:1318
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1315 core/models.py:1315
#: build/lib/core/models.py:1319 core/models.py:1319
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1365 core/models.py:1365
#: build/lib/core/models.py:1369 core/models.py:1369
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1366 core/models.py:1366
#: build/lib/core/models.py:1370 core/models.py:1370
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1415 core/models.py:1415
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1419 core/models.py:1419
#: build/lib/core/models.py:1423 core/models.py:1423
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1420 core/models.py:1420
#: build/lib/core/models.py:1424 core/models.py:1424
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1431 core/models.py:1431
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr "descripción"
#: build/lib/core/models.py:1432 core/models.py:1432
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr "código"
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1435 core/models.py:1435
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr "público"
#: build/lib/core/models.py:1437 core/models.py:1437
#: 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:1443 core/models.py:1443
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr "Plantilla"
#: build/lib/core/models.py:1444 core/models.py:1444
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr "Plantillas"
#: build/lib/core/models.py:1497 core/models.py:1497
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr "Relación plantilla/usuario"
#: build/lib/core/models.py:1498 core/models.py:1498
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr "Relaciones plantilla/usuario"
#: build/lib/core/models.py:1504 core/models.py:1504
#: 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:1510 core/models.py:1510
#: 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:1587 core/models.py:1587
#: build/lib/core/models.py:1591 core/models.py:1591
msgid "email address"
msgstr "dirección de correo electrónico"
#: build/lib/core/models.py:1606 core/models.py:1606
#: build/lib/core/models.py:1610 core/models.py:1610
msgid "Document invitation"
msgstr "Invitación al documento"
#: build/lib/core/models.py:1607 core/models.py:1607
#: build/lib/core/models.py:1611 core/models.py:1611
msgid "Document invitations"
msgstr "Invitaciones a documentos"
#: build/lib/core/models.py:1627 core/models.py:1627
#: build/lib/core/models.py:1631 core/models.py:1631
msgid "This email is already associated to a registered user."
msgstr "Este correo electrónico está asociado a un usuario registrado."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
@@ -79,11 +79,15 @@ msgstr "Type de corps"
msgid "Format"
msgstr "Format"
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr "copie de {title}"
#: build/lib/core/apps.py:12 core/apps.py:12
msgid "Impress core application"
msgstr "Noyau d'application Impress"
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
#: core/choices.py:43
msgid "Reader"
@@ -239,8 +243,8 @@ msgstr "utilisateur"
msgid "users"
msgstr "utilisateurs"
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
msgid "title"
msgstr "titre"
@@ -256,188 +260,188 @@ msgstr "Document"
msgid "Documents"
msgstr "Documents"
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
msgid "Untitled Document"
msgstr "Document sans titre"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:862 core/models.py:862
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} a partagé un document avec vous!"
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:866 core/models.py:866
#, 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:869 core/models.py:869
#: build/lib/core/models.py:872 core/models.py:872
#, 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:969 core/models.py:969
#: build/lib/core/models.py:973 core/models.py:973
msgid "Document/user link trace"
msgstr "Trace du lien document/utilisateur"
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:974 core/models.py:974
msgid "Document/user link traces"
msgstr "Traces du lien document/utilisateur"
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:980 core/models.py:980
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:999 core/models.py:999
#: build/lib/core/models.py:1003 core/models.py:1003
msgid "Document favorite"
msgstr "Document favori"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "Document favorites"
msgstr "Documents favoris"
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1010 core/models.py:1010
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:1028 core/models.py:1028
#: build/lib/core/models.py:1032 core/models.py:1032
msgid "Document/user relation"
msgstr "Relation document/utilisateur"
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "Document/user relations"
msgstr "Relations document/utilisateur"
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This user is already in this document."
msgstr "Cet utilisateur est déjà dans ce document."
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "This team is already in this document."
msgstr "Cette équipe est déjà dans ce document."
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
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:1198 core/models.py:1198
#: build/lib/core/models.py:1202 core/models.py:1202
msgid "Document ask for access"
msgstr "Demande d'accès au document"
#: build/lib/core/models.py:1199 core/models.py:1199
#: build/lib/core/models.py:1203 core/models.py:1203
msgid "Document ask for accesses"
msgstr "Demande d'accès au document"
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1209 core/models.py:1209
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:1262 core/models.py:1262
#: build/lib/core/models.py:1266 core/models.py:1266
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} souhaiterait accéder au document suivant !"
#: build/lib/core/models.py:1266 core/models.py:1266
#: build/lib/core/models.py:1270 core/models.py:1270
#, 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:1272 core/models.py:1272
#: build/lib/core/models.py:1276 core/models.py:1276
#, 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:1314 core/models.py:1314
#: build/lib/core/models.py:1318 core/models.py:1318
msgid "Thread"
msgstr "Conversation"
#: build/lib/core/models.py:1315 core/models.py:1315
#: build/lib/core/models.py:1319 core/models.py:1319
msgid "Threads"
msgstr "Conversations"
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
msgid "Anonymous"
msgstr "Anonyme"
#: build/lib/core/models.py:1365 core/models.py:1365
#: build/lib/core/models.py:1369 core/models.py:1369
msgid "Comment"
msgstr "Commentaire"
#: build/lib/core/models.py:1366 core/models.py:1366
#: build/lib/core/models.py:1370 core/models.py:1370
msgid "Comments"
msgstr "Commentaires"
#: build/lib/core/models.py:1415 core/models.py:1415
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "This emoji has already been reacted to this comment."
msgstr "Cet émoji a déjà été réagi à ce commentaire."
#: build/lib/core/models.py:1419 core/models.py:1419
#: build/lib/core/models.py:1423 core/models.py:1423
msgid "Reaction"
msgstr "Réaction"
#: build/lib/core/models.py:1420 core/models.py:1420
#: build/lib/core/models.py:1424 core/models.py:1424
msgid "Reactions"
msgstr "Réactions"
#: build/lib/core/models.py:1431 core/models.py:1431
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr "description"
#: build/lib/core/models.py:1432 core/models.py:1432
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr "code"
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr "CSS"
#: build/lib/core/models.py:1435 core/models.py:1435
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr "public"
#: build/lib/core/models.py:1437 core/models.py:1437
#: 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:1443 core/models.py:1443
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr "Modèle"
#: build/lib/core/models.py:1444 core/models.py:1444
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr "Modèles"
#: build/lib/core/models.py:1497 core/models.py:1497
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr "Relation modèle/utilisateur"
#: build/lib/core/models.py:1498 core/models.py:1498
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr "Relations modèle/utilisateur"
#: build/lib/core/models.py:1504 core/models.py:1504
#: 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:1510 core/models.py:1510
#: 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:1587 core/models.py:1587
#: build/lib/core/models.py:1591 core/models.py:1591
msgid "email address"
msgstr "adresse e-mail"
#: build/lib/core/models.py:1606 core/models.py:1606
#: build/lib/core/models.py:1610 core/models.py:1610
msgid "Document invitation"
msgstr "Invitation à un document"
#: build/lib/core/models.py:1607 core/models.py:1607
#: build/lib/core/models.py:1611 core/models.py:1611
msgid "Document invitations"
msgstr "Invitations à un document"
#: build/lib/core/models.py:1627 core/models.py:1627
#: build/lib/core/models.py:1631 core/models.py:1631
msgid "This email is already associated to a registered user."
msgstr "Cette adresse email est déjà associée à un utilisateur inscrit."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: Italian\n"
"Language: it_IT\n"
@@ -79,11 +79,15 @@ msgstr ""
msgid "Format"
msgstr "Formato"
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr "copia di {title}"
#: build/lib/core/apps.py:12 core/apps.py:12
msgid "Impress core application"
msgstr ""
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
#: core/choices.py:43
msgid "Reader"
@@ -239,8 +243,8 @@ msgstr "utente"
msgid "users"
msgstr "utenti"
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
msgid "title"
msgstr "titolo"
@@ -256,188 +260,188 @@ msgstr "Documento"
msgid "Documents"
msgstr "Documenti"
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
msgid "Untitled Document"
msgstr "Documento senza titolo"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:862 core/models.py:862
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} ha condiviso un documento con te!"
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:866 core/models.py:866
#, 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:869 core/models.py:869
#: build/lib/core/models.py:872 core/models.py:872
#, 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:969 core/models.py:969
#: build/lib/core/models.py:973 core/models.py:973
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:974 core/models.py:974
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:980 core/models.py:980
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:999 core/models.py:999
#: build/lib/core/models.py:1003 core/models.py:1003
msgid "Document favorite"
msgstr "Documento preferito"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "Document favorites"
msgstr "Documenti preferiti"
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1010 core/models.py:1010
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1028 core/models.py:1028
#: build/lib/core/models.py:1032 core/models.py:1032
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This user is already in this document."
msgstr "Questo utente è già presente in questo documento."
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "This team is already in this document."
msgstr "Questo team è già presente in questo documento."
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1202 core/models.py:1202
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1199 core/models.py:1199
#: build/lib/core/models.py:1203 core/models.py:1203
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1209 core/models.py:1209
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1262 core/models.py:1262
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1266 core/models.py:1266
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1276 core/models.py:1276
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1314 core/models.py:1314
#: build/lib/core/models.py:1318 core/models.py:1318
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1315 core/models.py:1315
#: build/lib/core/models.py:1319 core/models.py:1319
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1365 core/models.py:1365
#: build/lib/core/models.py:1369 core/models.py:1369
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1366 core/models.py:1366
#: build/lib/core/models.py:1370 core/models.py:1370
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1415 core/models.py:1415
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1419 core/models.py:1419
#: build/lib/core/models.py:1423 core/models.py:1423
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1420 core/models.py:1420
#: build/lib/core/models.py:1424 core/models.py:1424
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1431 core/models.py:1431
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr "descrizione"
#: build/lib/core/models.py:1432 core/models.py:1432
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr "code"
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1435 core/models.py:1435
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr "pubblico"
#: build/lib/core/models.py:1437 core/models.py:1437
#: 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:1443 core/models.py:1443
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr "Modello"
#: build/lib/core/models.py:1444 core/models.py:1444
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr "Modelli"
#: build/lib/core/models.py:1497 core/models.py:1497
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1498 core/models.py:1498
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1504 core/models.py:1504
#: 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:1510 core/models.py:1510
#: 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:1587 core/models.py:1587
#: build/lib/core/models.py:1591 core/models.py:1591
msgid "email address"
msgstr "indirizzo e-mail"
#: build/lib/core/models.py:1606 core/models.py:1606
#: build/lib/core/models.py:1610 core/models.py:1610
msgid "Document invitation"
msgstr "Invito al documento"
#: build/lib/core/models.py:1607 core/models.py:1607
#: build/lib/core/models.py:1611 core/models.py:1611
msgid "Document invitations"
msgstr "Inviti al documento"
#: build/lib/core/models.py:1627 core/models.py:1627
#: build/lib/core/models.py:1631 core/models.py:1631
msgid "This email is already associated to a registered user."
msgstr "Questa email è già associata a un utente registrato."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: Dutch\n"
"Language: nl_NL\n"
@@ -79,11 +79,15 @@ msgstr "Text type"
msgid "Format"
msgstr "Formaat"
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr "kopie van {title}"
#: build/lib/core/apps.py:12 core/apps.py:12
msgid "Impress core application"
msgstr "Docs kern applicatie"
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
#: core/choices.py:43
msgid "Reader"
@@ -239,8 +243,8 @@ msgstr "gebruiker"
msgid "users"
msgstr "gebruikers"
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
msgid "title"
msgstr "titel"
@@ -256,188 +260,188 @@ msgstr "Document"
msgid "Documents"
msgstr "Documenten"
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
msgid "Untitled Document"
msgstr "Naamloos Document"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:862 core/models.py:862
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} heeft een document met u gedeeld!"
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:866 core/models.py:866
#, 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:869 core/models.py:869
#: build/lib/core/models.py:872 core/models.py:872
#, 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:969 core/models.py:969
#: build/lib/core/models.py:973 core/models.py:973
msgid "Document/user link trace"
msgstr "Document/gebruiker link"
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:974 core/models.py:974
msgid "Document/user link traces"
msgstr "Document/gebruiker link"
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:980 core/models.py:980
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:999 core/models.py:999
#: build/lib/core/models.py:1003 core/models.py:1003
msgid "Document favorite"
msgstr "Document favoriet"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "Document favorites"
msgstr "Document favorieten"
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1010 core/models.py:1010
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:1028 core/models.py:1028
#: build/lib/core/models.py:1032 core/models.py:1032
msgid "Document/user relation"
msgstr "Document/gebruiker relatie"
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "Document/user relations"
msgstr "Document/gebruiker relaties"
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This user is already in this document."
msgstr "De gebruiker bestaat al in dit document."
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "This team is already in this document."
msgstr "Dit team bestaat al in dit document."
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
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:1198 core/models.py:1198
#: build/lib/core/models.py:1202 core/models.py:1202
msgid "Document ask for access"
msgstr "Document verzoekt om toegang"
#: build/lib/core/models.py:1199 core/models.py:1199
#: build/lib/core/models.py:1203 core/models.py:1203
msgid "Document ask for accesses"
msgstr "Document verzoekt om toegangen"
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1209 core/models.py:1209
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:1262 core/models.py:1262
#: build/lib/core/models.py:1266 core/models.py:1266
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} verzoekt toegang tot een document!"
#: build/lib/core/models.py:1266 core/models.py:1266
#: build/lib/core/models.py:1270 core/models.py:1270
#, 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:1272 core/models.py:1272
#: build/lib/core/models.py:1276 core/models.py:1276
#, 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:1314 core/models.py:1314
#: build/lib/core/models.py:1318 core/models.py:1318
msgid "Thread"
msgstr "Kanaal"
#: build/lib/core/models.py:1315 core/models.py:1315
#: build/lib/core/models.py:1319 core/models.py:1319
msgid "Threads"
msgstr "Kanalen"
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
msgid "Anonymous"
msgstr "Anoniem"
#: build/lib/core/models.py:1365 core/models.py:1365
#: build/lib/core/models.py:1369 core/models.py:1369
msgid "Comment"
msgstr "Reactie"
#: build/lib/core/models.py:1366 core/models.py:1366
#: build/lib/core/models.py:1370 core/models.py:1370
msgid "Comments"
msgstr "Reacties"
#: build/lib/core/models.py:1415 core/models.py:1415
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "This emoji has already been reacted to this comment."
msgstr "Deze emoji is al op deze opmerking gereageerd."
#: build/lib/core/models.py:1419 core/models.py:1419
#: build/lib/core/models.py:1423 core/models.py:1423
msgid "Reaction"
msgstr "Reactie"
#: build/lib/core/models.py:1420 core/models.py:1420
#: build/lib/core/models.py:1424 core/models.py:1424
msgid "Reactions"
msgstr "Reacties"
#: build/lib/core/models.py:1431 core/models.py:1431
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr "omschrijving"
#: build/lib/core/models.py:1432 core/models.py:1432
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr "code"
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1435 core/models.py:1435
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr "publiek"
#: build/lib/core/models.py:1437 core/models.py:1437
#: 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:1443 core/models.py:1443
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr "Sjabloon"
#: build/lib/core/models.py:1444 core/models.py:1444
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr "Sjabloon"
#: build/lib/core/models.py:1497 core/models.py:1497
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr "Sjabloon/gebruiker relatie"
#: build/lib/core/models.py:1498 core/models.py:1498
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr "Sjabloon/gebruiker relaties"
#: build/lib/core/models.py:1504 core/models.py:1504
#: 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:1510 core/models.py:1510
#: 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:1587 core/models.py:1587
#: build/lib/core/models.py:1591 core/models.py:1591
msgid "email address"
msgstr "e-mailadres"
#: build/lib/core/models.py:1606 core/models.py:1606
#: build/lib/core/models.py:1610 core/models.py:1610
msgid "Document invitation"
msgstr "Document uitnodiging"
#: build/lib/core/models.py:1607 core/models.py:1607
#: build/lib/core/models.py:1611 core/models.py:1611
msgid "Document invitations"
msgstr "Document uitnodigingen"
#: build/lib/core/models.py:1627 core/models.py:1627
#: build/lib/core/models.py:1631 core/models.py:1631
msgid "This email is already associated to a registered user."
msgstr "Deze email is al geassocieerd met een geregistreerde gebruiker."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: Portuguese\n"
"Language: pt_PT\n"
@@ -79,11 +79,15 @@ msgstr "Tipo de corpo"
msgid "Format"
msgstr "Formato"
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr "cópia de {title}"
#: build/lib/core/apps.py:12 core/apps.py:12
msgid "Impress core application"
msgstr ""
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
#: core/choices.py:43
msgid "Reader"
@@ -239,8 +243,8 @@ msgstr ""
msgid "users"
msgstr ""
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
msgid "title"
msgstr ""
@@ -256,188 +260,188 @@ msgstr ""
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:862 core/models.py:862
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:866 core/models.py:866
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:869 core/models.py:869
#: build/lib/core/models.py:872 core/models.py:872
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:969 core/models.py:969
#: build/lib/core/models.py:973 core/models.py:973
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:974 core/models.py:974
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:980 core/models.py:980
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:999 core/models.py:999
#: build/lib/core/models.py:1003 core/models.py:1003
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1010 core/models.py:1010
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1028 core/models.py:1028
#: build/lib/core/models.py:1032 core/models.py:1032
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1202 core/models.py:1202
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1199 core/models.py:1199
#: build/lib/core/models.py:1203 core/models.py:1203
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1209 core/models.py:1209
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1262 core/models.py:1262
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1266 core/models.py:1266
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1276 core/models.py:1276
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1314 core/models.py:1314
#: build/lib/core/models.py:1318 core/models.py:1318
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1315 core/models.py:1315
#: build/lib/core/models.py:1319 core/models.py:1319
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1365 core/models.py:1365
#: build/lib/core/models.py:1369 core/models.py:1369
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1366 core/models.py:1366
#: build/lib/core/models.py:1370 core/models.py:1370
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1415 core/models.py:1415
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1419 core/models.py:1419
#: build/lib/core/models.py:1423 core/models.py:1423
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1420 core/models.py:1420
#: build/lib/core/models.py:1424 core/models.py:1424
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1431 core/models.py:1431
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr ""
#: build/lib/core/models.py:1432 core/models.py:1432
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr ""
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr ""
#: build/lib/core/models.py:1435 core/models.py:1435
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr ""
#: build/lib/core/models.py:1437 core/models.py:1437
#: 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:1443 core/models.py:1443
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1444 core/models.py:1444
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1497 core/models.py:1497
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1498 core/models.py:1498
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1504 core/models.py:1504
#: build/lib/core/models.py:1508 core/models.py:1508
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1510 core/models.py:1510
#: build/lib/core/models.py:1514 core/models.py:1514
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1587 core/models.py:1587
#: build/lib/core/models.py:1591 core/models.py:1591
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1606 core/models.py:1606
#: build/lib/core/models.py:1610 core/models.py:1610
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1607 core/models.py:1607
#: build/lib/core/models.py:1611 core/models.py:1611
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1627 core/models.py:1627
#: build/lib/core/models.py:1631 core/models.py:1631
msgid "This email is already associated to a registered user."
msgstr ""

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: Russian\n"
"Language: ru_RU\n"
@@ -79,11 +79,15 @@ msgstr "Тип сообщения"
msgid "Format"
msgstr "Формат"
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr "копия {title}"
#: build/lib/core/apps.py:12 core/apps.py:12
msgid "Impress core application"
msgstr "Ядро приложения Impress"
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
#: core/choices.py:43
msgid "Reader"
@@ -239,8 +243,8 @@ msgstr "пользователь"
msgid "users"
msgstr "пользователи"
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
msgid "title"
msgstr "заголовок"
@@ -256,188 +260,188 @@ msgstr "Документ"
msgid "Documents"
msgstr "Документы"
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
msgid "Untitled Document"
msgstr "Безымянный документ"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:862 core/models.py:862
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} делится с вами документом!"
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:866 core/models.py:866
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} приглашает вас присоединиться к следующему документу с ролью \"{role}\":"
#: build/lib/core/models.py:869 core/models.py:869
#: build/lib/core/models.py:872 core/models.py:872
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} делится с вами документом: {title}"
#: build/lib/core/models.py:969 core/models.py:969
#: build/lib/core/models.py:973 core/models.py:973
msgid "Document/user link trace"
msgstr "Трассировка связи документ/пользователь"
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:974 core/models.py:974
msgid "Document/user link traces"
msgstr "Трассировка связей документ/пользователь"
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:980 core/models.py:980
msgid "A link trace already exists for this document/user."
msgstr "Для этого документа/пользователя уже существует трассировка ссылки."
#: build/lib/core/models.py:999 core/models.py:999
#: build/lib/core/models.py:1003 core/models.py:1003
msgid "Document favorite"
msgstr "Избранный документ"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "Document favorites"
msgstr "Избранные документы"
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1010 core/models.py:1010
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Этот документ уже помечен как избранный для этого пользователя."
#: build/lib/core/models.py:1028 core/models.py:1028
#: build/lib/core/models.py:1032 core/models.py:1032
msgid "Document/user relation"
msgstr "Отношение документ/пользователь"
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "Document/user relations"
msgstr "Отношения документ/пользователь"
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This user is already in this document."
msgstr "Этот пользователь уже имеет доступ к этому документу."
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "This team is already in this document."
msgstr "Эта команда уже имеет доступ к этому документу."
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
msgid "Either user or team must be set, not both."
msgstr "Может быть выбран либо пользователь, либо команда, но не оба варианта сразу."
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1202 core/models.py:1202
msgid "Document ask for access"
msgstr "Документ запрашивает доступ"
#: build/lib/core/models.py:1199 core/models.py:1199
#: build/lib/core/models.py:1203 core/models.py:1203
msgid "Document ask for accesses"
msgstr "Документ запрашивает доступы"
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1209 core/models.py:1209
msgid "This user has already asked for access to this document."
msgstr "Этот пользователь уже запросил доступ к этому документу."
#: build/lib/core/models.py:1262 core/models.py:1262
#: build/lib/core/models.py:1266 core/models.py:1266
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} хочет получить доступ к документу!"
#: build/lib/core/models.py:1266 core/models.py:1266
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} хочет получить доступ к следующему документу:"
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1276 core/models.py:1276
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} запрашивает доступ к документу: {title}"
#: build/lib/core/models.py:1314 core/models.py:1314
#: build/lib/core/models.py:1318 core/models.py:1318
msgid "Thread"
msgstr "Обсуждение"
#: build/lib/core/models.py:1315 core/models.py:1315
#: build/lib/core/models.py:1319 core/models.py:1319
msgid "Threads"
msgstr "Обсуждения"
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
msgid "Anonymous"
msgstr "Аноним"
#: build/lib/core/models.py:1365 core/models.py:1365
#: build/lib/core/models.py:1369 core/models.py:1369
msgid "Comment"
msgstr "Комментарий"
#: build/lib/core/models.py:1366 core/models.py:1366
#: build/lib/core/models.py:1370 core/models.py:1370
msgid "Comments"
msgstr "Комментарии"
#: build/lib/core/models.py:1415 core/models.py:1415
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "This emoji has already been reacted to this comment."
msgstr "Этот эмодзи уже использован в этом комментарии."
#: build/lib/core/models.py:1419 core/models.py:1419
#: build/lib/core/models.py:1423 core/models.py:1423
msgid "Reaction"
msgstr "Реакция"
#: build/lib/core/models.py:1420 core/models.py:1420
#: build/lib/core/models.py:1424 core/models.py:1424
msgid "Reactions"
msgstr "Реакции"
#: build/lib/core/models.py:1431 core/models.py:1431
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr "описание"
#: build/lib/core/models.py:1432 core/models.py:1432
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr "код"
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1435 core/models.py:1435
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr "доступно всем"
#: build/lib/core/models.py:1437 core/models.py:1437
#: 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:1443 core/models.py:1443
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr "Шаблон"
#: build/lib/core/models.py:1444 core/models.py:1444
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr "Шаблоны"
#: build/lib/core/models.py:1497 core/models.py:1497
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr "Отношение шаблон/пользователь"
#: build/lib/core/models.py:1498 core/models.py:1498
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr "Отношения шаблон/пользователь"
#: build/lib/core/models.py:1504 core/models.py:1504
#: build/lib/core/models.py:1508 core/models.py:1508
msgid "This user is already in this template."
msgstr "Этот пользователь уже указан в этом шаблоне."
#: build/lib/core/models.py:1510 core/models.py:1510
#: build/lib/core/models.py:1514 core/models.py:1514
msgid "This team is already in this template."
msgstr "Эта команда уже указана в этом шаблоне."
#: build/lib/core/models.py:1587 core/models.py:1587
#: build/lib/core/models.py:1591 core/models.py:1591
msgid "email address"
msgstr "адрес электронной почты"
#: build/lib/core/models.py:1606 core/models.py:1606
#: build/lib/core/models.py:1610 core/models.py:1610
msgid "Document invitation"
msgstr "Приглашение для документа"
#: build/lib/core/models.py:1607 core/models.py:1607
#: build/lib/core/models.py:1611 core/models.py:1611
msgid "Document invitations"
msgstr "Приглашения для документов"
#: build/lib/core/models.py:1627 core/models.py:1627
#: build/lib/core/models.py:1631 core/models.py:1631
msgid "This email is already associated to a registered user."
msgstr "Этот адрес уже связан с зарегистрированным пользователем."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: Slovenian\n"
"Language: sl_SI\n"
@@ -79,11 +79,15 @@ msgstr "Vrsta telesa"
msgid "Format"
msgstr "Oblika"
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr ""
#: build/lib/core/apps.py:12 core/apps.py:12
msgid "Impress core application"
msgstr ""
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
#: core/choices.py:43
msgid "Reader"
@@ -239,8 +243,8 @@ msgstr "uporabnik"
msgid "users"
msgstr "uporabniki"
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
msgid "title"
msgstr "naslov"
@@ -256,188 +260,188 @@ msgstr "Dokument"
msgid "Documents"
msgstr "Dokumenti"
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
msgid "Untitled Document"
msgstr "Dokument brez naslova"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:862 core/models.py:862
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} je delil dokument z vami!"
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:866 core/models.py:866
#, 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:869 core/models.py:869
#: build/lib/core/models.py:872 core/models.py:872
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} je delil dokument z vami: {title}"
#: build/lib/core/models.py:969 core/models.py:969
#: build/lib/core/models.py:973 core/models.py:973
msgid "Document/user link trace"
msgstr "Dokument/sled povezave uporabnika"
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:974 core/models.py:974
msgid "Document/user link traces"
msgstr "Sledi povezav dokumenta/uporabnika"
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:980 core/models.py:980
msgid "A link trace already exists for this document/user."
msgstr "Za ta dokument/uporabnika že obstaja sled povezave."
#: build/lib/core/models.py:999 core/models.py:999
#: build/lib/core/models.py:1003 core/models.py:1003
msgid "Document favorite"
msgstr "Priljubljeni dokument"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "Document favorites"
msgstr "Priljubljeni dokumenti"
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1010 core/models.py:1010
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:1028 core/models.py:1028
#: build/lib/core/models.py:1032 core/models.py:1032
msgid "Document/user relation"
msgstr "Odnos dokument/uporabnik"
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "Document/user relations"
msgstr "Odnosi dokument/uporabnik"
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This user is already in this document."
msgstr "Ta uporabnik je že v tem dokumentu."
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "This team is already in this document."
msgstr "Ta ekipa je že v tem dokumentu."
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
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:1198 core/models.py:1198
#: build/lib/core/models.py:1202 core/models.py:1202
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1199 core/models.py:1199
#: build/lib/core/models.py:1203 core/models.py:1203
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1209 core/models.py:1209
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1262 core/models.py:1262
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1266 core/models.py:1266
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1276 core/models.py:1276
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1314 core/models.py:1314
#: build/lib/core/models.py:1318 core/models.py:1318
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1315 core/models.py:1315
#: build/lib/core/models.py:1319 core/models.py:1319
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1365 core/models.py:1365
#: build/lib/core/models.py:1369 core/models.py:1369
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1366 core/models.py:1366
#: build/lib/core/models.py:1370 core/models.py:1370
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1415 core/models.py:1415
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1419 core/models.py:1419
#: build/lib/core/models.py:1423 core/models.py:1423
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1420 core/models.py:1420
#: build/lib/core/models.py:1424 core/models.py:1424
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1431 core/models.py:1431
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr "opis"
#: build/lib/core/models.py:1432 core/models.py:1432
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr "koda"
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1435 core/models.py:1435
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr "javno"
#: build/lib/core/models.py:1437 core/models.py:1437
#: 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:1443 core/models.py:1443
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr "Predloga"
#: build/lib/core/models.py:1444 core/models.py:1444
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr "Predloge"
#: build/lib/core/models.py:1497 core/models.py:1497
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr "Odnos predloga/uporabnik"
#: build/lib/core/models.py:1498 core/models.py:1498
#: 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:1504 core/models.py:1504
#: 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:1510 core/models.py:1510
#: 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:1587 core/models.py:1587
#: build/lib/core/models.py:1591 core/models.py:1591
msgid "email address"
msgstr "elektronski naslov"
#: build/lib/core/models.py:1606 core/models.py:1606
#: build/lib/core/models.py:1610 core/models.py:1610
msgid "Document invitation"
msgstr "Vabilo na dokument"
#: build/lib/core/models.py:1607 core/models.py:1607
#: build/lib/core/models.py:1611 core/models.py:1611
msgid "Document invitations"
msgstr "Vabila na dokument"
#: build/lib/core/models.py:1627 core/models.py:1627
#: build/lib/core/models.py:1631 core/models.py:1631
msgid "This email is already associated to a registered user."
msgstr "Ta e-poštni naslov je že povezan z registriranim uporabnikom."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: Swedish\n"
"Language: sv_SE\n"
@@ -79,11 +79,15 @@ msgstr ""
msgid "Format"
msgstr "Format"
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr ""
#: build/lib/core/apps.py:12 core/apps.py:12
msgid "Impress core application"
msgstr ""
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
#: core/choices.py:43
msgid "Reader"
@@ -239,8 +243,8 @@ msgstr ""
msgid "users"
msgstr ""
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
msgid "title"
msgstr ""
@@ -256,188 +260,188 @@ msgstr ""
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:862 core/models.py:862
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:866 core/models.py:866
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:869 core/models.py:869
#: build/lib/core/models.py:872 core/models.py:872
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:969 core/models.py:969
#: build/lib/core/models.py:973 core/models.py:973
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:974 core/models.py:974
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:980 core/models.py:980
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:999 core/models.py:999
#: build/lib/core/models.py:1003 core/models.py:1003
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1010 core/models.py:1010
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1028 core/models.py:1028
#: build/lib/core/models.py:1032 core/models.py:1032
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1202 core/models.py:1202
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1199 core/models.py:1199
#: build/lib/core/models.py:1203 core/models.py:1203
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1209 core/models.py:1209
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1262 core/models.py:1262
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1266 core/models.py:1266
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1276 core/models.py:1276
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1314 core/models.py:1314
#: build/lib/core/models.py:1318 core/models.py:1318
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1315 core/models.py:1315
#: build/lib/core/models.py:1319 core/models.py:1319
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1365 core/models.py:1365
#: build/lib/core/models.py:1369 core/models.py:1369
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1366 core/models.py:1366
#: build/lib/core/models.py:1370 core/models.py:1370
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1415 core/models.py:1415
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1419 core/models.py:1419
#: build/lib/core/models.py:1423 core/models.py:1423
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1420 core/models.py:1420
#: build/lib/core/models.py:1424 core/models.py:1424
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1431 core/models.py:1431
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr ""
#: build/lib/core/models.py:1432 core/models.py:1432
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr ""
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr ""
#: build/lib/core/models.py:1435 core/models.py:1435
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr ""
#: build/lib/core/models.py:1437 core/models.py:1437
#: 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:1443 core/models.py:1443
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1444 core/models.py:1444
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1497 core/models.py:1497
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1498 core/models.py:1498
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1504 core/models.py:1504
#: build/lib/core/models.py:1508 core/models.py:1508
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1510 core/models.py:1510
#: build/lib/core/models.py:1514 core/models.py:1514
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1587 core/models.py:1587
#: build/lib/core/models.py:1591 core/models.py:1591
msgid "email address"
msgstr "e-postadress"
#: build/lib/core/models.py:1606 core/models.py:1606
#: build/lib/core/models.py:1610 core/models.py:1610
msgid "Document invitation"
msgstr "Bjud in dokument"
#: build/lib/core/models.py:1607 core/models.py:1607
#: build/lib/core/models.py:1611 core/models.py:1611
msgid "Document invitations"
msgstr "Inbjudningar dokument"
#: build/lib/core/models.py:1627 core/models.py:1627
#: build/lib/core/models.py:1631 core/models.py:1631
msgid "This email is already associated to a registered user."
msgstr "Denna e-postadress är redan associerad med en registrerad användare."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: Turkish\n"
"Language: tr_TR\n"
@@ -79,11 +79,15 @@ msgstr ""
msgid "Format"
msgstr ""
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr ""
#: build/lib/core/apps.py:12 core/apps.py:12
msgid "Impress core application"
msgstr ""
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
#: core/choices.py:43
msgid "Reader"
@@ -239,8 +243,8 @@ msgstr ""
msgid "users"
msgstr ""
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
msgid "title"
msgstr ""
@@ -256,188 +260,188 @@ msgstr ""
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:862 core/models.py:862
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:866 core/models.py:866
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:869 core/models.py:869
#: build/lib/core/models.py:872 core/models.py:872
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:969 core/models.py:969
#: build/lib/core/models.py:973 core/models.py:973
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:974 core/models.py:974
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:980 core/models.py:980
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:999 core/models.py:999
#: build/lib/core/models.py:1003 core/models.py:1003
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1010 core/models.py:1010
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1028 core/models.py:1028
#: build/lib/core/models.py:1032 core/models.py:1032
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1202 core/models.py:1202
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1199 core/models.py:1199
#: build/lib/core/models.py:1203 core/models.py:1203
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1209 core/models.py:1209
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1262 core/models.py:1262
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1266 core/models.py:1266
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1276 core/models.py:1276
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1314 core/models.py:1314
#: build/lib/core/models.py:1318 core/models.py:1318
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1315 core/models.py:1315
#: build/lib/core/models.py:1319 core/models.py:1319
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1365 core/models.py:1365
#: build/lib/core/models.py:1369 core/models.py:1369
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1366 core/models.py:1366
#: build/lib/core/models.py:1370 core/models.py:1370
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1415 core/models.py:1415
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1419 core/models.py:1419
#: build/lib/core/models.py:1423 core/models.py:1423
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1420 core/models.py:1420
#: build/lib/core/models.py:1424 core/models.py:1424
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1431 core/models.py:1431
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr ""
#: build/lib/core/models.py:1432 core/models.py:1432
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr ""
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr ""
#: build/lib/core/models.py:1435 core/models.py:1435
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr ""
#: build/lib/core/models.py:1437 core/models.py:1437
#: 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:1443 core/models.py:1443
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1444 core/models.py:1444
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1497 core/models.py:1497
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1498 core/models.py:1498
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1504 core/models.py:1504
#: build/lib/core/models.py:1508 core/models.py:1508
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1510 core/models.py:1510
#: build/lib/core/models.py:1514 core/models.py:1514
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1587 core/models.py:1587
#: build/lib/core/models.py:1591 core/models.py:1591
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1606 core/models.py:1606
#: build/lib/core/models.py:1610 core/models.py:1610
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1607 core/models.py:1607
#: build/lib/core/models.py:1611 core/models.py:1611
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1627 core/models.py:1627
#: build/lib/core/models.py:1631 core/models.py:1631
msgid "This email is already associated to a registered user."
msgstr ""

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: Ukrainian\n"
"Language: uk_UA\n"
@@ -79,11 +79,15 @@ msgstr "Тип вмісту"
msgid "Format"
msgstr "Формат"
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr "копія {title}"
#: build/lib/core/apps.py:12 core/apps.py:12
msgid "Impress core application"
msgstr "Ядро додатку Impress"
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
#: core/choices.py:43
msgid "Reader"
@@ -239,8 +243,8 @@ msgstr "користувач"
msgid "users"
msgstr "користувачі"
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
msgid "title"
msgstr "заголовок"
@@ -256,188 +260,188 @@ msgstr "Документ"
msgid "Documents"
msgstr "Документи"
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
msgid "Untitled Document"
msgstr "Документ без назви"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:862 core/models.py:862
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} ділиться з вами документом!"
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:866 core/models.py:866
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} запрошує вас для роботи з документом із роллю \"{role}\":"
#: build/lib/core/models.py:869 core/models.py:869
#: build/lib/core/models.py:872 core/models.py:872
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} ділиться з вами документом: {title}"
#: build/lib/core/models.py:969 core/models.py:969
#: build/lib/core/models.py:973 core/models.py:973
msgid "Document/user link trace"
msgstr "Трасування посилання Документ/користувач"
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:974 core/models.py:974
msgid "Document/user link traces"
msgstr "Трасування посилань Документ/користувач"
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:980 core/models.py:980
msgid "A link trace already exists for this document/user."
msgstr "Відстеження вже існуючих посилань для цього документа/користувача."
#: build/lib/core/models.py:999 core/models.py:999
#: build/lib/core/models.py:1003 core/models.py:1003
msgid "Document favorite"
msgstr "Обраний документ"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "Document favorites"
msgstr "Обрані документи"
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1010 core/models.py:1010
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Цей документ вже вказаний як обраний для одного користувача."
#: build/lib/core/models.py:1028 core/models.py:1028
#: build/lib/core/models.py:1032 core/models.py:1032
msgid "Document/user relation"
msgstr "Відносини документ/користувач"
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "Document/user relations"
msgstr "Відносини документ/користувач"
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This user is already in this document."
msgstr "Цей користувач вже має доступ до цього документу."
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "This team is already in this document."
msgstr "Ця команда вже має доступ до цього документа."
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
msgid "Either user or team must be set, not both."
msgstr "Вкажіть користувача або команду, а не обох."
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1202 core/models.py:1202
msgid "Document ask for access"
msgstr "Запит доступу до документа"
#: build/lib/core/models.py:1199 core/models.py:1199
#: build/lib/core/models.py:1203 core/models.py:1203
msgid "Document ask for accesses"
msgstr "Запит доступу для документа"
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1209 core/models.py:1209
msgid "This user has already asked for access to this document."
msgstr "Цей користувач вже попросив доступ до цього документа."
#: build/lib/core/models.py:1262 core/models.py:1262
#: build/lib/core/models.py:1266 core/models.py:1266
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} хоче отримати доступ до документа!"
#: build/lib/core/models.py:1266 core/models.py:1266
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} бажає отримати доступ до наступного документа:"
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1276 core/models.py:1276
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} запитує доступ до документа: {title}"
#: build/lib/core/models.py:1314 core/models.py:1314
#: build/lib/core/models.py:1318 core/models.py:1318
msgid "Thread"
msgstr "Обговорення"
#: build/lib/core/models.py:1315 core/models.py:1315
#: build/lib/core/models.py:1319 core/models.py:1319
msgid "Threads"
msgstr "Обговорення"
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
msgid "Anonymous"
msgstr "Анонім"
#: build/lib/core/models.py:1365 core/models.py:1365
#: build/lib/core/models.py:1369 core/models.py:1369
msgid "Comment"
msgstr "Коментар"
#: build/lib/core/models.py:1366 core/models.py:1366
#: build/lib/core/models.py:1370 core/models.py:1370
msgid "Comments"
msgstr "Коментарі"
#: build/lib/core/models.py:1415 core/models.py:1415
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "This emoji has already been reacted to this comment."
msgstr "Цим емодзі вже відреагували на цей коментар."
#: build/lib/core/models.py:1419 core/models.py:1419
#: build/lib/core/models.py:1423 core/models.py:1423
msgid "Reaction"
msgstr "Реакція"
#: build/lib/core/models.py:1420 core/models.py:1420
#: build/lib/core/models.py:1424 core/models.py:1424
msgid "Reactions"
msgstr "Реакції"
#: build/lib/core/models.py:1431 core/models.py:1431
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr "опис"
#: build/lib/core/models.py:1432 core/models.py:1432
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr "код"
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1435 core/models.py:1435
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr "публічне"
#: build/lib/core/models.py:1437 core/models.py:1437
#: 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:1443 core/models.py:1443
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr "Шаблон"
#: build/lib/core/models.py:1444 core/models.py:1444
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr "Шаблони"
#: build/lib/core/models.py:1497 core/models.py:1497
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr "Відношення шаблон/користувач"
#: build/lib/core/models.py:1498 core/models.py:1498
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr "Відношення шаблон/користувач"
#: build/lib/core/models.py:1504 core/models.py:1504
#: build/lib/core/models.py:1508 core/models.py:1508
msgid "This user is already in this template."
msgstr "Цей користувач вже має доступ до цього шаблону."
#: build/lib/core/models.py:1510 core/models.py:1510
#: build/lib/core/models.py:1514 core/models.py:1514
msgid "This team is already in this template."
msgstr "Ця команда вже має доступ до цього шаблону."
#: build/lib/core/models.py:1587 core/models.py:1587
#: build/lib/core/models.py:1591 core/models.py:1591
msgid "email address"
msgstr "електронна адреса"
#: build/lib/core/models.py:1606 core/models.py:1606
#: build/lib/core/models.py:1610 core/models.py:1610
msgid "Document invitation"
msgstr "Запрошення до редагування документа"
#: build/lib/core/models.py:1607 core/models.py:1607
#: build/lib/core/models.py:1611 core/models.py:1611
msgid "Document invitations"
msgstr "Запрошення до редагування документів"
#: build/lib/core/models.py:1627 core/models.py:1627
#: build/lib/core/models.py:1631 core/models.py:1631
msgid "This email is already associated to a registered user."
msgstr "Ця електронна пошта вже пов'язана з зареєстрованим користувачем."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"POT-Creation-Date: 2026-01-08 15:38+0000\n"
"PO-Revision-Date: 2026-01-13 13:17\n"
"Last-Translator: \n"
"Language-Team: Chinese Simplified\n"
"Language: zh_CN\n"
@@ -79,11 +79,15 @@ msgstr "正文类型"
msgid "Format"
msgstr "格式"
#: build/lib/core/api/viewsets.py:1004 core/api/viewsets.py:1004
#: build/lib/core/api/viewsets.py:1081 core/api/viewsets.py:1081
#, python-brace-format
msgid "copy of {title}"
msgstr "{title} 的副本"
#: build/lib/core/apps.py:12 core/apps.py:12
msgid "Impress core application"
msgstr ""
#: build/lib/core/choices.py:35 build/lib/core/choices.py:43 core/choices.py:35
#: core/choices.py:43
msgid "Reader"
@@ -239,8 +243,8 @@ msgstr "用户"
msgid "users"
msgstr "个用户"
#: build/lib/core/models.py:361 build/lib/core/models.py:1430
#: core/models.py:361 core/models.py:1430
#: build/lib/core/models.py:361 build/lib/core/models.py:1434
#: core/models.py:361 core/models.py:1434
msgid "title"
msgstr "标题"
@@ -256,188 +260,188 @@ msgstr "文档"
msgid "Documents"
msgstr "个文档"
#: build/lib/core/models.py:424 build/lib/core/models.py:824 core/models.py:424
#: core/models.py:824
#: build/lib/core/models.py:424 build/lib/core/models.py:827 core/models.py:424
#: core/models.py:827
msgid "Untitled Document"
msgstr "未命名文档"
#: build/lib/core/models.py:859 core/models.py:859
#: build/lib/core/models.py:862 core/models.py:862
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} 与您共享了一个文档!"
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:866 core/models.py:866
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} 邀请您以“{role}”角色访问以下文档:"
#: build/lib/core/models.py:869 core/models.py:869
#: build/lib/core/models.py:872 core/models.py:872
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} 与您共享了一个文档:{title}"
#: build/lib/core/models.py:969 core/models.py:969
#: build/lib/core/models.py:973 core/models.py:973
msgid "Document/user link trace"
msgstr "文档/用户链接跟踪"
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:974 core/models.py:974
msgid "Document/user link traces"
msgstr "个文档/用户链接跟踪"
#: build/lib/core/models.py:976 core/models.py:976
#: build/lib/core/models.py:980 core/models.py:980
msgid "A link trace already exists for this document/user."
msgstr "此文档/用户的链接跟踪已存在。"
#: build/lib/core/models.py:999 core/models.py:999
#: build/lib/core/models.py:1003 core/models.py:1003
msgid "Document favorite"
msgstr "文档收藏"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1004 core/models.py:1004
msgid "Document favorites"
msgstr "文档收藏夹"
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1010 core/models.py:1010
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "该文档已被同一用户的收藏关系实例关联。"
#: build/lib/core/models.py:1028 core/models.py:1028
#: build/lib/core/models.py:1032 core/models.py:1032
msgid "Document/user relation"
msgstr "文档/用户关系"
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1033 core/models.py:1033
msgid "Document/user relations"
msgstr "文档/用户关系集"
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1039 core/models.py:1039
msgid "This user is already in this document."
msgstr "该用户已在此文档中。"
#: build/lib/core/models.py:1041 core/models.py:1041
#: build/lib/core/models.py:1045 core/models.py:1045
msgid "This team is already in this document."
msgstr "该团队已在此文档中。"
#: build/lib/core/models.py:1047 build/lib/core/models.py:1516
#: core/models.py:1047 core/models.py:1516
#: build/lib/core/models.py:1051 build/lib/core/models.py:1520
#: core/models.py:1051 core/models.py:1520
msgid "Either user or team must be set, not both."
msgstr "必须设置用户或团队之一,不能同时设置两者。"
#: build/lib/core/models.py:1198 core/models.py:1198
#: build/lib/core/models.py:1202 core/models.py:1202
msgid "Document ask for access"
msgstr "文档需要访问权限"
#: build/lib/core/models.py:1199 core/models.py:1199
#: build/lib/core/models.py:1203 core/models.py:1203
msgid "Document ask for accesses"
msgstr "文档需要访问权限"
#: build/lib/core/models.py:1205 core/models.py:1205
#: build/lib/core/models.py:1209 core/models.py:1209
msgid "This user has already asked for access to this document."
msgstr "用户已申请该文档的访问权限。"
#: build/lib/core/models.py:1262 core/models.py:1262
#: build/lib/core/models.py:1266 core/models.py:1266
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} 申请访问文档!"
#: build/lib/core/models.py:1266 core/models.py:1266
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} 申请访问以下文档:"
#: build/lib/core/models.py:1272 core/models.py:1272
#: build/lib/core/models.py:1276 core/models.py:1276
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name}申请文档:{title}的访问权限"
#: build/lib/core/models.py:1314 core/models.py:1314
#: build/lib/core/models.py:1318 core/models.py:1318
msgid "Thread"
msgstr ""
#: build/lib/core/models.py:1315 core/models.py:1315
#: build/lib/core/models.py:1319 core/models.py:1319
msgid "Threads"
msgstr ""
#: build/lib/core/models.py:1318 build/lib/core/models.py:1370
#: core/models.py:1318 core/models.py:1370
#: build/lib/core/models.py:1322 build/lib/core/models.py:1374
#: core/models.py:1322 core/models.py:1374
msgid "Anonymous"
msgstr ""
#: build/lib/core/models.py:1365 core/models.py:1365
#: build/lib/core/models.py:1369 core/models.py:1369
msgid "Comment"
msgstr ""
#: build/lib/core/models.py:1366 core/models.py:1366
#: build/lib/core/models.py:1370 core/models.py:1370
msgid "Comments"
msgstr ""
#: build/lib/core/models.py:1415 core/models.py:1415
#: build/lib/core/models.py:1419 core/models.py:1419
msgid "This emoji has already been reacted to this comment."
msgstr ""
#: build/lib/core/models.py:1419 core/models.py:1419
#: build/lib/core/models.py:1423 core/models.py:1423
msgid "Reaction"
msgstr ""
#: build/lib/core/models.py:1420 core/models.py:1420
#: build/lib/core/models.py:1424 core/models.py:1424
msgid "Reactions"
msgstr ""
#: build/lib/core/models.py:1431 core/models.py:1431
#: build/lib/core/models.py:1435 core/models.py:1435
msgid "description"
msgstr "说明"
#: build/lib/core/models.py:1432 core/models.py:1432
#: build/lib/core/models.py:1436 core/models.py:1436
msgid "code"
msgstr "代码"
#: build/lib/core/models.py:1433 core/models.py:1433
#: build/lib/core/models.py:1437 core/models.py:1437
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1435 core/models.py:1435
#: build/lib/core/models.py:1439 core/models.py:1439
msgid "public"
msgstr "公开"
#: build/lib/core/models.py:1437 core/models.py:1437
#: 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:1443 core/models.py:1443
#: build/lib/core/models.py:1447 core/models.py:1447
msgid "Template"
msgstr "模板"
#: build/lib/core/models.py:1444 core/models.py:1444
#: build/lib/core/models.py:1448 core/models.py:1448
msgid "Templates"
msgstr "模板"
#: build/lib/core/models.py:1497 core/models.py:1497
#: build/lib/core/models.py:1501 core/models.py:1501
msgid "Template/user relation"
msgstr "模板/用户关系"
#: build/lib/core/models.py:1498 core/models.py:1498
#: build/lib/core/models.py:1502 core/models.py:1502
msgid "Template/user relations"
msgstr "模板/用户关系集"
#: build/lib/core/models.py:1504 core/models.py:1504
#: build/lib/core/models.py:1508 core/models.py:1508
msgid "This user is already in this template."
msgstr "该用户已在此模板中。"
#: build/lib/core/models.py:1510 core/models.py:1510
#: build/lib/core/models.py:1514 core/models.py:1514
msgid "This team is already in this template."
msgstr "该团队已在此模板中。"
#: build/lib/core/models.py:1587 core/models.py:1587
#: build/lib/core/models.py:1591 core/models.py:1591
msgid "email address"
msgstr "电子邮件地址"
#: build/lib/core/models.py:1606 core/models.py:1606
#: build/lib/core/models.py:1610 core/models.py:1610
msgid "Document invitation"
msgstr "文档邀请"
#: build/lib/core/models.py:1607 core/models.py:1607
#: build/lib/core/models.py:1611 core/models.py:1611
msgid "Document invitations"
msgstr "文档邀请"
#: build/lib/core/models.py:1627 core/models.py:1627
#: build/lib/core/models.py:1631 core/models.py:1631
msgid "This email is already associated to a registered user."
msgstr "此电子邮件已经与现有注册用户关联。"

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "4.2.0"
version = "4.4.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -19,19 +19,19 @@ classifiers = [
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.12",
]
description = "An application to print markdown to pdf from a set of managed templates."
description = "Docs is a collaborative text editor designed to address common challenges in knowledge building and sharing."
keywords = ["Django", "Contacts", "Templates", "RBAC"]
license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"beautifulsoup4==4.14.2",
"boto3==1.40.74",
"beautifulsoup4==4.14.3",
"boto3==1.42.17",
"Brotli==1.2.0",
"celery[redis]==5.5.3",
"django-configurations==2.5.1",
"django-cors-headers==4.9.0",
"django-countries==8.1.0",
"django-countries==8.2.0",
"django-csp==4.0",
"django-filter==25.2",
"django-lasuite[all]==0.0.22",
@@ -39,8 +39,8 @@ dependencies = [
"django-redis==6.0.0",
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django==5.2.9",
"django-treebeard==4.7.1",
"django<6.0.0",
"django-treebeard==4.8.0",
"djangorestframework==3.16.1",
"drf_spectacular==0.29.0",
"dockerflow==2024.4.2",
@@ -48,18 +48,19 @@ dependencies = [
"factory_boy==3.3.3",
"gunicorn==23.0.0",
"jsonschema==4.25.1",
"langfuse==3.11.2",
"lxml==6.0.2",
"markdown==3.10",
"mozilla-django-oidc==4.0.1",
"mozilla-django-oidc==5.0.2",
"nested-multipart-parser==1.6.0",
"openai==2.8.0",
"psycopg[binary]==3.2.12",
"pycrdt==0.12.43",
"openai==2.14.0",
"psycopg[binary]==3.3.2",
"pycrdt==0.12.44",
"PyJWT==2.10.1",
"python-magic==0.4.27",
"redis<6.0.0",
"requests==2.32.5",
"sentry-sdk==2.44.0",
"sentry-sdk==2.48.0",
"whitenoise==6.11.0",
]
@@ -73,20 +74,20 @@ dependencies = [
dev = [
"django-extensions==4.1",
"django-test-migrations==1.5.0",
"drf-spectacular-sidecar==2025.10.1",
"drf-spectacular-sidecar==2025.12.1",
"freezegun==1.5.5",
"ipdb==0.13.13",
"ipython==9.7.0",
"pyfakefs==5.10.2",
"ipython==9.8.0",
"pyfakefs==6.0.0",
"pylint-django==2.6.1",
"pylint<4.0.0",
"pytest-cov==7.0.0",
"pytest-django==4.11.1",
"pytest==9.0.1",
"pytest==9.0.2",
"pytest-icdiff==0.9",
"pytest-xdist==3.8.0",
"responses==0.25.8",
"ruff==0.14.5",
"ruff==0.14.10",
"types-requests==2.32.4.20250913",
]

View File

@@ -1,6 +1,6 @@
# e2e
test-results/
report/
report*/
blob-report/
playwright/.auth/
playwright/.cache/

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,60 @@
![473389927-e4ff1794-69f3-460a-85f8-fec993cd74d6.png](http://localhost:3000/assets/logo-suite-numerique.png)![497094770-53e5f8e2-c93e-4a0b-a82f-cd184fd03f51.svg](http://localhost:3000/assets/icon-docs.svg)
# Lorem Ipsum import Document
## Introduction
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam auctor, nisl eget ultricies tincidunt, nisl nisl aliquam nisl, eget ultricies nisl nisl eget nisl.
### Subsection 1.1
* **Bold text**: Lorem ipsum dolor sit amet.
* *Italic text*: Consectetur adipiscing elit.
* ~~Strikethrough text~~: Nullam auctor, nisl eget ultricies tincidunt.
1. First item in an ordered list.
2. Second item in an ordered list.
* Indented bullet point.
* Another indented bullet point.
3. Third item in an ordered list.
### Subsection 1.2
**Code block:**
```js
const hello_world = () => {
console.log("Hello, world!");
}
```
**Blockquote:**
> Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam auctor, nisl eget ultricies tincidunt.
**Horizontal rule:**
***
**Table:**
| Syntax | Description |
| --------- | ----------- |
| Header | Title |
| Paragraph | Text |
**Inline code:**
Use the `printf()` function.
**Link:** [Example](http://localhost:3000/)
## Conclusion
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam auctor, nisl eget ultricies tincidunt, nisl nisl aliquam nisl, eget ultricies nisl nisl eget nisl.

View File

@@ -93,9 +93,7 @@ test.describe('Config', () => {
expect(
await page.locator('button[data-test="convertMarkdown"]').count(),
).toBe(1);
expect(await page.locator('button[data-test="ai-actions"]').count()).toBe(
0,
);
await expect(page.getByRole('button', { name: 'Ask AI' })).toBeHidden();
});
test('it checks that Crisp is trying to init from config endpoint', async ({

View File

@@ -58,7 +58,7 @@ test.describe('Doc Comments', () => {
await page.getByRole('button', { name: '👍' }).click();
await expect(
thread.getByRole('img', { name: 'E2E Chromium' }).first(),
thread.getByRole('img', { name: `E2E ${browserName}` }).first(),
).toBeVisible();
await expect(thread.getByText('This is a comment').first()).toBeVisible();
await expect(thread.getByText(`E2E ${browserName}`).first()).toBeVisible();
@@ -394,6 +394,8 @@ test.describe('Doc Comments mobile', () => {
await thread.getByRole('paragraph').first().fill('This is a comment');
await thread.locator('[data-test="save"]').click();
await expect(thread.getByText('This is a comment').first()).toBeHidden();
// Check toolbar is closed after adding a comment
await expect(page.getByRole('button', { name: 'Paragraph' })).toBeHidden();
await editor.first().click();
await editor.getByText('Hello').click();

View File

@@ -1,4 +1,3 @@
/* eslint-disable playwright/no-conditional-expect */
import path from 'path';
import { expect, test } from '@playwright/test';
@@ -389,13 +388,72 @@ 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) => {
test('it checks the AI feature', async ({ page, browserName }) => {
await overrideConfig(page, {
AI_BOT: {
name: 'Albert AI',
color: '#8bc6ff',
},
});
await page.goto('/');
await page.route(/.*\/ai-proxy\//, async (route) => {
const request = route.request();
if (request.method().includes('POST')) {
await route.fulfill({
json: {
answer: 'Bonjour le monde',
id: 'chatcmpl-b1e7a9e456ca41f78fec130d552a6bf5',
choices: [
{
finish_reason: 'stop',
index: 0,
logprobs: null,
message: {
content: '',
refusal: null,
role: 'assistant',
annotations: null,
audio: null,
function_call: null,
tool_calls: [
{
id: 'chatcmpl-tool-2e3567dfecf94a4c85e27a3528337718',
function: {
arguments:
'{"operations": [{"type": "update", "id": "initialBlockId$", "block": "<p>Bonjour le monde</p>"}]}',
name: 'json',
},
type: 'function',
},
],
reasoning_content: null,
},
stop_reason: null,
},
],
created: 1749549477,
model: 'neuralmagic/Meta-Llama-3.1-70B-Instruct-FP8',
object: 'chat.completion',
service_tier: null,
system_fingerprint: null,
usage: {
completion_tokens: 0,
prompt_tokens: 204,
total_tokens: 204,
completion_tokens_details: null,
prompt_tokens_details: null,
details: [
{
id: 'chatcmpl-b1e7a9e456ca41f78fec130d552a6bf5',
model: 'neuralmagic/Meta-Llama-3.1-70B-Instruct-FP8',
prompt_tokens: 204,
completion_tokens: 0,
total_tokens: 204,
},
],
},
prompt_logprobs: null,
},
});
} else {
@@ -408,118 +466,84 @@ test.describe('Doc Editor', () => {
await page.locator('.bn-block-outer').last().fill('Hello World');
const editor = page.locator('.ProseMirror');
await editor.getByText('Hello').selectText();
await editor.getByText('Hello World').selectText();
await page.getByRole('button', { name: 'AI' }).click();
// Check from toolbar
await page.getByRole('button', { name: 'Ask AI' }).click();
await expect(
page.getByRole('menuitem', { name: 'Use as prompt' }),
page.getByRole('option', { name: 'Improve Writing' }),
).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' }),
page.getByRole('option', { name: 'Fix Spelling' }),
).toBeVisible();
await expect(page.getByRole('option', { name: 'Translate' })).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('option', { name: 'Translate' }).click();
await page.getByPlaceholder('Ask AI anything…').fill('French');
await page.getByPlaceholder('Ask AI anything…').press('Enter');
await expect(editor.getByText('Albert AI')).toBeVisible();
await page
.locator('p.bn-mt-suggestion-menu-item-title')
.getByText('Accept')
.click();
await page.getByRole('menuitem', { name: 'English', exact: true }).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();
});
[
{ 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',
},
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_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();
}
],
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();
});
test('it downloads unsafe files', async ({ page, browserName }) => {
@@ -604,7 +628,7 @@ test.describe('Doc Editor', () => {
await verifyDocName(page, randomDoc);
const editor = await openSuggestionMenu({ page });
const { editor } = await openSuggestionMenu({ page });
await page.getByText('Embedded file').click();
await page.getByText('Upload file').click();
@@ -890,6 +914,9 @@ test.describe('Doc Editor', () => {
await expect(interlinkChild.locator('svg').first()).toBeHidden();
await interlinkChild.click();
// wait for navigation to complete
await page.waitForTimeout(1000);
await verifyDocName(page, docChild2);
await editor.click();

View File

@@ -1,18 +1,19 @@
import fs from 'fs';
import path from 'path';
import { expect, test } from '@playwright/test';
import { Download, Page, 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 { createRootSubPage } from './utils-sub-pages';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -34,9 +35,6 @@ test.describe('Doc Export', () => {
await expect(
page.getByText(/Download your document in a \.docx, \.odt.*format\./i),
).toBeVisible();
await expect(
page.getByRole('combobox', { name: 'Template' }),
).toBeVisible();
await expect(page.getByRole('combobox', { name: 'Format' })).toBeVisible();
await expect(
page.getByRole('button', {
@@ -46,81 +44,14 @@ test.describe('Doc Export', () => {
await expect(page.getByTestId('doc-export-download-button')).toBeVisible();
});
test('it exports the doc with pdf line break', async ({
page,
browserName,
}) => {
const [randomDoc] = await createDoc(
page,
'doc-editor-line-break',
browserName,
1,
);
await verifyDocName(page, randomDoc);
const editor = await writeInEditor({ page, text: 'Hello' });
await page.keyboard.press('Enter');
await openSuggestionMenu({ page });
await page.getByText('Page Break').click();
await expect(
editor.locator('div[data-content-type="pageBreak"]'),
).toBeVisible();
await writeInEditor({ page, text: 'World' });
await page
.getByRole('button', {
name: 'Export the document',
})
.click();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
});
void page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfParse = new PDFParse({ data: pdfBuffer });
const pdfInfo = await pdfParse.getInfo();
const pdfText = await pdfParse.getText();
expect(pdfInfo.total).toBe(2);
expect(pdfText.pages).toStrictEqual([
{ text: 'Hello', num: 1 },
{ text: 'World', num: 2 },
]);
expect(pdfInfo?.info.Title).toBe(randomDoc);
});
/**
* We override the document content to ensure that the exported DOCX
* contains various elements for testing.
* We don't check the content of the DOCX here, just that the export works
* and the file is correctly named.
*/
test('it exports the doc to docx', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1);
await verifyDocName(page, randomDoc);
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
await page.keyboard.press('Enter');
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Resizable image with caption').click();
const fileChooserPromise = page.waitForEvent('filechooser');
await page.getByText('Upload image').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(path.join(__dirname, 'assets/test.svg'));
const image = page
.locator('.--docs--editor-container img.bn-visual-media')
.first();
await expect(image).toBeVisible();
const randomDoc = await overrideDocContent({ page, browserName });
await page
.getByRole('button', {
@@ -143,29 +74,14 @@ test.describe('Doc Export', () => {
expect(download.suggestedFilename()).toBe(`${randomDoc}.docx`);
});
/**
* We override the document content to ensure that the exported ODT
* contains various elements for testing.
* We don't check the content of the ODT here, just that the export works
* and the file is correctly named.
*/
test('it exports the doc to odt', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-editor-odt', browserName, 1);
await verifyDocName(page, randomDoc);
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World ODT');
await page.keyboard.press('Enter');
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Resizable image with caption').click();
const fileChooserPromise = page.waitForEvent('filechooser');
await page.getByText('Upload image').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(path.join(__dirname, 'assets/test.svg'));
const image = page
.locator('.--docs--editor-container img.bn-visual-media')
.first();
await expect(image).toBeVisible();
const randomDoc = await overrideDocContent({ page, browserName });
await page
.getByRole('button', {
@@ -317,20 +233,6 @@ test.describe('Doc Export', () => {
})
.click();
await page
.getByRole('combobox', {
name: 'Template',
})
.click();
await page
.getByRole('option', {
name: 'Demo Template',
})
.click({
delay: 100,
});
await new Promise((resolve) => setTimeout(resolve, 1000));
await expect(page.getByTestId('doc-export-download-button')).toBeVisible();
@@ -358,108 +260,6 @@ test.describe('Doc Export', () => {
expect(pdfText.text).toContain('Hello World');
});
test('it exports the doc with quotes', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'export-quotes', browserName, 1);
const editor = page.locator('.ProseMirror.bn-editor');
// Trigger slash menu to show menu
await editor.click();
await editor.fill('/');
await page.getByText('Quote or excerpt').click();
await expect(
editor.locator('.bn-block-content[data-content-type="quote"]'),
).toBeVisible();
await editor
.locator('.bn-block-content[data-content-type="quote"]')
.fill('Hello World');
await expect(editor.getByText('Hello World')).toHaveCSS(
'font-style',
'italic',
);
await page
.getByRole('button', {
name: 'Export the document',
})
.click();
await expect(page.getByTestId('doc-export-download-button')).toBeVisible();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
});
void page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfParse = new PDFParse({ data: pdfBuffer });
const pdfText = await pdfParse.getText();
expect(pdfText.text).toContain('Hello World');
});
test('it exports the doc with multi columns', async ({
page,
browserName,
}) => {
const [randomDoc] = await createDoc(
page,
'doc-multi-columns',
browserName,
1,
);
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Three Columns', { exact: true }).click();
await page.locator('.bn-block-column').first().fill('Column 1');
await page.locator('.bn-block-column').nth(1).fill('Column 2');
await page.locator('.bn-block-column').last().fill('Column 3');
expect(await page.locator('.bn-block-column').count()).toBe(3);
await expect(
page.locator('.bn-block-column[data-node-type="column"]').first(),
).toHaveText('Column 1');
await expect(
page.locator('.bn-block-column[data-node-type="column"]').nth(1),
).toHaveText('Column 2');
await expect(
page.locator('.bn-block-column[data-node-type="column"]').last(),
).toHaveText('Column 3');
await page
.getByRole('button', {
name: 'Export the document',
})
.click();
await expect(
page.getByTestId('doc-open-modal-download-button'),
).toBeVisible();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
});
void page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfParse = new PDFParse({ data: pdfBuffer });
const pdfText = await pdfParse.getText();
expect(pdfText.text).toContain('Column 1');
expect(pdfText.text).toContain('Column 2');
expect(pdfText.text).toContain('Column 3');
});
test('it injects the correct language attribute into PDF export', async ({
page,
browserName,
@@ -506,53 +306,18 @@ test.describe('Doc Export', () => {
expect(pdfString).toContain('/Lang (fr)');
});
test('it exports the doc with interlinking', async ({
test('it exports the doc to PDF and checks regressions', async ({
page,
browserName,
}) => {
const [randomDoc] = await createDoc(
page,
'export-interlinking',
browserName,
1,
);
// 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 verifyDocName(page, randomDoc);
const { name: docChild } = await createRootSubPage(
page,
browserName,
'export-interlink-child',
);
await verifyDocName(page, docChild);
const editor = await openSuggestionMenu({ page });
await page.getByText('Link a doc').first().click();
const input = page.locator(
"span[data-inline-content-type='interlinkingSearchInline'] input",
);
const searchContainer = page.locator('.quick-search-container');
await input.fill('export-interlink');
await expect(searchContainer).toBeVisible();
await expect(searchContainer.getByText(randomDoc)).toBeVisible();
// We are in docChild, we want to create a link to randomDoc (parent)
await searchContainer.getByText(randomDoc).click();
// Search the interlinking link in the editor (not in the document tree)
const interlink = editor
.locator('.--docs--interlinking-link-inline-content')
.first();
await expect(interlink).toContainText(randomDoc);
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${docChild}.pdf`);
});
const randomDoc = await overrideDocContent({ page, browserName });
await page
.getByRole('button', {
@@ -560,77 +325,161 @@ test.describe('Doc Export', () => {
})
.click();
void page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${docChild}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfParse = new PDFParse({ data: pdfBuffer });
const pdfText = await pdfParse.getText();
expect(pdfText.text).toContain(randomDoc);
});
test('it exports the doc with interlinking to odt', async ({
page,
browserName,
}) => {
const [randomDoc] = await createDoc(
page,
'export-interlinking-odt',
browserName,
1,
);
await verifyDocName(page, randomDoc);
const { name: docChild } = await createRootSubPage(
page,
browserName,
'export-interlink-child-odt',
);
await verifyDocName(page, docChild);
const editor = await openSuggestionMenu({ page });
await page.getByText('Link a doc').first().click();
const input = page.locator(
"span[data-inline-content-type='interlinkingSearchInline'] input",
);
const searchContainer = page.locator('.quick-search-container');
await input.fill('export-interlink');
await expect(searchContainer).toBeVisible();
await expect(searchContainer.getByText(randomDoc)).toBeVisible();
// We are in docChild, we want to create a link to randomDoc (parent)
await searchContainer.getByText(randomDoc).click();
// Search the interlinking link in the editor (not in the document tree)
const interlink = editor
.locator('.--docs--interlinking-link-inline-content')
.first();
await expect(interlink).toContainText(randomDoc);
await page
.getByRole('button', {
name: 'Export the document',
})
.click();
await page.getByRole('combobox', { name: 'Format' }).click();
await page.getByRole('option', { name: 'Odt' }).click();
await expect(
page.getByTestId('doc-open-modal-download-button'),
).toBeVisible();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${docChild}.odt`);
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
});
void page.getByTestId('doc-export-download-button').click();
await page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${docChild}.odt`);
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
// If we need to update the PDF regression fixture, uncomment the line below
//await savePDFToAssetFolder(download);
// Assert the generated PDF matches "assets/doc-export-regressions.pdf"
await comparePDFWithAssetFolder(download);
});
});
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;
};

View File

@@ -0,0 +1,181 @@
import { readFileSync } from 'fs';
import path from 'path';
import { Page, expect, test } from '@playwright/test';
import { getEditor } from './utils-editor';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Import', () => {
test('it imports 2 docs with the import icon', async ({ page }) => {
const fileChooserPromise = page.waitForEvent('filechooser');
await page.getByLabel('Open the upload dialog').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles([
path.join(__dirname, 'assets/test_import.docx'),
path.join(__dirname, 'assets/test_import.md'),
]);
await expect(
page.getByText(
'The document "test_import.docx" has been successfully imported',
),
).toBeVisible();
await expect(
page.getByText(
'The document "test_import.md" has been successfully imported',
),
).toBeVisible();
const docsGrid = page.getByTestId('docs-grid');
await expect(docsGrid.getByText('test_import.docx').first()).toBeVisible();
await expect(docsGrid.getByText('test_import.md').first()).toBeVisible();
// Check content of imported md
await docsGrid.getByText('test_import.md').first().click();
const editor = await getEditor({ page });
const contentCheck = async (isMDCheck = false) => {
await expect(
editor.getByRole('heading', {
name: 'Lorem Ipsum import Document',
level: 1,
}),
).toBeVisible();
await expect(
editor.getByRole('heading', {
name: 'Introduction',
level: 2,
}),
).toBeVisible();
await expect(
editor.getByRole('heading', {
name: 'Subsection 1.1',
level: 3,
}),
).toBeVisible();
await expect(
editor
.locator('div[data-content-type="bulletListItem"] strong')
.getByText('Bold text'),
).toBeVisible();
await expect(
editor
.locator('div[data-content-type="codeBlock"]')
.getByText('hello_world'),
).toBeVisible();
await expect(
editor
.locator('div[data-content-type="table"] td')
.getByText('Paragraph'),
).toBeVisible();
await expect(
editor.locator('a[href="http://localhost:3000/"]').getByText('Example'),
).toBeVisible();
/* eslint-disable playwright/no-conditional-expect */
if (isMDCheck) {
await expect(
editor.locator(
'img[src="http://localhost:3000/assets/logo-suite-numerique.png"]',
),
).toBeVisible();
await expect(
editor.locator(
'img[src="http://localhost:3000/assets/icon-docs.svg"]',
),
).toBeVisible();
} else {
await expect(editor.locator('img')).toHaveCount(2);
}
/* eslint-enable playwright/no-conditional-expect */
/**
* Divider are not supported in docx import in DocSpec 2.4.4
*/
/* eslint-disable playwright/no-conditional-expect */
if (isMDCheck) {
await expect(
editor.locator('div[data-content-type="divider"] hr'),
).toBeVisible();
}
/* eslint-enable playwright/no-conditional-expect */
};
await contentCheck(true);
// Check content of imported docx
await page.getByLabel('Back to homepage').first().click();
await docsGrid.getByText('test_import.docx').first().click();
await contentCheck();
});
test('it imports 2 docs with the drag and drop area', async ({ page }) => {
const docsGrid = page.getByTestId('docs-grid');
await expect(docsGrid).toBeVisible();
await dragAndDropFiles(page, "[data-testid='docs-grid']", [
{
filePath: path.join(__dirname, 'assets/test_import.docx'),
fileName: 'test_import.docx',
fileType:
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
},
{
filePath: path.join(__dirname, 'assets/test_import.md'),
fileName: 'test_import.md',
fileType: 'text/markdown',
},
]);
// Wait for success messages
await expect(
page.getByText(
'The document "test_import.docx" has been successfully imported',
),
).toBeVisible();
await expect(
page.getByText(
'The document "test_import.md" has been successfully imported',
),
).toBeVisible();
await expect(docsGrid.getByText('test_import.docx').first()).toBeVisible();
await expect(docsGrid.getByText('test_import.md').first()).toBeVisible();
});
});
const dragAndDropFiles = async (
page: Page,
selector: string,
files: Array<{ filePath: string; fileName: string; fileType?: string }>,
) => {
const filesData = files.map((file) => ({
bufferData: `data:application/octet-stream;base64,${readFileSync(file.filePath).toString('base64')}`,
fileName: file.fileName,
fileType: file.fileType || '',
}));
const dataTransfer = await page.evaluateHandle(async (filesInfo) => {
const dt = new DataTransfer();
for (const fileInfo of filesInfo) {
const blobData = await fetch(fileInfo.bufferData).then((res) =>
res.blob(),
);
const file = new File([blobData], fileInfo.fileName, {
type: fileInfo.fileType,
});
dt.items.add(file);
}
return dt;
}, filesData);
await page.dispatchEvent(selector, 'drop', { dataTransfer });
};

View File

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

View File

@@ -4,7 +4,6 @@ import {
createDoc,
expectLoginPage,
keyCloakSignIn,
randomName,
updateDocTitle,
verifyDocName,
} from './utils-common';
@@ -20,50 +19,6 @@ test.describe('Doc Tree', () => {
await page.goto('/');
});
test('create new sub pages', async ({ page, browserName }) => {
const [titleParent] = await createDoc(
page,
'doc-tree-content',
browserName,
1,
);
await verifyDocName(page, titleParent);
const addButton = page.getByTestId('new-doc-button');
const docTree = page.getByTestId('doc-tree');
await expect(addButton).toBeVisible();
// Wait for and intercept the POST request to create a new page
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes('/documents/') &&
response.url().includes('/children/') &&
response.request().method() === 'POST',
);
await clickOnAddRootSubPage(page);
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
const subPageJson = await response.json();
await expect(docTree).toBeVisible();
const subPageItem = docTree
.getByTestId(`doc-sub-page-item-${subPageJson.id}`)
.first();
await expect(subPageItem).toBeVisible();
await subPageItem.click();
await verifyDocName(page, '');
const input = page.getByRole('textbox', { name: 'Document title' });
await input.click();
const [randomDocName] = randomName('doc-tree-test', browserName, 1);
await input.fill(randomDocName);
await input.press('Enter');
await expect(subPageItem.getByText(randomDocName)).toBeVisible();
await page.reload();
await expect(subPageItem.getByText(randomDocName)).toBeVisible();
});
test('check the reorder of sub pages', async ({ page, browserName }) => {
await createDoc(page, 'doc-tree-content', browserName, 1);
const addButton = page.getByTestId('new-doc-button');
@@ -72,47 +27,32 @@ test.describe('Doc Tree', () => {
const docTree = page.getByTestId('doc-tree');
// Create first sub page
const firstResponsePromise = page.waitForResponse(
(response) =>
response.url().includes('/documents/') &&
response.url().includes('/children/') &&
response.request().method() === 'POST',
);
await clickOnAddRootSubPage(page);
const firstResponse = await firstResponsePromise;
expect(firstResponse.ok()).toBeTruthy();
await updateDocTitle(page, 'first');
const secondResponsePromise = page.waitForResponse(
(response) =>
response.url().includes('/documents/') &&
response.url().includes('/children/') &&
response.request().method() === 'POST',
);
await updateDocTitle(page, 'first move');
// Create second sub page
await clickOnAddRootSubPage(page);
const secondResponse = await secondResponsePromise;
expect(secondResponse.ok()).toBeTruthy();
await updateDocTitle(page, 'second');
await updateDocTitle(page, 'second move');
const secondSubPageJson = await secondResponse.json();
const firstSubPageJson = await firstResponse.json();
const firstSubPageItem = docTree
.getByTestId(`doc-sub-page-item-${firstSubPageJson.id}`)
.first();
const secondSubPageItem = docTree
.getByTestId(`doc-sub-page-item-${secondSubPageJson.id}`)
.first();
const firstSubPageItem = docTree.getByText('first move').first();
const secondSubPageItem = docTree.getByText('second move').first();
// check that the sub pages are visible in the tree
await expect(firstSubPageItem).toBeVisible();
await expect(secondSubPageItem).toBeVisible();
// get the bounding boxes of the sub pages
// Check the position of the sub pages
const allSubPageItems = await docTree
.getByTestId(/^doc-sub-page-item/)
.all();
expect(allSubPageItems.length).toBe(2);
// Check that elements are in the correct order
await expect(allSubPageItems[0].getByText('first move')).toBeVisible();
await expect(allSubPageItems[1].getByText('second move')).toBeVisible();
// Will move the first sub page to the second position
const firstSubPageBoundingBox = await firstSubPageItem.boundingBox();
const secondSubPageBoundingBox = await secondSubPageItem.boundingBox();
@@ -120,10 +60,9 @@ test.describe('Doc Tree', () => {
expect(secondSubPageBoundingBox).toBeDefined();
if (!firstSubPageBoundingBox || !secondSubPageBoundingBox) {
throw new Error('Impossible de déterminer la position des éléments');
throw new Error('unable to determine the position of the elements');
}
// move the first sub page to the second position
await page.mouse.move(
firstSubPageBoundingBox.x + firstSubPageBoundingBox.width / 2,
firstSubPageBoundingBox.y + firstSubPageBoundingBox.height / 2,
@@ -150,24 +89,19 @@ test.describe('Doc Tree', () => {
await expect(firstSubPageItem).toBeVisible();
await expect(secondSubPageItem).toBeVisible();
// Check the position of the sub pages
const allSubPageItems = await docTree
// Check that elements are in the correct order
const allSubPageItemsAfterReload = await docTree
.getByTestId(/^doc-sub-page-item/)
.all();
expect(allSubPageItems.length).toBe(2);
expect(allSubPageItemsAfterReload.length).toBe(2);
// Check that the first element has the ID of the second sub page after the drag and drop
await expect(allSubPageItems[0]).toHaveAttribute(
'data-testid',
`doc-sub-page-item-${secondSubPageJson.id}`,
);
// Check that the second element has the ID of the first sub page after the drag and drop
await expect(allSubPageItems[1]).toHaveAttribute(
'data-testid',
`doc-sub-page-item-${firstSubPageJson.id}`,
);
await expect(
allSubPageItemsAfterReload[0].getByText('second move'),
).toBeVisible();
await expect(
allSubPageItemsAfterReload[1].getByText('first move'),
).toBeVisible();
});
test('it detaches a document', async ({ page, browserName }) => {

View File

@@ -42,8 +42,8 @@ test.describe('Doc Version', () => {
// Write more
await writeInEditor({ page, text: 'It will create a version' });
await openSuggestionMenu({ page });
await page.getByText('Add a callout block').click();
const { suggestionMenu } = await openSuggestionMenu({ page });
await suggestionMenu.getByText('Add a callout block').click();
const calloutBlock = page
.locator('div[data-content-type="callout"]')

View File

@@ -59,45 +59,90 @@ test.describe('Header', () => {
).toBeVisible();
await expect(header.getByText('English')).toBeVisible();
await expect(
header.getByRole('button', {
name: 'Les services de La Suite numérique',
}),
).toBeVisible();
});
test('checks La Gauffre interaction', async ({ page }) => {
test('checks a custom waffle', async ({ page }) => {
await overrideConfig(page, {
FRONTEND_THEME: 'dsfr',
theme_customization: {
waffle: {
data: {
services: [
{
name: 'Docs E2E Custom 1',
url: 'https://docs.numerique.gouv.fr/',
maturity: 'stable',
logo: 'https://lasuite.numerique.gouv.fr/assets/products/docs.svg',
},
{
name: 'Docs E2E Custom 2',
url: 'https://docs.numerique.gouv.fr/',
maturity: 'stable',
logo: 'https://lasuite.numerique.gouv.fr/assets/products/docs.svg',
},
],
},
showMoreLimit: 9,
},
},
});
await page.goto('/');
const header = page.locator('header').first();
await expect(
header.getByRole('button', {
name: 'Les services de La Suite numérique',
}),
header.getByRole('button', { name: 'Digital LaSuite services' }),
).toBeVisible();
/**
* La gaufre load a js file from a remote server,
* The Waffle loads a js file from a remote server,
* it takes some time to load the file and have the interaction available
*/
await page.waitForTimeout(1500);
await header
.getByRole('button', { name: 'Digital LaSuite services' })
.click();
await expect(
page.getByRole('link', { name: 'Docs E2E Custom 1' }),
).toBeVisible();
await expect(
page.getByRole('link', { name: 'Docs E2E Custom 2' }),
).toBeVisible();
});
test('checks the waffle dsfr', async ({ page }) => {
await overrideConfig(page, {
theme_customization: {
waffle: {
apiUrl: 'https://lasuite.numerique.gouv.fr/api/services',
showMoreLimit: 9,
},
},
});
await page.goto('/');
const header = page.locator('header').first();
await expect(
header.getByRole('button', { name: 'Digital LaSuite services' }),
).toBeVisible();
/**
* The Waffle loads a js file from a remote server,
* it takes some time to load the file and have the interaction available
*/
await page.waitForTimeout(1500);
await header
.getByRole('button', {
name: 'Les services de La Suite numérique',
name: 'Digital LaSuite services',
})
.click();
await expect(
page.getByRole('link', { name: 'France Transfert' }),
).toBeVisible();
await expect(page.getByRole('link', { name: 'Tchap' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Grist' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Visio' })).toBeVisible();
});
});
@@ -124,11 +169,6 @@ test.describe('Header mobile', () => {
await expect(header.getByLabel('Open the header menu')).toBeVisible();
await expect(header.getByTestId('header-icon-docs')).toBeVisible();
await expect(
header.getByRole('button', {
name: 'Les services de La Suite numérique',
}),
).toBeVisible();
});
});

View File

@@ -113,9 +113,6 @@ test.describe('Home page', () => {
});
await expect(languageButton).toBeVisible();
await expect(
header.getByRole('button', { name: 'Les services de La Suite numé' }),
).toBeVisible();
await expect(
header.getByRole('img', { name: 'Gouvernement Logo' }),
).toBeVisible();

View File

@@ -1,6 +1,11 @@
import { expect, test } from '@playwright/test';
import { TestLanguage, createDoc, waitForLanguageSwitch } from './utils-common';
import {
TestLanguage,
createDoc,
overrideConfig,
waitForLanguageSwitch,
} from './utils-common';
import { openSuggestionMenu } from './utils-editor';
test.describe('Language', () => {
@@ -107,10 +112,21 @@ test.describe('Language', () => {
page,
browserName,
}) => {
await createDoc(page, 'doc-toolbar', browserName, 1);
await overrideConfig(page, {
LANGUAGES: [
['en-us', 'English'],
['fr-fr', 'Français'],
['sv-se', 'Svenska'],
],
LANGUAGE_CODE: 'en-us',
});
const editor = await openSuggestionMenu({ page });
await expect(page.getByText('Headings', { exact: true })).toBeVisible();
await createDoc(page, 'doc-translations-slash', browserName, 1);
const { editor, suggestionMenu } = await openSuggestionMenu({ page });
await expect(
suggestionMenu.getByText('Headings', { exact: true }),
).toBeVisible();
await editor.click(); // close the menu
@@ -121,6 +137,17 @@ test.describe('Language', () => {
// Trigger slash menu to show french menu
await openSuggestionMenu({ page });
await expect(page.getByText('Titres', { exact: true })).toBeVisible();
await expect(
suggestionMenu.getByText('Titres', { exact: true }),
).toBeVisible();
/**
* Swedish is not yet supported in the BlockNote locales, so it should fallback to English
*/
await waitForLanguageSwitch(page, TestLanguage.Swedish);
await openSuggestionMenu({ page });
await expect(
suggestionMenu.getByText('Headings', { exact: true }),
).toBeVisible();
});
});

View File

@@ -4,10 +4,18 @@ 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_MODEL: 'llama',
AI_STREAM: false,
CRISP_WEBSITE_ID: null,
COLLABORATION_WS_URL: 'ws://localhost:4444/collaboration/ws/',
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY: true,
CONVERSION_FILE_EXTENSIONS_ALLOWED: ['.docx', '.md'],
CONVERSION_FILE_MAX_SIZE: 20971520,
ENVIRONMENT: 'development',
FRONTEND_CSS_URL: null,
FRONTEND_JS_URL: null,
@@ -160,7 +168,7 @@ export const verifyDocName = async (page: Page, docName: string) => {
await expect(
page.getByRole('textbox', { name: 'Document title' }),
).toContainText(docName, {
timeout: 1000,
timeout: 3000,
});
} catch {
await expect(page.getByRole('heading', { name: docName })).toBeVisible();
@@ -224,7 +232,9 @@ export const updateDocTitle = async (page: Page, title: string) => {
await expect(input).toHaveText('');
await expect(input).toBeVisible();
await input.click();
await input.fill(title);
await input.fill(title, {
force: true,
});
await input.click();
await input.blur();
await verifyDocName(page, title);
@@ -328,6 +338,10 @@ export const TestLanguage = {
label: 'Deutsch',
expectedLocale: ['de-de'],
},
Swedish: {
label: 'Svenska',
expectedLocale: ['sv-se'],
},
} as const;
type TestLanguageKey = keyof typeof TestLanguage;

View File

@@ -7,11 +7,11 @@ export const getEditor = async ({ page }: { page: Page }) => {
};
export const openSuggestionMenu = async ({ page }: { page: Page }) => {
const editor = await getEditor({ page });
await editor.click();
await writeInEditor({ page, text: '/' });
const editor = await writeInEditor({ page, text: '/' });
return editor;
const suggestionMenu = page.locator('.bn-suggestion-menu');
return { editor, suggestionMenu };
};
export const writeInEditor = async ({
@@ -22,6 +22,11 @@ export const writeInEditor = async ({
text: string;
}) => {
const editor = await getEditor({ page });
await editor.locator('.bn-block-outer .bn-inline-content').last().fill(text);
await editor
.locator('.bn-block-outer:last-child')
.last()
.locator('.bn-inline-content:last-child')
.last()
.fill(text);
return editor;
};

View File

@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "4.2.0",
"version": "4.4.0",
"repository": "https://github.com/suitenumerique/docs",
"author": "DINUM",
"license": "MIT",

View File

@@ -1,58 +1,53 @@
import { cunninghamConfig as tokens } from '@gouvfr-lasuite/ui-kit';
import { defaultTokens } from '@openfun/cunningham-react';
import merge from 'lodash/merge';
import {
dsfrGlobals,
getUIKitThemesFromGlobals,
whiteLabelGlobals,
} from '@gouvfr-lasuite/ui-kit';
// Uikit does not provide the full list of tokens.
// To be able to override correctly, we need to merge with the default tokens.
let mergedColors = merge(
defaultTokens.globals.colors,
tokens.themes.default.globals.colors,
);
mergedColors = {
...mergedColors,
'logo-1': '#2845C1',
};
tokens.themes.default.globals = {
...tokens.themes.default.globals,
...{
colors: mergedColors,
font: {
...tokens.themes.default.globals.font,
families: {
base: 'sans-serif',
accent: 'sans-serif',
const themeWhiteLabelLight = getUIKitThemesFromGlobals(whiteLabelGlobals, {
prefix: 'default',
variants: ['light'],
overrides: {
globals: {
spacing: {
'0': '0rem',
none: '0rem',
auto: 'auto',
bx: '2.2rem',
full: '100%',
'3xs': '0.25rem',
'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',
},
},
},
});
const themeDefault = {
default: themeWhiteLabelLight['default-light'],
};
tokens.themes.default.components = {
...tokens.themes.default.components,
...{
logo: {
src: '',
alt: '',
widthHeader: '',
widthFooter: '',
},
'la-gaufre': false,
'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',
},
},
};
const dsfrTheme = {
dsfr: {
const themesDSFRLight = getUIKitThemesFromGlobals(dsfrGlobals, {
prefix: 'dsfr',
variants: ['light'],
overrides: {
globals: {
font: {
families: {
@@ -68,7 +63,6 @@ const dsfrTheme = {
widthFooter: '220px',
alt: 'Gouvernement Logo',
},
'la-gaufre': true,
'home-proconnect': true,
icon: {
src: '/assets/icon-docs-dsfr.svg',
@@ -82,317 +76,16 @@ const dsfrTheme = {
},
},
},
};
});
const genericTheme = {
generic: {
globals: {
colors: {
'brand-050': '#EEF1FA',
'brand-100': '#DDE2F5',
'brand-150': '#CED3F1',
'brand-200': '#BEC5F0',
'brand-250': '#AFB5F1',
'brand-300': '#A0A5F6',
'brand-350': '#8F94FD',
'brand-400': '#8184FC',
'brand-450': '#7576EE',
'brand-500': '#6969DF',
'brand-550': '#5E5CD0',
'brand-600': '#534FC2',
'brand-650': '#4844AD',
'brand-700': '#3E3B98',
'brand-750': '#36347D',
'brand-800': '#2D2F5F',
'brand-850': '#262848',
'brand-900': '#1C1E32',
'brand-950': '#11131F',
'gray-000': '#FFFFFF',
'gray-025': '#F8F8F9',
'gray-050': '#F0F0F3',
'gray-100': '#E2E2EA',
'gray-150': '#D3D4E0',
'gray-200': '#C5C6D5',
'gray-250': '#B7B7CB',
'gray-300': '#A9A9BF',
'gray-350': '#9C9CB2',
'gray-400': '#8F8FA4',
'gray-450': '#828297',
'gray-500': '#75758A',
'gray-550': '#69697D',
'gray-600': '#5D5D70',
'gray-650': '#515164',
'gray-700': '#454558',
'gray-750': '#3A3A4C',
'gray-800': '#2F303D',
'gray-850': '#25252F',
'gray-900': '#1B1B23',
'gray-950': '#111114',
'gray-1000': '#000000',
'info-050': '#EAF2F9',
'info-100': '#D5E4F3',
'info-150': '#BFD7F0',
'info-200': '#A7CAEE',
'info-250': '#8DBDEF',
'info-300': '#6EB0F2',
'info-350': '#50A2F5',
'info-400': '#3593F4',
'info-450': '#1185ED',
'info-500': '#0077DE',
'info-550': '#0069CF',
'info-600': '#005BC0',
'info-650': '#0D4EAA',
'info-700': '#124394',
'info-750': '#163878',
'info-800': '#192F5A',
'info-850': '#192541',
'info-900': '#141B2D',
'info-950': '#0C111C',
'success-050': '#E8F1EA',
'success-100': '#CFE4D4',
'success-150': '#BAD9C1',
'success-200': '#A2CFAD',
'success-250': '#86C597',
'success-300': '#6CBA83',
'success-350': '#4FB070',
'success-400': '#40A363',
'success-450': '#309556',
'success-500': '#1E884A',
'success-550': '#027B3E',
'success-600': '#016D31',
'success-650': '#006024',
'success-700': '#005317',
'success-750': '#0D4511',
'success-800': '#11380E',
'success-850': '#132A11',
'success-900': '#101E0F',
'success-950': '#091209',
'warning-050': '#F8F0E9',
'warning-100': '#F1E0D3',
'warning-150': '#ECD0BC',
'warning-200': '#E8C0A4',
'warning-250': '#E8AE8A',
'warning-300': '#EB9970',
'warning-350': '#E98456',
'warning-400': '#E57036',
'warning-450': '#DA5E18',
'warning-500': '#CB5000',
'warning-550': '#BC4200',
'warning-600': '#AD3300',
'warning-650': '#9E2300',
'warning-700': '#882011',
'warning-750': '#731E16',
'warning-800': '#58201A',
'warning-850': '#401D18',
'warning-900': '#2E1714',
'warning-950': '#1D0F0D',
'error-050': '#F9EFEC',
'error-100': '#F4DFD9',
'error-150': '#F0CEC6',
'error-200': '#EEBCB2',
'error-250': '#EEA99D',
'error-300': '#EF9486',
'error-350': '#F37C6E',
'error-400': '#F65F53',
'error-450': '#F0463D',
'error-500': '#E82322',
'error-550': '#D7010E',
'error-600': '#C00100',
'error-650': '#AA0000',
'error-700': '#910C06',
'error-750': '#731E16',
'error-800': '#58201A',
'error-850': '#401D18',
'error-900': '#2E1714',
'error-950': '#1D0F0D',
'red-050': '#FAEFEE',
'red-100': '#F4DEDD',
'red-150': '#F1CDCB',
'red-200': '#EFBBBA',
'red-250': '#EEA8A8',
'red-300': '#F09394',
'red-350': '#F37B7E',
'red-400': '#EF6569',
'red-450': '#E94A55',
'red-500': '#DA3B49',
'red-550': '#CA2A3C',
'red-600': '#BB1330',
'red-650': '#A90021',
'red-700': '#910A13',
'red-750': '#731E16',
'red-800': '#58201A',
'red-850': '#411D18',
'red-900': '#2E1714',
'red-950': '#1D0F0D',
'orange-050': '#F8F0E9',
'orange-100': '#F1E0D3',
'orange-150': '#ECD0BD',
'orange-200': '#EABFA6',
'orange-250': '#EBAC90',
'orange-300': '#EC9772',
'orange-350': '#E5845A',
'orange-400': '#D6774D',
'orange-450': '#C86A40',
'orange-500': '#B95D33',
'orange-550': '#AB5025',
'orange-600': '#9D4315',
'orange-650': '#8F3600',
'orange-700': '#812900',
'orange-750': '#6C2511',
'orange-800': '#572017',
'orange-850': '#401D18',
'orange-900': '#2E1714',
'orange-950': '#1D0F0D',
'brown-050': '#F6F0E8',
'brown-100': '#F1E0D3',
'brown-150': '#EBD0BA',
'brown-200': '#E2C0A6',
'brown-250': '#D4B398',
'brown-300': '#C6A58B',
'brown-350': '#B8987E',
'brown-400': '#AA8B71',
'brown-450': '#9D7E65',
'brown-500': '#8F7158',
'brown-550': '#82654C',
'brown-600': '#765841',
'brown-650': '#694C35',
'brown-700': '#5D412A',
'brown-750': '#51361E',
'brown-800': '#452A13',
'brown-850': '#392008',
'brown-900': '#29180A',
'brown-950': '#1B0F08',
'yellow-050': '#F3F0E7',
'yellow-100': '#E9E2CF',
'yellow-150': '#E1D4B7',
'yellow-200': '#D9C599',
'yellow-250': '#D2B677',
'yellow-300': '#CAA756',
'yellow-350': '#C2972E',
'yellow-400': '#B98900',
'yellow-450': '#AB7B00',
'yellow-500': '#9D6E00',
'yellow-550': '#916100',
'yellow-600': '#855400',
'yellow-650': '#784700',
'yellow-700': '#6C3A00',
'yellow-750': '#5F2E00',
'yellow-800': '#512302',
'yellow-850': '#3E1D10',
'yellow-900': '#2D1711',
'yellow-950': '#1D0F0D',
'green-050': '#E6F1E9',
'green-100': '#CFE4D5',
'green-150': '#B8D8C1',
'green-200': '#A0CFAE',
'green-250': '#84C59A',
'green-300': '#65BA86',
'green-350': '#45B173',
'green-400': '#23A562',
'green-450': '#029755',
'green-500': '#008948',
'green-550': '#017B3B',
'green-600': '#006E2E',
'green-650': '#006022',
'green-700': '#005314',
'green-750': '#0D4510',
'green-800': '#11380E',
'green-850': '#132A11',
'green-900': '#101E0F',
'green-950': '#091209',
'blue1-050': '#EBF1F9',
'blue1-100': '#D6E4F4',
'blue1-150': '#C1D7F0',
'blue1-200': '#AACAEF',
'blue1-250': '#8FBCEF',
'blue1-300': '#7CAFEB',
'blue1-350': '#68A1E4',
'blue1-400': '#5B94D6',
'blue1-450': '#4E86C7',
'blue1-500': '#4279B9',
'blue1-550': '#356CAC',
'blue1-600': '#28609E',
'blue1-650': '#1B5390',
'blue1-700': '#0B4783',
'blue1-750': '#0F3C6E',
'blue1-800': '#133059',
'blue1-850': '#152641',
'blue1-900': '#121C2D',
'blue1-950': '#0B111C',
'blue2-050': '#E7F3F4',
'blue2-100': '#CEE7E9',
'blue2-150': '#B2DCE0',
'blue2-200': '#91D1D7',
'blue2-250': '#68C7D0',
'blue2-300': '#43BBC5',
'blue2-350': '#00AFBA',
'blue2-400': '#01A0AA',
'blue2-450': '#00929D',
'blue2-500': '#00848F',
'blue2-550': '#007682',
'blue2-600': '#016874',
'blue2-650': '#005B67',
'blue2-700': '#004E5A',
'blue2-750': '#00424E',
'blue2-800': '#003642',
'blue2-850': '#002A38',
'blue2-900': '#061E28',
'blue2-950': '#071219',
'purple-050': '#F7F0F6',
'purple-100': '#EEE0EE',
'purple-150': '#E7D1E7',
'purple-200': '#DBBFE4',
'purple-250': '#D3AEE2',
'purple-300': '#CB99E1',
'purple-350': '#C188D9',
'purple-400': '#B47BCB',
'purple-450': '#A66EBD',
'purple-500': '#9961AF',
'purple-550': '#8B55A1',
'purple-600': '#7E4894',
'purple-650': '#723C87',
'purple-700': '#633376',
'purple-750': '#552A65',
'purple-800': '#452551',
'purple-850': '#35213D',
'purple-900': '#261A2C',
'purple-950': '#17111C',
'pink-050': '#F8EFF4',
'pink-100': '#F0DFEA',
'pink-150': '#EACEDF',
'pink-200': '#E9BBD1',
'pink-250': '#E9A7C2',
'pink-300': '#E095B4',
'pink-350': '#D685A8',
'pink-400': '#C7799B',
'pink-450': '#B86C8D',
'pink-500': '#AA5F80',
'pink-550': '#9C5374',
'pink-600': '#8E4767',
'pink-650': '#813B5B',
'pink-700': '#732E4F',
'pink-750': '#632643',
'pink-800': '#521F38',
'pink-850': '#3E1C2B',
'pink-900': '#2D171F',
'pink-950': '#1C0E12',
},
font: {
families: {
base: 'Inter, Roboto Flex Variable, sans-serif',
accent: 'Inter, Roboto Flex Variable, sans-serif',
},
},
},
},
const themesDSFR = {
dsfr: themesDSFRLight['dsfr-light'],
};
const docsTokens = {
...tokens,
themes: {
...tokens.themes,
...dsfrTheme,
...genericTheme,
...themeDefault,
...themesDSFR,
},
};

View File

@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "4.2.0",
"version": "4.4.0",
"repository": "https://github.com/suitenumerique/docs",
"author": "DINUM",
"license": "MIT",
@@ -19,14 +19,18 @@
},
"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/groq": "^3.0.15",
"@ai-sdk/openai": "^3.0.19",
"@ai-sdk/openai-compatible": "2.0.18",
"@blocknote/code-block": "0.46.1",
"@blocknote/core": "0.46.1",
"@blocknote/mantine": "0.46.1",
"@blocknote/react": "0.46.1",
"@blocknote/xl-ai": "0.46.1",
"@blocknote/xl-docx-exporter": "0.46.1",
"@blocknote/xl-multi-column": "0.46.1",
"@blocknote/xl-odt-exporter": "0.46.1",
"@blocknote/xl-pdf-exporter": "0.46.1",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/modifiers": "9.0.0",
"@emoji-mart/data": "1.2.1",
@@ -34,16 +38,17 @@
"@fontsource-variable/inter": "5.2.8",
"@fontsource-variable/material-symbols-outlined": "5.2.30",
"@fontsource/material-icons": "5.2.7",
"@gouvfr-lasuite/cunningham-react": "4.1.0",
"@gouvfr-lasuite/integration": "1.0.3",
"@gouvfr-lasuite/ui-kit": "0.18.4",
"@gouvfr-lasuite/ui-kit": "0.18.6",
"@hocuspocus/provider": "3.4.3",
"@mantine/core": "8.3.10",
"@mantine/hooks": "8.3.10",
"@openfun/cunningham-react": "4.0.0",
"@react-pdf/renderer": "4.3.1",
"@sentry/nextjs": "10.30.0",
"@tanstack/react-query": "5.90.12",
"@sentry/nextjs": "10.32.1",
"@tanstack/react-query": "5.90.16",
"@tiptap/extensions": "*",
"ai": "6.0.49",
"canvg": "4.0.3",
"clsx": "2.1.1",
"cmdk": "1.1.1",
@@ -52,32 +57,35 @@
"emoji-datasource-apple": "16.0.0",
"emoji-mart": "5.6.0",
"emoji-regex": "10.6.0",
"i18next": "25.7.2",
"i18next": "25.7.3",
"i18next-browser-languagedetector": "8.2.0",
"idb": "8.0.3",
"lodash": "4.17.21",
"lodash": "4.17.23",
"luxon": "3.7.2",
"next": "15.5.9",
"posthog-js": "1.306.1",
"posthog-js": "1.312.0",
"react": "*",
"react-aria-components": "1.13.0",
"react-aria-components": "1.14.0",
"react-dom": "*",
"react-i18next": "16.5.0",
"react-dropzone": "14.3.8",
"react-i18next": "16.5.1",
"react-intersection-observer": "10.0.0",
"react-resizable-panels": "3.0.6",
"react-select": "5.10.2",
"styled-components": "6.1.19",
"use-debounce": "10.0.6",
"y-protocols": "1.0.6",
"uuid": "13.0.0",
"y-protocols": "1.0.7",
"yjs": "*",
"zod": "3.25.28",
"zustand": "5.0.9"
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.91.1",
"@tanstack/react-query-devtools": "5.91.2",
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.0",
"@testing-library/react": "16.3.1",
"@testing-library/user-event": "14.6.1",
"@types/lodash": "4.17.21",
"@types/luxon": "3.7.1",
@@ -90,16 +98,16 @@
"dotenv": "17.2.3",
"eslint-plugin-docs": "*",
"fetch-mock": "9.11.0",
"jsdom": "27.3.0",
"jsdom": "27.4.0",
"node-fetch": "2.7.0",
"prettier": "3.7.4",
"stylelint": "16.26.1",
"stylelint-config-standard": "39.0.1",
"stylelint-prettier": "5.0.3",
"typescript": "*",
"vite-tsconfig-paths": "6.0.1",
"vitest": "4.0.15",
"webpack": "5.103.0",
"vite-tsconfig-paths": "6.0.3",
"vitest": "4.0.16",
"webpack": "5.104.1",
"workbox-webpack-plugin": "7.1.0"
},
"packageManager": "yarn@1.22.22"

View File

@@ -20,7 +20,7 @@ export type DefinedInitialDataInfiniteOptionsAPI<
QueryKey,
TPageParam
>;
export type UseInfiniteQueryResultAPI<Q> = InfiniteData<Q>;
export type InfiniteQueryConfig<Q> = Omit<
DefinedInitialDataInfiniteOptionsAPI<Q>,
'queryKey' | 'initialData' | 'getNextPageParam' | 'initialPageParam'

View File

@@ -0,0 +1,20 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M6.12757 9.8486C5.98657 9.6993 5.91709 9.5143 5.91709 9.30858C5.91709 9.10284 5.98679 8.91775 6.13233 8.77221C6.28262 8.62192 6.47291 8.54842 6.68579 8.54842H13.1697C13.3775 8.54842 13.5623 8.62245 13.7061 8.77215C13.8559 8.91601 13.9299 9.10081 13.9299 9.30858C13.9299 9.51737 13.8553 9.70306 13.7085 9.8511C13.5643 10.0024 13.3787 10.0773 13.1697 10.0773H6.68579C6.47291 10.0773 6.28262 10.0038 6.13233 9.85349L6.13076 9.85192L6.12757 9.8486Z"
fill="currentColor"
/>
<path
d="M6.12757 12.83C5.98657 12.6807 5.91709 12.4957 5.91709 12.29C5.91709 12.0843 5.98679 11.8992 6.13233 11.7536C6.28262 11.6033 6.47291 11.5298 6.68579 11.5298H13.1697C13.3775 11.5298 13.5623 11.6039 13.7061 11.7536C13.8559 11.8974 13.9299 12.0822 13.9299 12.29C13.9299 12.4988 13.8553 12.6845 13.7085 12.8325C13.5643 12.9838 13.3787 13.0587 13.1697 13.0587H6.68579C6.47291 13.0587 6.28262 12.9852 6.13233 12.8349L6.13076 12.8333L6.12757 12.83Z"
fill="currentColor"
/>
<path
d="M5.91709 15.2885C5.91709 15.4912 5.98839 15.6726 6.12757 15.82L6.134 15.8266L6.13723 15.8296C6.28833 15.9723 6.47704 16.0401 6.68579 16.0401H9.75263C9.96123 16.0401 10.1502 15.9722 10.2975 15.8249C10.444 15.6784 10.5213 15.4956 10.5213 15.2885C10.5213 15.0768 10.4486 14.8874 10.2999 14.7374C10.1539 14.5842 9.96433 14.5113 9.75263 14.5113H6.68579C6.47293 14.5113 6.28257 14.5847 6.13226 14.735L6.12757 14.7399C5.98486 14.891 5.91709 15.0797 5.91709 15.2885Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M7.37975 1.24597C7.88425 0.735004 8.61944 0.5 9.54031 0.5H18.6127C19.533 0.5 20.2661 0.734736 20.7653 1.24652C21.2686 1.75666 21.5 2.49628 21.5 3.42147V16.3808C21.5 17.3112 21.2688 18.0521 20.7638 18.5572C20.2645 19.0624 19.532 19.2937 18.6127 19.2937H17.347V20.5338C17.347 21.4641 17.1158 22.2051 16.6108 22.7102C16.1115 23.2153 15.3789 23.4467 14.4597 23.4467H5.3873C4.46721 23.4467 3.73242 23.2149 3.22781 22.7103C2.72908 22.2051 2.5 21.4635 2.5 20.5338V7.57442C2.5 6.64962 2.72942 5.90915 3.22673 5.39893C3.73123 4.88796 4.46643 4.65295 5.3873 4.65295H6.65302V3.42147C6.65302 2.49666 6.88244 1.7562 7.37975 1.24597ZM8.42319 4.65295H14.4597C15.38 4.65295 16.1131 4.88769 16.6122 5.39947C17.1156 5.90962 17.347 6.64923 17.347 7.57442V17.5236H18.5444C18.9636 17.5236 19.2496 17.4163 19.4324 17.2289L19.4337 17.2275C19.6238 17.0374 19.7298 16.7549 19.7298 16.3552V3.4471C19.7298 3.04734 19.6238 2.76485 19.4337 2.57481L19.431 2.57206C19.248 2.37972 18.9625 2.27017 18.5444 2.27017H9.60866C9.19081 2.27017 8.90126 2.37956 8.71212 2.57341C8.52701 2.76329 8.42319 3.04633 8.42319 3.4471V4.65295ZM5.45564 21.6765C5.03728 21.6765 4.74743 21.5697 4.55844 21.3811C4.37372 21.1913 4.27017 20.9084 4.27017 20.5081V7.60005C4.27017 7.19928 4.37399 6.91625 4.55911 6.72636C4.74825 6.53252 5.03779 6.42313 5.45564 6.42313H14.3913C14.8095 6.42313 15.095 6.53268 15.278 6.72501L15.2807 6.72776C15.4708 6.9178 15.5768 7.20029 15.5768 7.60005V20.5081C15.5768 20.9079 15.4708 21.1904 15.2807 21.3804L15.2793 21.3818C15.0966 21.5693 14.8105 21.6765 14.3913 21.6765H5.45564Z"
fill="currentColor"
/>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -5,6 +5,8 @@ import { useTranslation } from 'react-i18next';
import { Box } from '@/components';
export const PICKER_HEIGHT = 500;
interface EmojiPickerProps {
emojiData: EmojiMartData;
onClickOutside: () => void;
@@ -20,11 +22,18 @@ export const EmojiPicker = ({
}: EmojiPickerProps) => {
const { i18n } = useTranslation();
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Escape') {
onClickOutside();
}
};
const pickerContent = (
<Box $position="absolute" $zIndex={1000} $margin="2rem 0 0 0">
<Box $position="absolute" $zIndex={1000} onKeyDownCapture={handleKeyDown}>
<Picker
data={emojiData}
locale={i18n.resolvedLanguage}
autoFocus
onClickOutside={onClickOutside}
onEmojiSelect={onEmojiSelect}
previewPosition="none"

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