Compare commits

..

72 Commits

Author SHA1 Message Date
Fabre Florian
562a0a4285 🔧(backend) setup Docs app dockers to work with Find
Add nginx with 'nginx' alias to the 'lasuite-net' network (keycloak calls)
Add celery-dev to the 'lasuite-net' network (Find API calls in jobs)
Set app-dev alias as 'impress' in the 'lasuite-net' network
Add indexer configuration in common settings

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-10-07 07:14:51 +02:00
Fabre Florian
8a483a7da0 🔧(backend) force a valid key for token storage in development mode
Generate a fernet key for the OIDC_STORE_REFRESH_TOKEN_KEY in development
settings if not set.

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-10-02 16:21:27 +02:00
Fabre Florian
cc47ff2b46 (backend) Index deleted documents
Add SEARCH_INDEXER_COUNTDOWN as configurable setting.
Make the search backend creation simplier (only 'get_document_indexer' now).
Allow indexation of deleted documents.

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-10-02 16:21:27 +02:00
Fabre Florian
33b4e2e446 (backend) Index partially empty documents
Only documents without title and content are ignored by indexer.
2025-10-02 16:21:26 +02:00
Fabre Florian
4185ad2419 (backend) add fallback search & default ordering
Filter deleted documents from visited ones.
Set default ordering to the Find API search call (-updated_at)
BaseDocumentIndexer.search now returns a list of document ids instead of models.
Do not call the indexer in signals when SEARCH_INDEXER_CLASS is not defined
or properly configured.

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-10-02 16:21:26 +02:00
Fabre Florian
b5a7af99f8 (backend) refactor indexation signals and fix circular import issues
Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-10-02 16:21:25 +02:00
Fabre Florian
f830fc6490 (backend) improve search indexer service configuration
New SEARCH_INDEXER_CLASS setting to define the indexer service class.
Raise ImpoperlyConfigured errors instead of RuntimeError in index service.

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-10-02 16:18:08 +02:00
Fabre Florian
1ff0ddacec (backend) add document search view
New API view that calls the indexed documents search view
(resource server) of app "Find".

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-10-02 16:18:07 +02:00
Fabre Florian
8e73c88b68 (backend) add unit test for the 'index' command
Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-10-02 16:18:07 +02:00
Fabre Florian
d954986bce 🔧(compose) Add some ignore for docker-compose local overrides
Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-10-02 16:18:06 +02:00
Samuel Paccoud - DINUM
bffb101d5b (backend) add async triggers to enable document indexation with find
On document content or permission changes, start a celery job that will call the
indexation API of the app "Find".

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-10-02 16:18:05 +02:00
Samuel Paccoud - DINUM
b702d8dd22 (backend) add document search indexer
Add indexer that loops across documents in the database, formats them
as json objects and indexes them in the remote "Find" mico-service.
2025-10-02 16:18:04 +02:00
Samuel Paccoud - DINUM
d29741b20e (backend) add dummy content to demo documents
We need to content in our demo documents so that we can test
indexing.
2025-10-02 16:18:04 +02:00
Samuel Paccoud - DINUM
76c218a220 🔧(compose) configure external network for communication with search
Search in Docs relies on an external project like "La Suite Find".
We need to declare a common external network in order to connect to
the search app and index our documents.
2025-10-02 16:18:02 +02:00
Cyril
18f4ab880f (frontend) update labels and shared document icon accessibility
remove aria-labels from decorative icons and add sr-only text to shared doc icon

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-10-02 13:24:47 +02:00
Cyril
e71c45077d (frontend) checked checkboxes: removing strikethrough
removing strikethrough

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-10-01 11:41:18 +02:00
Cyril
14c84f000e (frontend) add h1 for SR on 40X pages and remove alt texts
improves screen reader support on error pages by clarifying structure

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-30 08:57:30 +02:00
Cyril
6cc42636e5 (frontend) convert to figure/figcaption structure if caption exists
ensure  html structure by using figure/figcaption when captions are present

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-29 10:42:56 +02:00
Anthony LC
cc4bed6f8e ♻️(frontend) add upload loader block
The way we were handling the antivirus upload loader
was not optimal, it didn't work well with the pdf
embed block. We created a dedicated upload loader
block, it will replace the previous implementation,
it is more Blocknote idiomatic and will work
better with any type of upload files.
2025-09-26 17:15:22 +02:00
dakshesh14
d8f90c04bd (frontend) add pdf blocks to the editor
Added pdf block in the editor.

Signed-off-by: dakshesh14 <65905942+dakshesh14@users.noreply.github.com>
2025-09-26 17:15:22 +02:00
Cyril
1fdf70bdcf (frontend) remove redundant aria-label on hidden icons and update tests
remove aria-label from aria-hidden icons and update tests with data-testid

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-24 13:19:08 +02:00
Cyril
8ab21ef00d (frontend) improve semantic structure and aria roles of leftpanel
use nav and appropriate aria attributes to enhance accessibility

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-24 12:19:47 +02:00
Cyril
f337a2a8f2 (frontend) add default background to left panel for better a11y
defined a default background color to prevent issues with user stylesheets

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-24 11:40:49 +02:00
Cyril
3607faa475 (frontend) remove redundant aria-label to avoid over-accessibility
aria-label was removed because the visible span already provides the text

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-23 11:26:28 +02:00
Manuel Raynaud
0ea7dd727f 🔒️(frontend) update alpine packages in production image (#1425)
Force an update of installed package in the image used for the frontend
in production.
2025-09-23 09:21:01 +00:00
Anthony LC
6aca40a034 ⬆️(dependencies) Bump vite from 7.1.0 to 7.1.5
Bumps vite from 7.1.0 to 7.1.5.
- [Release notes](https://github.com/vitejs/vite/releases)
- https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md
- https://github.com/vitejs/vite/commits/v7.1.5/packages/vite

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.1.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-22 15:56:23 +02:00
Cyril
ee3b05cb55 (frontend) improve NVDA navigation in DocShareModal
fix NVDA focus and announcement issues in search modal combobox

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-22 14:53:34 +02:00
Anthony LC
c23ff546d8 🐛(frontend) scroll back to top when navigate to a document
When navigating to a new document, the scroll
position was preserved. This commit changes this
behavior to scroll back to the top of
the page when navigating to a new document.
2025-09-22 10:52:34 +02:00
Anthony LC
a751f1255a ♻️(frontend) replace Arial font-family with token font
In some components, the Arial font was still used
because of a centering problem.
We removed all instances of Arial and replaced them
with the current font token, the centering problems
were fixed by adding "contain: content;" to the css.
2025-09-22 10:09:15 +02:00
Anthony LC
8ee50631f3 🍱(frontend) replace Marianne font
Some improvements has been made to the Marianne
fonts. We replace the previous one with the
newer version.
2025-09-22 10:09:14 +02:00
Cyril
e5e5fba0b3 (frontend) hide decorative icons from assistive tech with aria-hidden
improves accessibility by reducing screen reader noise from icon elements

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-22 08:31:51 +02:00
Cyril
0894bcdca5 (docs) add title metadata to exported docx/pdf for accessibility
ensures document title is preserved in exports to meet accessibility needs

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-18 14:55:02 +02:00
Anthony LC
75da342058 🏷️(frontend) adapt types to link-configuration endpoint
The link-configuration endpoint has now a strict
validation schema about the combination of
link_reach and link_role.
We need to adapt our types
frontend side to reflect that.
2025-09-18 13:16:37 +02:00
Anthony LC
1ed01fd64b 🥅(backend) link role could be updated when restricted document
When a document was restricted, the link role could
be updated from "link-configuration" and gives a
200 response, but the change did not
have any effect because of a restriction in
LinkReachChoices.
We added a validation step to ensure that the
link role can only be updated if the document
is not restricted.
2025-09-18 12:17:08 +02:00
Anthony LC
e4aa85be83 (e2e) fix flakiness
Some tests were getting very flaky due to previous
tests updates. This should fix it.
2025-09-18 11:28:20 +02:00
Anthony LC
2dc1e07b42 ⚗️(service-worker) remove index from cache first strategy
Some users reported that the app was giving a
blank page, it seems to happens often after a
release. It could be due to the fact that
the service worker is caching the index.html
file and not updating it properly after a new release.
We remove the index from the cache first strategy
to avoid this kind of issue. We set as well
the default handler with the "StaleWhileRevalidate"
strategy to force the cache to be updated in
the background.
2025-09-18 10:40:47 +02:00
Anthony LC
fbdeb90113 🛂(frontend) invalidate doc query when lost connection
When the provider reports a lost connection,
we invalidate the doc query to refetch the document
data.
This ensures that if a user has lost is rights
to access a document, he will be redirected
to a 403 page without needing to refresh the page.
2025-09-17 17:45:26 +02:00
Anthony LC
b773f09792 🥅(frontend) improve meta 401 page
Add better meta for the 401 page.
2025-09-17 17:45:26 +02:00
Anthony LC
d8c9283dd1 🐛(frontend) fix 404 page when reload 403 page
When users were reloading a 403 page, they were
redirected to the 404 page because of Nextjs
routing mechanism. This commit fixes this issue by
removing the 403 page from the pages directory
and creating a component that is used directly
in the layout when a 403 error is detected.
2025-09-17 17:45:26 +02:00
Cyril
1e39d17914 (frontend) improve accessibility by adding landmark roles to layout
landmark roles help assistive tech users navigate quickly across the page

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-17 08:24:21 +02:00
Anthony LC
ecd2f97cf5 🐛(frontend) fix legacy role computation
Before the subpages feature, the user_role was
computed thanks to the abilities.
This is not the correct way to do it anymore,
the abilities are now different.
We now have "user_role" in the doc response
which is the correct way to get the user role
for the current document.
2025-09-16 17:23:26 +02:00
Anthony LC
90624e83f5 🩹(demo) update the email in realm.json
We updated the email addresses for the demo users
but forgot to change them in the realm.json file.
This commit fixes that oversight.
2025-09-16 17:23:26 +02:00
Cyril
5fc002658c (frontend) add pdf outline property to enable bookmarks display
allows pdf viewers like adobe reader to display bookmarks in the sidebar

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-16 14:29:53 +02:00
Cyril
dfd5dc1545 (frontend) document visible in list are now openable via enter key
the document now appears in the list and can be opened using the enter key

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-16 12:51:23 +02:00
Cyril
69e7235f75 (frontend) refine focus outline with shadow for visual consistency
aligns focus state with app style by adding background shadow to outline

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-16 10:56:34 +02:00
Cyril
942c90c29f (frontend) enable enter key to open documents and subdocuments
added keyboard support to open docs and subdocs using the enter key

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-16 10:26:49 +02:00
virgile-dev
c5f0142671 📝 (doc) add mosa.cloud docs instance (#1334)
## Purpose

So that users have more options to choose from


## Proposal
Add mosa.cloud docs instance url

Please ensure the following items are checked before submitting your
pull request:
- [x] I have read and followed the [contributing
guidelines](https://github.com/suitenumerique/docs/blob/main/CONTRIBUTING.md)
- [x] I have read and agreed to the [Code of
Conduct](https://github.com/suitenumerique/docs/blob/main/CODE_OF_CONDUCT.md)
- [x] I have signed off my commits with `git commit --signoff` (DCO
compliance)
- [x] I have signed my commits with my SSH or GPG key (`git commit -S`)
- [x] My commit messages follow the required format: `<gitmoji>(type)
title description`
- [ ] I have added a changelog entry under `## [Unreleased]` section (if
noticeable change)
- [ ] I have added corresponding tests for new features or bug fixes (if
applicable)

Signed-off-by: virgile-deville <virgile.deville@beta.gouv.fr>
2025-09-16 07:01:10 +00:00
Manuel Raynaud
7f37d3bda4 🐛(backend) duplicate sub docs as root for reader user
Reader user should be able to duplicate a doc in the doc tree. It should
be created a new doc at the root level.
2025-09-15 20:44:58 +00:00
Manuel Raynaud
7033d0ecf7 🐛(backend) cast DOCUMENT_IMAGE_MAX_SIZE in integer
The expected type for the settings DOCUMENT_IMAGE_MAX_SIZE is an
integer. By not using django configurations IntegerValue, the value is
used as it and most of the time will be a string. We must use the
IntegerValue in order to cast the value in string.
2025-09-15 17:47:43 +02:00
Fabre Florian
0dd6818e91 (frontend) Adapt e2e test utils to the Keycloak 26.3 login page
Fix the keyCloakSignIn() function for the new login page.

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-09-15 11:19:42 +02:00
Fabre Florian
eb225fc86f 🔧(keycloak) Fix https required issue in dev mode
On some environments keycloak returns a 'HTTPS required' message on login.
The same issue was fixed in drive by changing the 'sslRequired' value
from 'external' to 'none'.
Also upgrade keycloak up to 26.3.2

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-09-15 11:19:41 +02:00
Anthony LC
b893a29138 🔖(minor) release 3.7.0
Added:
- (api) add API route to fetch document content

Changed:
- 🔒️(backend) configure throttle on every viewsets
- ⬆️ Bump eslint to V9
- (frontend) improve accessibility:
  - fix major accessibility issues reported
  by wave and axe
  - unify tab focus style for better visual consistency
  - improve modal a11y: structure, labels, and title
  - improve accessibility of cdoc content with
  correct aria tags
  - unify tab focus style for better visual consistency
  - hide decorative icons, label menus, avoid
  accessible name
- ♻️(tilt) use helm dev-backend chart

Removed:
- 🔥(frontend) remove multi column drop cursor

Fixed:
- 🐛(frontend) fix callout emoji list
2025-09-12 14:21:13 +02:00
Anthony LC
a812580d6c ♻️(frontend) add categories on top of the EmojiPicker
In a recent fix we had to remove the categories
from the EmojiPicker component due to a bug in the
underlying library. This commit reintroduces the
categories feature, placing them at the top of the
picker for improved user experience. The
categories help users quickly find emojis
by grouping them into relevant sections.

We set the default color as well to ensure
consistency across the emoji picker.
2025-09-12 14:21:13 +02:00
AntoLC
1062e38c92 🌐(i18n) update translated strings
Update translated files with new translations
2025-09-12 12:11:02 +02:00
renovate[bot]
62e122b05f ⬆️(dependencies) update js dependencies 2025-09-12 11:33:17 +02:00
Anthony LC
32bc2890e0 📌(dependencies) pin wrap-ansi to 9.0.2
By security we pin wrap-ansi to 9.0.2,
the 9.0.1 version being infected.
2025-09-12 10:32:40 +02:00
Anthony LC
3c3686dc7e 🔧(frontend) add meta information to package.json files
- Add missing repository, author, and license fields
- Add recommended packageManager
2025-09-12 10:20:01 +02:00
Anthony LC
ab90611c36 🔥(frontend) remove multi column drop cursor
The drop cursor for multi column was causing
issues with the editor's usability.
This commit removes the custom drop cursor
implementation to enhance user experience.
2025-09-11 16:11:48 +02:00
Cyril
f9c08cf5ec Revert "(frontend) add document visible in list and openable via enter key"
This reverts commit b619850b1420421f09f56aa8644a93e0fa698682.
2025-09-11 13:43:36 +02:00
Cyril
2155c2ff1f (frontend) add document visible in list and openable via enter key
the document now appears in the list and can be opened using the enter key

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-11 13:43:35 +02:00
Cyril
ef08ba3a00 (frontend) hide decorative icons, label menus, avoid name duplicates
improves a11y by hiding decorative icons, labeling menus and deduping names

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-11 13:43:20 +02:00
Anthony LC
7a903041f8 🐛(frontend) fix callout emoji list
Removing explicit categories prop from
EmojiPicker to let emoji-mart manage categories
and avoid mismatch causing runtime error with
locales.
2025-09-11 11:54:52 +02:00
Anthony LC
4f2e07f949 🛂(frontend) limit input search to 254 characters
254 characters should be sufficient for most
of our usecases.
Limit input search to 254 characters to prevent
errors caused by overly long email addresses.
2025-09-10 16:11:16 +02:00
Anthony LC
8c1e95c587 (demo) change email from user to user.test in demo
When we create a new user in the demo environment,
the email address will now follow the format
user.test@example.com instead of user@example.com.
"user" was only 4 characters long, it created failing
tests in the e2e suite.
2025-09-10 16:11:16 +02:00
Manuel Raynaud
20161fd6db 🐛(backend) validate user search input data
Only the input data min length was checked. We also have to check the
mex length because the levenshtein dos not accept more than 254
characters and the email field has a max length of 254
2025-09-10 16:11:15 +02:00
dependabot[bot]
e827cfeee1 Bump django from 5.2.4 to 5.2.6 in /src/backend (#1360)
⬆️(backend) bump django from 5.2.4 to 5.2.6
    
Bumps [django](https://github.com/django/django) from 5.2.4 to 5.2.6.
- [Commits](https://github.com/django/django/compare/5.2.4...5.2.6)

---
updated-dependencies:
- dependency-name: django
  dependency-version: 5.2.6
  dependency-type: direct:production
...
    
Signed-off-by: dependabot[bot] <support@github.com>
2025-09-10 14:09:17 +00:00
Manuel Raynaud
eab2a75bff ♻️(tilt) use hem dev-backend chart (#1340)
Remove usage of bitnami charts and use our own dev-backend charts
instead.
2025-09-10 11:43:30 +00:00
Cyril
cd84751cb9 (frontend) fix major accessibility issues found by wave and axe
improves a11y by fixing multiple critical validation errors

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-10 10:26:47 +02:00
Anthony LC
1d20a8b0a7 💄(frontend) remove margin from modal title
Recent improvement changes the modal title with
a h1 tag, h1 tag adds margin by default.
We remove the margin from the h1 tag to stick to
the design system.
2025-09-10 09:35:54 +02:00
Cyril
8a310d004b (frontend) improve modal a11y: structure, labels, and title
added aria-label, structured text in p, and added title for better accessibility

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-10 08:10:35 +02:00
Cyril
9f9fae96e5 (frontend) unify tab focus style for better visual consistency
standardizes keyboard focus appearance to improve UI coherence

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-09 18:37:28 +02:00
Cyril
9cb2b6a6fb (frontend) improve accessibility of cdoc content with correct aria tags
added appropriate aria attributes and semantic tags to enhance accessibility

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-09 15:25:38 +02:00
178 changed files with 5353 additions and 2541 deletions

View File

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

4
.gitignore vendored
View File

@@ -43,6 +43,10 @@ venv.bak/
env.d/development/*.local
env.d/terraform
# Docker
compose.override.yml
docker/auth/*.local
# npm
node_modules

View File

@@ -1,5 +1,3 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0),
@@ -8,7 +6,48 @@ and this project adheres to
## [Unreleased]
### Added
### Added
- ✨(frontend) add pdf block to the editor #1293
### Changed
- ♻️(frontend) replace Arial font-family with token font #1411
- ♿(frontend) improve accessibility:
- #1354
- #1349
- ♿ improve accessibility by adding landmark roles to layout #1394
- ♿ add document visible in list and openable via enter key #1365
- ♿ add pdf outline property to enable bookmarks display #1368
- ♿ hide decorative icons from assistive tech with aria-hidden #1404
- ♿ fix rgaa 1.9.1: convert to figure/figcaption structure #1426
- ♿ remove redundant aria-label to avoid over-accessibility #1420
- ♿ remove redundant aria-label on hidden icons and update tests #1432
- ♿ improve semantic structure and aria roles of leftpanel #1431
- ♿ add default background to left panel for better accessibility #1423
- ♿ restyle checked checkboxes: removing strikethrough #1439
- ♿ add h1 for SR on 40X pages and remove alt texts #1438
- ♿ update labels and shared document icon accessibility #1442
- ✨(backend) add async indexation of documents on save (or access save) #1276
- ✨(backend) add debounce mechanism to limit indexation jobs #1276
- ✨(api) add API route to search for indexed documents in Find #1276
### Fixed
- 🐛(backend) duplicate sub docs as root for reader users
- ⚗️(service-worker) remove index from cache first strategy #1395
- 🐛(frontend) fix 404 page when reload 403 page #1402
- 🐛(frontend) fix legacy role computation #1376
- 🐛(frontend) scroll back to top when navigate to a document #1406
### Changed
- ♿(frontend) improve accessibility:
- ♿improve NVDA navigation in DocShareModal #1396
## [3.7.0] - 2025-09-12
### Added
- ✨(api) add API route to fetch document content #1206
@@ -16,6 +55,22 @@ and this project adheres to
- 🔒️(backend) configure throttle on every viewsets #1343
- ⬆️ Bump eslint to V9 #1071
- ♿(frontend) improve accessibility:
- ♿fix major accessibility issues reported by wave and axe #1344
- ✨unify tab focus style for better visual consistency #1341
- ✨improve modal a11y: structure, labels, and title #1349
- ✨improve accessibility of cdoc content with correct aria tags #1271
- ✨unify tab focus style for better visual consistency #1341
- ♿hide decorative icons, label menus, avoid accessible name… #1362
- ♻️(tilt) use helm dev-backend chart
### Removed
- 🔥(frontend) remove multi column drop cursor #1370
### Fixed
- 🐛(frontend) fix callout emoji list #1366
## [3.6.0] - 2025-09-04
@@ -52,6 +107,7 @@ and this project adheres to
- 🐛(frontend) fix dnd conflict with tree and Blocknote #1328
- 🐛(frontend) fix display bug on homepage #1332
- 🐛link role update #1287
- 🔧(keycloak) Fix https required issue in dev mode #1286
## [3.5.0] - 2025-07-31
@@ -723,7 +779,8 @@ and this project adheres to
- ✨(frontend) Coming Soon page (#67)
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/suitenumerique/docs/compare/v3.6.0...main
[unreleased]: https://github.com/suitenumerique/docs/compare/v3.7.0...main
[v3.7.0]: https://github.com/suitenumerique/docs/releases/v3.7.0
[v3.6.0]: https://github.com/suitenumerique/docs/releases/v3.6.0
[v3.5.0]: https://github.com/suitenumerique/docs/releases/v3.5.0
[v3.4.2]: https://github.com/suitenumerique/docs/releases/v3.4.2

View File

@@ -247,6 +247,10 @@ demo: ## flush db then create a demo for load testing purpose
@$(MANAGE) create_demo
.PHONY: demo
index: ## index all documents to remote search
@$(MANAGE) index
.PHONY: index
# Nota bene: Black should come after isort just in case they don't agree...
lint: ## lint back-end python sources
lint: \

View File

@@ -54,16 +54,16 @@ Docs is a collaborative text editor designed to address common challenges in kno
We use Kubernetes for our [production instance](https://docs.numerique.gouv.fr/) but also support Docker Compose. The community contributed a couple other methods (Nix, YunoHost etc.) check out the [docs](/docs/installation/README.md) to get detailed instructions and examples.
#### 🌍 Known instances
We hope to see many more, here is an incomplete list of public Docs instances (urls listed in alphabetical order). Feel free to make a PR to add ones that are not listed below🙏
| | | |
| --- | --- | ------- |
We hope to see many more, here is an incomplete list of public Docs instances. Feel free to make a PR to add ones that are not listed below🙏
| Url | Org | Public |
| docs.numerique.gouv.fr | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up|
| docs.suite.anct.gouv.fr | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up|
| notes.demo.opendesk.eu | ZenDiS | Demo instance of OpenDesk. Request access to get credentials |
| notes.liiib.re | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
| docs.federated.nexus | federated.nexus | Public instance, but you have to [sign up for a Federated Nexus account](https://federated.nexus/register/). |
| --- | --- | ------- |
| [docs.numerique.gouv.fr](https://docs.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up|
| [docs.suite.anct.gouv.fr](https://docs.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up|
| [notes.demo.opendesk.eu](https://notes.demo.opendesk.eu) | ZenDiS | Demo instance of OpenDesk. Request access to get credentials |
| [notes.liiib.re](https://notes.liiib.re/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
| [docs.federated.nexus](https://docs.federated.nexus/) | federated.nexus | Public instance, but you have to [sign up for a Federated Nexus account](https://federated.nexus/register/). |
| [docs.demo.mosacloud.eu](https://docs.demo.mosacloud.eu/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
#### ⚠️ Advanced features
For some advanced features (ex: Export as PDF) Docs relies on XL packages from BlockNote. These are licenced under GPL and are not MIT compatible. You can perfectly use Docs without these packages by setting the environment variable `PUBLISH_AS_MIT` to true. That way you'll build an image of the application without the features that are not MIT compatible. Read the [environment variables documentation](/docs/env.md) for more information.

View File

@@ -39,9 +39,10 @@ docker_build(
]
)
k8s_resource('impress-docs-backend-migrate', resource_deps=['postgres-postgresql'])
k8s_resource('impress-docs-backend-migrate', resource_deps=['dev-backend-postgres'])
k8s_resource('impress-docs-backend-createsuperuser', resource_deps=['impress-docs-backend-migrate'])
k8s_resource('impress-docs-backend', resource_deps=['impress-docs-backend-migrate'])
k8s_resource('dev-backend-keycloak', resource_deps=['dev-backend-keycloak-pg'])
k8s_resource('impress-docs-backend', resource_deps=['impress-docs-backend-migrate', 'dev-backend-redis', 'dev-backend-keycloak', 'dev-backend-postgres', 'dev-backend-minio:statefulset'])
k8s_yaml(local('cd ../src/helm && helmfile -n impress -e dev template .'))
migration = '''

6
bin/fernetkey Executable file
View File

@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# shellcheck source=bin/_config.sh
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
_dc_run app-dev python -c 'from cryptography.fernet import Fernet;import sys; sys.stdout.write("\n" + Fernet.generate_key().decode() + "\n");'

View File

@@ -72,6 +72,11 @@ services:
- env.d/development/postgresql.local
ports:
- "8071:8000"
networks:
default: {}
lasuite-net:
aliases:
- impress
volumes:
- ./src/backend:/app
- ./data/static:/data/static
@@ -92,6 +97,9 @@ services:
command: ["celery", "-A", "impress.celery_app", "worker", "-l", "DEBUG"]
environment:
- DJANGO_CONFIGURATION=Development
networks:
- default
- lasuite-net
env_file:
- env.d/development/common
- env.d/development/common.local
@@ -107,6 +115,11 @@ services:
image: nginx:1.25
ports:
- "8083:8083"
networks:
default: {}
lasuite-net:
aliases:
- nginx
volumes:
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
depends_on:
@@ -184,22 +197,20 @@ services:
- env.d/development/kc_postgresql.local
keycloak:
image: quay.io/keycloak/keycloak:20.0.1
image: quay.io/keycloak/keycloak:26.3
volumes:
- ./docker/auth/realm.json:/opt/keycloak/data/import/realm.json
command:
- start-dev
- --features=preview
- --import-realm
- --proxy=edge
- --hostname-url=http://localhost:8083
- --hostname-admin-url=http://localhost:8083/
- --hostname=http://localhost:8083
- --hostname-strict=false
- --hostname-strict-https=false
- --health-enabled=true
- --metrics-enabled=true
healthcheck:
test: ["CMD", "curl", "--head", "-fsS", "http://localhost:8080/health/ready"]
test: ['CMD-SHELL', 'exec 3<>/dev/tcp/localhost/9000; echo -e "GET /health/live HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" >&3; grep "HTTP/1.1 200 OK" <&3']
start_period: 5s
interval: 1s
timeout: 2s
retries: 300
@@ -219,3 +230,8 @@ services:
kc_postgresql:
condition: service_healthy
restart: true
networks:
lasuite-net:
name: lasuite-net
driver: bridge

View File

@@ -26,7 +26,7 @@
"oauth2DeviceCodeLifespan": 600,
"oauth2DevicePollingInterval": 5,
"enabled": true,
"sslRequired": "external",
"sslRequired": "none",
"registrationAllowed": true,
"registrationEmailAsUsername": false,
"rememberMe": true,
@@ -60,7 +60,7 @@
},
{
"username": "user-e2e-chromium",
"email": "user@chromium.test",
"email": "user.test@chromium.test",
"firstName": "E2E",
"lastName": "Chromium",
"enabled": true,
@@ -74,7 +74,7 @@
},
{
"username": "user-e2e-webkit",
"email": "user@webkit.test",
"email": "user.test@webkit.test",
"firstName": "E2E",
"lastName": "Webkit",
"enabled": true,
@@ -88,7 +88,7 @@
},
{
"username": "user-e2e-firefox",
"email": "user@firefox.test",
"email": "user.test@firefox.test",
"firstName": "E2E",
"lastName": "Firefox",
"enabled": true,
@@ -2270,7 +2270,7 @@
"cibaInterval": "5",
"realmReusableOtpCode": "false"
},
"keycloakVersion": "20.0.1",
"keycloakVersion": "26.3.2",
"userManagedAccessAllowed": false,
"clientProfiles": {
"profiles": []

View File

@@ -49,6 +49,14 @@ LOGOUT_REDIRECT_URL=http://localhost:3000
OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
# Store OIDC tokens in the session
OIDC_STORE_ACCESS_TOKEN = True
OIDC_STORE_REFRESH_TOKEN = True # Store the encrypted refresh token in the session.
# Must be a valid Fernet key (32 url-safe base64-encoded bytes)
# To create one, use the bin/fernetkey command.
# OIDC_STORE_REFRESH_TOKEN_KEY="your-32-byte-encryption-key=="
# AI
AI_FEATURE_ENABLED=true
AI_BASE_URL=https://openaiendpoint.com
@@ -66,3 +74,9 @@ COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/
DJANGO_SERVER_TO_SERVER_API_TOKENS=server-api-token
Y_PROVIDER_API_BASE_URL=http://y-provider-development:4444/api/
Y_PROVIDER_API_KEY=yprovider-api-key
# impress
SEARCH_INDEXER_CLASS="core.services.search_indexers.FindDocumentIndexer"
SEARCH_INDEXER_SECRET=find-api-key-for-docs-with-exactly-50-chars-length # Key generated by create_demo in Find app.
SEARCH_INDEXER_URL="http://find:8000/api/v1.0/documents/index/"
SEARCH_INDEXER_QUERY_URL="http://find:8000/api/v1.0/documents/search/"

View File

@@ -128,3 +128,11 @@ class ListDocumentFilter(DocumentFilter):
queryset_method = queryset.filter if bool(value) else queryset.exclude
return queryset_method(link_traces__user=user, link_traces__is_masked=True)
class UserSearchFilter(django_filters.FilterSet):
"""
Custom filter for searching users.
"""
q = django_filters.CharFilter(min_length=5, max_length=254)

View File

@@ -506,6 +506,10 @@ class LinkDocumentSerializer(serializers.ModelSerializer):
We expose it separately from document in order to simplify and secure access control.
"""
link_reach = serializers.ChoiceField(
choices=models.LinkReachChoices.choices, required=True
)
class Meta:
model = models.Document
fields = [
@@ -513,6 +517,58 @@ class LinkDocumentSerializer(serializers.ModelSerializer):
"link_reach",
]
def validate(self, attrs):
"""Validate that link_role and link_reach are compatible using get_select_options."""
link_reach = attrs.get("link_reach")
link_role = attrs.get("link_role")
if not link_reach:
raise serializers.ValidationError(
{"link_reach": _("This field is required.")}
)
# Get available options based on ancestors' link definition
available_options = models.LinkReachChoices.get_select_options(
**self.instance.ancestors_link_definition
)
# Validate link_reach is allowed
if link_reach not in available_options:
msg = _(
"Link reach '%(link_reach)s' is not allowed based on parent document configuration."
)
raise serializers.ValidationError(
{"link_reach": msg % {"link_reach": link_reach}}
)
# Validate link_role is compatible with link_reach
allowed_roles = available_options[link_reach]
# Restricted reach: link_role must be None
if link_reach == models.LinkReachChoices.RESTRICTED:
if link_role is not None:
raise serializers.ValidationError(
{
"link_role": (
"Cannot set link_role when link_reach is 'restricted'. "
"Link role must be null for restricted reach."
)
}
)
return attrs
# Non-restricted: link_role must be in allowed roles
if link_role not in allowed_roles:
allowed_roles_str = ", ".join(allowed_roles) if allowed_roles else "none"
raise serializers.ValidationError(
{
"link_role": (
f"Link role '{link_role}' is not allowed for link reach '{link_reach}'. "
f"Allowed roles: {allowed_roles_str}"
)
}
)
return attrs
class DocumentDuplicationSerializer(serializers.Serializer):
"""
@@ -821,3 +877,13 @@ class MoveDocumentSerializer(serializers.Serializer):
choices=enums.MoveNodePositionChoices.choices,
default=enums.MoveNodePositionChoices.LAST_CHILD,
)
class FindDocumentSerializer(serializers.Serializer):
"""Serializer for Find search requests"""
q = serializers.CharField(required=True, allow_blank=False, trim_whitespace=True)
page_size = serializers.IntegerField(
required=False, min_value=1, max_value=50, default=20
)
page = serializers.IntegerField(required=False, min_value=1, default=1)

View File

@@ -21,6 +21,7 @@ from django.db.models.expressions import RawSQL
from django.db.models.functions import Left, Length
from django.http import Http404, StreamingHttpResponse
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.utils.functional import cached_property
from django.utils.text import capfirst, slugify
from django.utils.translation import gettext_lazy as _
@@ -31,6 +32,7 @@ from botocore.exceptions import ClientError
from csp.constants import NONE
from csp.decorators import csp_update
from lasuite.malware_detection import malware_detection
from lasuite.oidc_login.decorators import refresh_oidc_access_token
from rest_framework import filters, status, viewsets
from rest_framework import response as drf_response
from rest_framework.permissions import AllowAny
@@ -47,11 +49,15 @@ from core.services.converter_services import (
from core.services.converter_services import (
YdocConverter,
)
from core.services.search_indexers import (
get_document_indexer,
get_visited_document_ids_of,
)
from core.tasks.mail import send_ask_for_access_mail
from core.utils import extract_attachments, filter_descendants
from . import permissions, serializers, utils
from .filters import DocumentFilter, ListDocumentFilter
from .filters import DocumentFilter, ListDocumentFilter, UserSearchFilter
from .throttling import UserListThrottleBurst, UserListThrottleSustained
logger = logging.getLogger(__name__)
@@ -176,12 +182,18 @@ class UserViewSet(
if self.action != "list":
return queryset
filterset = UserSearchFilter(
self.request.GET, queryset=queryset, request=self.request
)
if not filterset.is_valid():
raise drf.exceptions.ValidationError(filterset.errors)
# Exclude all users already in the given document
if document_id := self.request.query_params.get("document_id", ""):
queryset = queryset.exclude(documentaccess__document_id=document_id)
if not (query := self.request.query_params.get("q", "")) or len(query) < 5:
return queryset.none()
filter_data = filterset.form.cleaned_data
query = filter_data["q"]
# For emails, match emails by Levenstein distance to prevent typing errors
if "@" in query:
@@ -367,6 +379,7 @@ class DocumentViewSet(
list_serializer_class = serializers.ListDocumentSerializer
trashbin_serializer_class = serializers.ListDocumentSerializer
tree_serializer_class = serializers.ListDocumentSerializer
search_serializer_class = serializers.ListDocumentSerializer
def get_queryset(self):
"""Get queryset performing all annotation and filtering on the document tree structure."""
@@ -935,37 +948,64 @@ class DocumentViewSet(
in the payload.
"""
# Get document while checking permissions
document = self.get_object()
document_to_duplicate = self.get_object()
serializer = serializers.DocumentDuplicationSerializer(
data=request.data, partial=True
)
serializer.is_valid(raise_exception=True)
with_accesses = serializer.validated_data.get("with_accesses", False)
is_owner_or_admin = document.get_role(request.user) in models.PRIVILEGED_ROLES
user_role = document_to_duplicate.get_role(request.user)
is_owner_or_admin = user_role in models.PRIVILEGED_ROLES
base64_yjs_content = document.content
base64_yjs_content = document_to_duplicate.content
# Duplicate the document instance
link_kwargs = (
{"link_reach": document.link_reach, "link_role": document.link_role}
{
"link_reach": document_to_duplicate.link_reach,
"link_role": document_to_duplicate.link_role,
}
if with_accesses
else {}
)
extracted_attachments = set(extract_attachments(document.content))
attachments = list(extracted_attachments & set(document.attachments))
duplicated_document = document.add_sibling(
extracted_attachments = set(extract_attachments(document_to_duplicate.content))
attachments = list(
extracted_attachments & set(document_to_duplicate.attachments)
)
title = capfirst(_("copy of {title}").format(title=document_to_duplicate.title))
if not document_to_duplicate.is_root() and choices.RoleChoices.get_priority(
user_role
) < choices.RoleChoices.get_priority(models.RoleChoices.EDITOR):
duplicated_document = models.Document.add_root(
creator=self.request.user,
title=title,
content=base64_yjs_content,
attachments=attachments,
duplicated_from=document_to_duplicate,
**link_kwargs,
)
models.DocumentAccess.objects.create(
document=duplicated_document,
user=self.request.user,
role=models.RoleChoices.OWNER,
)
return drf_response.Response(
{"id": str(duplicated_document.id)}, status=status.HTTP_201_CREATED
)
duplicated_document = document_to_duplicate.add_sibling(
"right",
title=capfirst(_("copy of {title}").format(title=document.title)),
title=title,
content=base64_yjs_content,
attachments=attachments,
duplicated_from=document,
duplicated_from=document_to_duplicate,
creator=request.user,
**link_kwargs,
)
# Always add the logged-in user as OWNER for root documents
if document.is_root():
if document_to_duplicate.is_root():
accesses_to_create = [
models.DocumentAccess(
document=duplicated_document,
@@ -977,7 +1017,7 @@ class DocumentViewSet(
# If accesses should be duplicated, add other users' accesses as per original document
if with_accesses and is_owner_or_admin:
original_accesses = models.DocumentAccess.objects.filter(
document=document
document=document_to_duplicate
).exclude(user=request.user)
accesses_to_create.extend(
@@ -997,6 +1037,68 @@ class DocumentViewSet(
{"id": str(duplicated_document.id)}, status=status.HTTP_201_CREATED
)
@drf.decorators.action(detail=False, methods=["get"], url_path="search")
@method_decorator(refresh_oidc_access_token)
def search(self, request, *args, **kwargs):
"""
Returns a DRF response containing the filtered, annotated and ordered document list.
Applies filtering based on request parameter 'q' from `FindDocumentSerializer`.
Depending of the configuration it can be:
- A fulltext search through the opensearch indexation app "find" if the backend is
enabled (see SEARCH_BACKEND_CLASS)
- A filtering by the model field 'title'.
The ordering is always by the most recent first.
"""
access_token = request.session.get("oidc_access_token")
user = request.user
serializer = serializers.FindDocumentSerializer(data=request.query_params)
serializer.is_valid(raise_exception=True)
indexer = get_document_indexer()
text = serializer.validated_data["q"]
# The indexer is not configured, so we fallback on a simple filter on the
# model field 'title'.
if not indexer:
# As the 'list' view we get a prefiltered queryset (deleted docs are excluded)
queryset = self.get_queryset()
filterset = DocumentFilter({"title": text}, queryset=queryset)
if not filterset.is_valid():
raise drf.exceptions.ValidationError(filterset.errors)
queryset = filterset.filter_queryset(queryset).order_by("-updated_at")
return self.get_response_for_queryset(
queryset,
context={
"request": request,
},
)
queryset = models.Document.objects.all()
# Retrieve the documents ids from Find.
results = indexer.search(
text=text,
token=access_token,
visited=get_visited_document_ids_of(queryset, user),
page=serializer.validated_data.get("page", 1),
page_size=serializer.validated_data.get("page_size", 20),
)
queryset = queryset.filter(pk__in=results).order_by("-updated_at")
return self.get_response_for_queryset(
queryset,
context={
"request": request,
},
)
@drf.decorators.action(detail=True, methods=["get"], url_path="versions")
def versions_list(self, request, *args, **kwargs):
"""

View File

@@ -1,11 +1,19 @@
"""Impress Core application"""
# from django.apps import AppConfig
# from django.utils.translation import gettext_lazy as _
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
# class CoreConfig(AppConfig):
# """Configuration class for the impress core app."""
class CoreConfig(AppConfig):
"""Configuration class for the impress core app."""
# name = "core"
# app_label = "core"
# verbose_name = _("impress core application")
name = "core"
app_label = "core"
verbose_name = _("Impress core application")
def ready(self):
"""
Import signals when the app is ready.
"""
# pylint: disable=import-outside-toplevel, unused-import
from . import signals # noqa: PLC0415

View File

@@ -0,0 +1,40 @@
"""
Handle search setup that needs to be done at bootstrap time.
"""
import logging
import time
from django.core.management.base import BaseCommand, CommandError
from core.services.search_indexers import get_document_indexer
logger = logging.getLogger("docs.search.bootstrap_search")
class Command(BaseCommand):
"""Index all documents to remote search service"""
help = __doc__
def handle(self, *args, **options):
"""Launch and log search index generation."""
indexer = get_document_indexer()
if not indexer:
raise CommandError("The indexer is not enabled or properly configured.")
logger.info("Starting to regenerate Find index...")
start = time.perf_counter()
try:
count = indexer.index()
except Exception as err:
raise CommandError("Unable to regenerate index") from err
duration = time.perf_counter() - start
logger.info(
"Search index regenerated from %d document(s) in %.2f seconds.",
count,
duration,
)

View File

@@ -28,7 +28,6 @@ from django.utils.translation import get_language, override
from django.utils.translation import gettext_lazy as _
from botocore.exceptions import ClientError
from dirtyfields import DirtyFieldsMixin
from rest_framework.exceptions import ValidationError
from timezone_field import TimeZoneField
from treebeard.mp_tree import MP_Node, MP_NodeManager, MP_NodeQuerySet
@@ -354,7 +353,7 @@ class DocumentManager(MP_NodeManager.from_queryset(DocumentQuerySet)):
# pylint: disable=too-many-public-methods
class Document(DirtyFieldsMixin, MP_Node, BaseModel):
class Document(MP_Node, BaseModel):
"""Pad document carrying the content."""
title = models.CharField(_("title"), max_length=255, null=True, blank=True)
@@ -404,8 +403,6 @@ class Document(DirtyFieldsMixin, MP_Node, BaseModel):
objects = DocumentManager()
FIELDS_TO_CHECK = ["link_reach", "link_role"]
class Meta:
db_table = "impress_document"
ordering = ("path",)
@@ -429,46 +426,39 @@ class Document(DirtyFieldsMixin, MP_Node, BaseModel):
super().__init__(*args, **kwargs)
self._ancestors_link_definition = None
self._computed_link_definition = None
self._cached_parent_obj = None
def save(self, *args, **kwargs):
"""Write content to object storage only if _content has changed."""
invalidate_abilities_cache = False
if not self._state.adding:
changed_fields = self.get_dirty_fields()
if any(field in changed_fields for field in self.FIELDS_TO_CHECK):
invalidate_abilities_cache = True
super().save(*args, **kwargs)
if invalidate_abilities_cache:
self.invalidate_abilities_cache()
if self._content:
file_key = self.file_key
bytes_content = self._content.encode("utf-8")
self.save_content(self._content)
# Attempt to directly check if the object exists using the storage client.
try:
response = default_storage.connection.meta.client.head_object(
Bucket=default_storage.bucket_name, Key=file_key
)
except ClientError as excpt:
# If the error is a 404, the object doesn't exist, so we should create it.
if excpt.response["Error"]["Code"] == "404":
has_changed = True
else:
raise
def save_content(self, content):
"""Save content to object storage."""
file_key = self.file_key
bytes_content = content.encode("utf-8")
# Attempt to directly check if the object exists using the storage client.
try:
response = default_storage.connection.meta.client.head_object(
Bucket=default_storage.bucket_name, Key=file_key
)
except ClientError as excpt:
# If the error is a 404, the object doesn't exist, so we should create it.
if excpt.response["Error"]["Code"] == "404":
has_changed = True
else:
# Compare the existing ETag with the MD5 hash of the new content.
has_changed = (
response["ETag"].strip('"')
!= hashlib.md5(bytes_content).hexdigest() # noqa: S324
)
raise
else:
# Compare the existing ETag with the MD5 hash of the new content.
has_changed = (
response["ETag"].strip('"') != hashlib.md5(bytes_content).hexdigest() # noqa: S324
)
if has_changed:
content_file = ContentFile(bytes_content)
default_storage.save(file_key, content_file)
if has_changed:
content_file = ContentFile(bytes_content)
default_storage.save(file_key, content_file)
def is_leaf(self):
"""
@@ -476,42 +466,6 @@ class Document(DirtyFieldsMixin, MP_Node, BaseModel):
"""
return not self.has_deleted_children and self.numchild == 0
def get_ancestors(self):
"""
:returns: A queryset containing the current node object's ancestors,
starting by the root node and descending to the parent.
"""
if self.is_root():
return Document.objects.none()
paths = [self.path[0:pos] for pos in range(self.steplen, len(self.path), self.steplen)]
return (
Document.objects.select_related("creator")
.filter(path__in=paths)
.order_by("depth")
)
def get_parent(self, update=False):
"""
:returns: the parent node of the current node object.
Caches the result in the object itself to help in loops.
"""
depth = int(len(self.path) / self.steplen)
if depth <= 1:
return None
if update:
self._cached_parent_obj = None
if self._cached_parent_obj is not None:
return self._cached_parent_obj
parentpath = self._get_basepath(self.path, depth - 1)
self._cached_parent_obj = Document.objects.select_related("creator").get(
path=parentpath
)
return self._cached_parent_obj
@property
def key_base(self):
"""Key base of the location where the document is stored in object storage."""
@@ -761,44 +715,10 @@ class Document(DirtyFieldsMixin, MP_Node, BaseModel):
"""Actual link role on the document."""
return self.computed_link_definition["link_role"]
def _get_abilities_cache_key(self):
"""Generate a unique cache key for each document."""
return f"document:abilities:{self.path!s}"
def _get_abilities_cache_for_user(self, user):
"""Return the abilities cache for the document and user."""
key = self._get_abilities_cache_key()
document_abilities = cache.get(key, {})
user_id = user.id if user.is_authenticated else "anonymous"
return document_abilities.get(user_id)
def _set_abilities_cache_for_user(self, user, abilities):
"""Set the abilities cache for the document and user."""
key = self._get_abilities_cache_key()
document_abilities = cache.get(key, {})
user_id = user.id if user.is_authenticated else "anonymous"
document_abilities[user_id] = abilities
cache.set(key, document_abilities, settings.DOCUMENT_ABILITIES_CACHE_TIMEOUT)
def invalidate_abilities_cache(self):
"""Invalidate the abilities cache for the document."""
key = self._get_abilities_cache_key()
cache.delete(key)
# Invalidate in cascade the abilities for all children
for child in self.get_children():
child.invalidate_abilities_cache()
def get_abilities(self, user):
"""
Compute and return abilities for a given user on the document.
"""
abilities = self._get_abilities_cache_for_user(user)
if abilities is not None:
return abilities
# First get the role based on specific access
role = self.get_role(user)
@@ -842,10 +762,6 @@ class Document(DirtyFieldsMixin, MP_Node, BaseModel):
if self.is_root()
else (is_owner_or_admin or (user.is_authenticated and self.creator == user))
)
can_duplicate = can_get and user.is_authenticated
if not self.is_root() and user.is_authenticated:
parent_ability = self.get_parent().get_abilities(user)
can_duplicate = parent_ability["children_create"]
ai_allow_reach_from = settings.AI_ALLOW_REACH_FROM
ai_access = any(
@@ -859,7 +775,7 @@ class Document(DirtyFieldsMixin, MP_Node, BaseModel):
]
)
abilities = {
return {
"accesses_manage": is_owner_or_admin,
"accesses_view": has_access_role,
"ai_transform": ai_access,
@@ -874,7 +790,7 @@ class Document(DirtyFieldsMixin, MP_Node, BaseModel):
"cors_proxy": can_get,
"descendants": can_get,
"destroy": can_destroy,
"duplicate": can_duplicate,
"duplicate": can_get and user.is_authenticated,
"favorite": can_get and user.is_authenticated,
"link_configuration": is_owner_or_admin,
"invite_owner": is_owner,
@@ -891,8 +807,6 @@ class Document(DirtyFieldsMixin, MP_Node, BaseModel):
"versions_list": has_access_role,
"versions_retrieve": has_access_role,
}
self._set_abilities_cache_for_user(user, abilities)
return abilities
def send_email(self, subject, emails, context=None, language=None):
"""Generate and send email from a template."""
@@ -978,7 +892,6 @@ class Document(DirtyFieldsMixin, MP_Node, BaseModel):
self.ancestors_deleted_at = self.deleted_at = timezone.now()
self.save()
self.invalidate_nb_accesses_cache()
self.invalidate_abilities_cache()
if self.depth > 1:
self._meta.model.objects.filter(pk=self.get_parent().pk).update(
@@ -1140,7 +1053,6 @@ class DocumentAccess(BaseAccess):
"""Override save to clear the document's cache for number of accesses."""
super().save(*args, **kwargs)
self.document.invalidate_nb_accesses_cache()
self.document.invalidate_abilities_cache()
@property
def target_key(self):
@@ -1151,7 +1063,6 @@ class DocumentAccess(BaseAccess):
"""Override delete to clear the document's cache for number of accesses."""
super().delete(*args, **kwargs)
self.document.invalidate_nb_accesses_cache()
self.document.invalidate_abilities_cache()
def set_user_roles_tuple(self, ancestors_role, current_role):
"""

View File

@@ -0,0 +1,303 @@
"""Document search index management utilities and indexers"""
import logging
from abc import ABC, abstractmethod
from collections import defaultdict
from functools import cache
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ImproperlyConfigured
from django.db.models import Subquery
from django.utils.module_loading import import_string
import requests
from core import models, utils
logger = logging.getLogger(__name__)
@cache
def get_document_indexer():
"""Returns an instance of indexer service if enabled and properly configured."""
classpath = settings.SEARCH_INDEXER_CLASS
# For this usecase an empty indexer class is not an issue but a feature.
if not classpath:
logger.info("Document indexer is not configured (see SEARCH_INDEXER_CLASS)")
return None
try:
indexer_class = import_string(settings.SEARCH_INDEXER_CLASS)
return indexer_class()
except ImportError as err:
logger.error("SEARCH_INDEXER_CLASS setting is not valid : %s", err)
except ImproperlyConfigured as err:
logger.error("Document indexer is not properly configured : %s", err)
return None
def get_batch_accesses_by_users_and_teams(paths):
"""
Get accesses related to a list of document paths,
grouped by users and teams, including all ancestor paths.
"""
ancestor_map = utils.get_ancestor_to_descendants_map(
paths, steplen=models.Document.steplen
)
ancestor_paths = list(ancestor_map.keys())
access_qs = models.DocumentAccess.objects.filter(
document__path__in=ancestor_paths
).values("document__path", "user__sub", "team")
access_by_document_path = defaultdict(lambda: {"users": set(), "teams": set()})
for access in access_qs:
ancestor_path = access["document__path"]
user_sub = access["user__sub"]
team = access["team"]
for descendant_path in ancestor_map.get(ancestor_path, []):
if user_sub:
access_by_document_path[descendant_path]["users"].add(str(user_sub))
if team:
access_by_document_path[descendant_path]["teams"].add(team)
return dict(access_by_document_path)
def get_visited_document_ids_of(queryset, user):
"""
Returns the ids of the documents that have a linktrace to the user and NOT owned.
It will be use to limit the opensearch responses to the public documents already
"visited" by the user.
"""
if isinstance(user, AnonymousUser):
return []
qs = models.LinkTrace.objects.filter(user=user)
docs = (
queryset.exclude(accesses__user=user)
.filter(
deleted_at__isnull=True,
ancestors_deleted_at__isnull=True,
)
.filter(pk__in=Subquery(qs.values("document_id")))
.order_by("pk")
.distinct("pk")
)
return [str(id) for id in docs.values_list("pk", flat=True)]
class BaseDocumentIndexer(ABC):
"""
Base class for document indexers.
Handles batching and access resolution. Subclasses must implement both
`serialize_document()` and `push()` to define backend-specific behavior.
"""
def __init__(self, batch_size=None):
"""
Initialize the indexer.
Args:
batch_size (int, optional): Number of documents per batch.
Defaults to settings.SEARCH_INDEXER_BATCH_SIZE.
"""
self.batch_size = batch_size or settings.SEARCH_INDEXER_BATCH_SIZE
self.indexer_url = settings.SEARCH_INDEXER_URL
self.indexer_secret = settings.SEARCH_INDEXER_SECRET
self.search_url = settings.SEARCH_INDEXER_QUERY_URL
if not self.indexer_url:
raise ImproperlyConfigured(
"SEARCH_INDEXER_URL must be set in Django settings."
)
if not self.indexer_secret:
raise ImproperlyConfigured(
"SEARCH_INDEXER_SECRET must be set in Django settings."
)
if not self.search_url:
raise ImproperlyConfigured(
"SEARCH_INDEXER_QUERY_URL must be set in Django settings."
)
def index(self):
"""
Fetch documents in batches, serialize them, and push to the search backend.
"""
last_id = 0
count = 0
while True:
documents_batch = list(
models.Document.objects.filter(
id__gt=last_id,
).order_by("id")[: self.batch_size]
)
if not documents_batch:
break
doc_paths = [doc.path for doc in documents_batch]
last_id = documents_batch[-1].id
accesses_by_document_path = get_batch_accesses_by_users_and_teams(doc_paths)
serialized_batch = [
self.serialize_document(document, accesses_by_document_path)
for document in documents_batch
if document.content or document.title
]
self.push(serialized_batch)
count += len(serialized_batch)
return count
@abstractmethod
def serialize_document(self, document, accesses):
"""
Convert a Document instance to a JSON-serializable format for indexing.
Must be implemented by subclasses.
"""
@abstractmethod
def push(self, data):
"""
Push a batch of serialized documents to the backend.
Must be implemented by subclasses.
"""
# pylint: disable-next=too-many-arguments,too-many-positional-arguments
def search(self, text, token, visited=(), page=1, page_size=50):
"""
Search for documents in Find app.
Ensure the same default ordering as "Docs" list : -updated_at
Returns ids of the documents
Args:
text (str): Text search content.
token (str): OIDC Authentication token.
visited (list, optional):
List of ids of active public documents with LinkTrace
Defaults to settings.SEARCH_INDEXER_BATCH_SIZE.
page (int, optional):
The page number to retrieve.
Defaults to 1 if not specified.
page_size (int, optional):
The number of results to return per page.
Defaults to 50 if not specified.
"""
response = self.search_query(
data={
"q": text,
"visited": visited,
"services": ["docs"],
"page_number": page,
"page_size": page_size,
"order_by": "updated_at",
"order_direction": "desc",
},
token=token,
)
return [d["_id"] for d in response]
@abstractmethod
def search_query(self, data, token) -> dict:
"""
Retrieve documents from the Find app API.
Must be implemented by subclasses.
"""
class FindDocumentIndexer(BaseDocumentIndexer):
"""
Document indexer that pushes documents to La Suite Find app.
"""
def serialize_document(self, document, accesses):
"""
Convert a Document to the JSON format expected by La Suite Find.
Args:
document (Document): The document instance.
accesses (dict): Mapping of document ID to user/team access.
Returns:
dict: A JSON-serializable dictionary.
"""
doc_path = document.path
doc_content = document.content
text_content = utils.base64_yjs_to_text(doc_content) if doc_content else ""
return {
"id": str(document.id),
"title": document.title or "",
"content": text_content,
"depth": document.depth,
"path": document.path,
"numchild": document.numchild,
"created_at": document.created_at.isoformat(),
"updated_at": document.updated_at.isoformat(),
"users": list(accesses.get(doc_path, {}).get("users", set())),
"groups": list(accesses.get(doc_path, {}).get("teams", set())),
"reach": document.computed_link_reach,
"size": len(text_content.encode("utf-8")),
"is_active": not bool(document.ancestors_deleted_at),
}
def search_query(self, data, token) -> requests.Response:
"""
Retrieve documents from the Find app API.
Args:
data (dict): search data
token (str): OICD token
Returns:
dict: A JSON-serializable dictionary.
"""
try:
response = requests.post(
self.search_url,
json=data,
headers={"Authorization": f"Bearer {token}"},
timeout=10,
)
response.raise_for_status()
return response.json()
except requests.exceptions.HTTPError as e:
logger.error("HTTPError: %s", e)
raise
def push(self, data):
"""
Push a batch of documents to the Find backend.
Args:
data (list): List of document dictionaries.
"""
try:
response = requests.post(
self.indexer_url,
json=data,
headers={"Authorization": f"Bearer {self.indexer_secret}"},
timeout=10,
)
response.raise_for_status()
except requests.exceptions.HTTPError as e:
logger.error("HTTPError: %s", e)
raise

View File

@@ -0,0 +1,31 @@
"""
Declare and configure the signals for the impress core application
"""
from functools import partial
from django.db import transaction
from django.db.models import signals
from django.dispatch import receiver
from . import models
from .tasks.find import trigger_document_indexer
@receiver(signals.post_save, sender=models.Document)
def document_post_save(sender, instance, **kwargs): # pylint: disable=unused-argument
"""
Asynchronous call to the document indexer at the end of the transaction.
Note : Within the transaction we can have an empty content and a serialization
error.
"""
transaction.on_commit(partial(trigger_document_indexer, instance))
@receiver(signals.post_save, sender=models.DocumentAccess)
def document_access_post_save(sender, instance, created, **kwargs): # pylint: disable=unused-argument
"""
Asynchronous call to the document indexer at the end of the transaction.
"""
if not created:
transaction.on_commit(partial(trigger_document_indexer, instance.document))

View File

@@ -0,0 +1,89 @@
"""Trigger document indexation using celery task."""
from logging import getLogger
from django.conf import settings
from django.core.cache import cache
from impress.celery_app import app
logger = getLogger(__file__)
def indexer_debounce_lock(document_id):
"""Increase or reset counter"""
key = f"doc-indexer-debounce-{document_id}"
try:
return cache.incr(key)
except ValueError:
cache.set(key, 1)
return 1
def indexer_debounce_release(document_id):
"""Decrease or reset counter"""
key = f"doc-indexer-debounce-{document_id}"
try:
return cache.decr(key)
except ValueError:
cache.set(key, 0)
return 0
@app.task
def document_indexer_task(document_id):
"""Celery Task : Sends indexation query for a document."""
# Prevents some circular imports
# pylint: disable=import-outside-toplevel
from core import models # noqa : PLC0415
from core.services.search_indexers import ( # noqa : PLC0415
get_batch_accesses_by_users_and_teams,
get_document_indexer,
)
# check if the counter : if still up, skip the task. only the last one
# within the countdown delay will do the query.
if indexer_debounce_release(document_id) > 0:
logger.info("Skip document %s indexation", document_id)
return
indexer = get_document_indexer()
if indexer is None:
return
doc = models.Document.objects.get(pk=document_id)
accesses = get_batch_accesses_by_users_and_teams((doc.path,))
data = indexer.serialize_document(document=doc, accesses=accesses)
logger.info("Start document %s indexation", document_id)
indexer.push(data)
def trigger_document_indexer(document):
"""
Trigger indexation task with debounce a delay set by the SEARCH_INDEXER_COUNTDOWN setting.
Args:
document (Document): The document instance.
"""
countdown = settings.SEARCH_INDEXER_COUNTDOWN
# DO NOT create a task if indexation if disabled
if not settings.SEARCH_INDEXER_CLASS:
return
logger.info(
"Add task for document %s indexation in %.2f seconds",
document.pk,
countdown,
)
# Each time this method is called during the countdown, we increment the
# counter and each task decrease it, so the index be run only once.
indexer_debounce_lock(document.pk)
document_indexer_task.apply_async(args=[document.pk], countdown=countdown)

View File

@@ -0,0 +1,65 @@
"""
Unit test for `index` command.
"""
from operator import itemgetter
from unittest import mock
from django.core.management import CommandError, call_command
from django.db import transaction
import pytest
from core import factories
from core.services.search_indexers import FindDocumentIndexer
@pytest.mark.django_db
@pytest.mark.usefixtures("indexer_settings")
def test_index():
"""Test the command `index` that run the Find app indexer for all the available documents."""
user = factories.UserFactory()
indexer = FindDocumentIndexer()
with transaction.atomic():
doc = factories.DocumentFactory()
empty_doc = factories.DocumentFactory(title=None, content="")
no_title_doc = factories.DocumentFactory(title=None)
factories.UserDocumentAccessFactory(document=doc, user=user)
factories.UserDocumentAccessFactory(document=empty_doc, user=user)
factories.UserDocumentAccessFactory(document=no_title_doc, user=user)
accesses = {
str(doc.path): {"users": [user.sub]},
str(empty_doc.path): {"users": [user.sub]},
str(no_title_doc.path): {"users": [user.sub]},
}
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
call_command("index")
push_call_args = [call.args[0] for call in mock_push.call_args_list]
# called once but with a batch of docs
mock_push.assert_called_once()
assert sorted(push_call_args[0], key=itemgetter("id")) == sorted(
[
indexer.serialize_document(doc, accesses),
indexer.serialize_document(no_title_doc, accesses),
],
key=itemgetter("id"),
)
@pytest.mark.django_db
@pytest.mark.usefixtures("indexer_settings")
def test_index_improperly_configured(indexer_settings):
"""The command should raise an exception if the indexer is not configured"""
indexer_settings.SEARCH_INDEXER_CLASS = None
with pytest.raises(CommandError) as err:
call_command("index")
assert str(err.value) == "The indexer is not enabled or properly configured."

View File

@@ -24,3 +24,29 @@ def mock_user_teams():
"core.models.User.teams", new_callable=mock.PropertyMock
) as mock_teams:
yield mock_teams
@pytest.fixture(name="indexer_settings")
def indexer_settings_fixture(settings):
"""
Setup valid settings for the document indexer. Clear the indexer cache.
"""
# pylint: disable-next=import-outside-toplevel
from core.services.search_indexers import ( # noqa: PLC0415
get_document_indexer,
)
get_document_indexer.cache_clear()
settings.SEARCH_INDEXER_CLASS = "core.services.search_indexers.FindDocumentIndexer"
settings.SEARCH_INDEXER_SECRET = "ThisIsAKeyForTest"
settings.SEARCH_INDEXER_URL = "http://localhost:8081/api/v1.0/documents/index/"
settings.SEARCH_INDEXER_QUERY_URL = (
"http://localhost:8081/api/v1.0/documents/search/"
)
yield settings
# clear cache to prevent issues with other tests
get_document_indexer.cache_clear()

View File

@@ -98,7 +98,7 @@ def test_api_documents_children_list_anonymous_public_parent(django_assert_num_q
with django_assert_num_queries(9):
APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/")
with django_assert_num_queries(4):
with django_assert_num_queries(5):
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/")
assert response.status_code == 200
@@ -187,7 +187,7 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1)
with django_assert_num_queries(11):
with django_assert_num_queries(9):
client.get(f"/api/v1.0/documents/{document.id!s}/children/")
with django_assert_num_queries(5):
response = client.get(
@@ -267,10 +267,10 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1)
with django_assert_num_queries(17):
with django_assert_num_queries(10):
client.get(f"/api/v1.0/documents/{document.id!s}/children/")
with django_assert_num_queries(5):
with django_assert_num_queries(6):
response = client.get(f"/api/v1.0/documents/{document.id!s}/children/")
assert response.status_code == 200
@@ -373,7 +373,7 @@ def test_api_documents_children_list_authenticated_related_direct(
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1)
with django_assert_num_queries(11):
with django_assert_num_queries(9):
response = client.get(
f"/api/v1.0/documents/{document.id!s}/children/",
)
@@ -456,7 +456,7 @@ def test_api_documents_children_list_authenticated_related_parent(
document=grand_parent, user=user
)
with django_assert_num_queries(17):
with django_assert_num_queries(10):
response = client.get(
f"/api/v1.0/documents/{document.id!s}/children/",
)
@@ -591,7 +591,7 @@ def test_api_documents_children_list_authenticated_related_team_members(
access = factories.TeamDocumentAccessFactory(document=document, team="myteam")
with django_assert_num_queries(11):
with django_assert_num_queries(9):
response = client.get(f"/api/v1.0/documents/{document.id!s}/children/")
# pylint: disable=R0801

View File

@@ -293,3 +293,28 @@ def test_api_documents_duplicate_non_root_document(role):
assert duplicated_accesses.count() == 0
assert duplicated_document.is_sibling_of(child)
assert duplicated_document.is_child_of(document)
def test_api_documents_duplicate_reader_non_root_document():
"""
Reader users should be able to duplicate non-root documents but will be
created as a root document.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "reader")])
child = factories.DocumentFactory(parent=document)
assert child.get_role(user) == "reader"
response = client.post(
f"/api/v1.0/documents/{child.id!s}/duplicate/", format="json"
)
assert response.status_code == 201
duplicated_document = models.Document.objects.get(id=response.json()["id"])
assert duplicated_document.is_root()
assert duplicated_document.accesses.count() == 1
assert duplicated_document.accesses.get(user=user).role == "owner"

View File

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

View File

@@ -152,7 +152,7 @@ def test_api_documents_list_authenticated_direct(django_assert_num_queries):
str(child4_with_access.id),
}
with django_assert_num_queries(17):
with django_assert_num_queries(14):
response = client.get("/api/v1.0/documents/")
# nb_accesses should now be cached
@@ -272,7 +272,7 @@ def test_api_documents_list_authenticated_link_reach_public_or_authenticated(
expected_ids = {str(document1.id), str(document2.id), str(visible_child.id)}
with django_assert_num_queries(13):
with django_assert_num_queries(11):
response = client.get("/api/v1.0/documents/")
# nb_accesses should now be cached

View File

@@ -305,7 +305,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
"cors_proxy": True,
"content": True,
"destroy": False,
"duplicate": grand_parent.link_role == "editor",
"duplicate": True,
"favorite": True,
"invite_owner": False,
"link_configuration": False,
@@ -500,7 +500,7 @@ def test_api_documents_retrieve_authenticated_related_parent():
"cors_proxy": True,
"content": True,
"destroy": access.role in ["administrator", "owner"],
"duplicate": access.role != "reader",
"duplicate": True,
"favorite": True,
"invite_owner": access.role == "owner",
"link_configuration": access.role in ["administrator", "owner"],
@@ -855,7 +855,7 @@ def test_api_documents_retrieve_user_role(django_assert_max_num_queries):
)
expected_role = choices.RoleChoices.max(*[access.role for access in accesses])
with django_assert_max_num_queries(16):
with django_assert_max_num_queries(14):
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 200

View File

@@ -0,0 +1,234 @@
"""
Tests for Documents API endpoint in impress's core app: list
"""
from json import loads as json_loads
import pytest
import responses
from faker import Faker
from rest_framework.test import APIClient
from core import factories, models
from core.services.search_indexers import get_document_indexer
fake = Faker()
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
@responses.activate
def test_api_documents_search_anonymous(reach, role, indexer_settings):
"""
Anonymous users should not be allowed to search documents whatever the
link reach and link role
"""
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
factories.DocumentFactory(link_reach=reach, link_role=role)
# Find response
responses.add(
responses.POST,
"http://find/api/v1.0/search",
json=[],
status=200,
)
response = APIClient().get("/api/v1.0/documents/search/", data={"q": "alpha"})
assert response.status_code == 200
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
def test_api_documents_search_endpoint_is_none(indexer_settings):
"""
Missing SEARCH_INDEXER_QUERY_URL, so the indexer is not properly configured.
Should fallback on title filter
"""
indexer_settings.SEARCH_INDEXER_QUERY_URL = None
assert get_document_indexer() is None
user = factories.UserFactory()
document = factories.DocumentFactory(title="alpha")
access = factories.UserDocumentAccessFactory(document=document, user=user)
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/documents/search/", data={"q": "alpha"})
assert response.status_code == 200
content = response.json()
results = content.pop("results")
assert content == {
"count": 1,
"next": None,
"previous": None,
}
assert len(results) == 1
assert results[0] == {
"id": str(document.id),
"abilities": document.get_abilities(user),
"ancestors_link_reach": None,
"ancestors_link_role": None,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 1,
"excerpt": document.excerpt,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 1,
"numchild": 0,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": access.role,
}
@responses.activate
def test_api_documents_search_invalid_params(indexer_settings):
"""Validate the format of documents as returned by the search view."""
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/documents/search/")
assert response.status_code == 400
assert response.json() == {"q": ["This field is required."]}
response = client.get("/api/v1.0/documents/search/", data={"q": " "})
assert response.status_code == 400
assert response.json() == {"q": ["This field may not be blank."]}
response = client.get(
"/api/v1.0/documents/search/", data={"q": "any", "page": "NaN"}
)
assert response.status_code == 400
assert response.json() == {"page": ["A valid integer is required."]}
@responses.activate
def test_api_documents_search_format(indexer_settings):
"""Validate the format of documents as returned by the search view."""
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
assert get_document_indexer() is not None
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
user_a, user_b, user_c = factories.UserFactory.create_batch(3)
document = factories.DocumentFactory(
title="alpha",
users=(user_a, user_c),
link_traces=(user, user_b),
)
access = factories.UserDocumentAccessFactory(document=document, user=user)
# Find response
responses.add(
responses.POST,
"http://find/api/v1.0/search",
json=[
{"_id": str(document.pk)},
],
status=200,
)
response = client.get("/api/v1.0/documents/search/", data={"q": "alpha"})
assert response.status_code == 200
content = response.json()
results = content.pop("results")
assert content == {
"count": 1,
"next": None,
"previous": None,
}
assert len(results) == 1
assert results[0] == {
"id": str(document.id),
"abilities": document.get_abilities(user),
"ancestors_link_reach": None,
"ancestors_link_role": None,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 1,
"excerpt": document.excerpt,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses_ancestors": 3,
"nb_accesses_direct": 3,
"numchild": 0,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": access.role,
}
@responses.activate
def test_api_documents_search_pagination(indexer_settings):
"""Documents should be ordered by descending "updated_at" by default"""
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
assert get_document_indexer() is not None
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
docs = factories.DocumentFactory.create_batch(10)
# Find response
# pylint: disable-next=assignment-from-none
api_search = responses.add(
responses.POST,
"http://find/api/v1.0/search",
json=[{"_id": str(doc.pk)} for doc in docs],
status=200,
)
response = client.get(
"/api/v1.0/documents/search/", data={"q": "alpha", "page": 2, "page_size": 5}
)
assert response.status_code == 200
content = response.json()
results = content.pop("results")
assert len(results) == 5
# Check the query parameters.
assert api_search.call_count == 1
assert api_search.calls[0].response.status_code == 200
assert json_loads(api_search.calls[0].request.body) == {
"q": "alpha",
"visited": [],
"services": ["docs"],
"page_number": 2,
"page_size": 5,
"order_by": "updated_at",
"order_direction": "desc",
}

View File

@@ -165,7 +165,7 @@ def test_api_documents_trashbin_authenticated_direct(django_assert_num_queries):
expected_ids = {str(document1.id), str(document2.id), str(document3.id)}
with django_assert_num_queries(14):
with django_assert_num_queries(10):
response = client.get("/api/v1.0/documents/trashbin/")
with django_assert_num_queries(4):

View File

@@ -377,7 +377,7 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
document, sibling = factories.DocumentFactory.create_batch(2, parent=parent)
child = factories.DocumentFactory(link_reach="public", parent=document)
with django_assert_num_queries(16):
with django_assert_num_queries(13):
client.get(f"/api/v1.0/documents/{document.id!s}/tree/")
with django_assert_num_queries(5):

View File

@@ -194,18 +194,41 @@ def test_api_users_list_query_short_queries():
factories.UserFactory(email="john.lennon@example.com")
response = client.get("/api/v1.0/users/?q=jo")
assert response.status_code == 200
assert response.json() == []
assert response.status_code == 400
assert response.json() == {
"q": ["Ensure this value has at least 5 characters (it has 2)."]
}
response = client.get("/api/v1.0/users/?q=john")
assert response.status_code == 200
assert response.json() == []
assert response.status_code == 400
assert response.json() == {
"q": ["Ensure this value has at least 5 characters (it has 4)."]
}
response = client.get("/api/v1.0/users/?q=john.")
assert response.status_code == 200
assert len(response.json()) == 2
def test_api_users_list_query_long_queries():
"""
Queries longer than 255 characters should return an empty result set.
"""
user = factories.UserFactory(email="paul@example.com")
client = APIClient()
client.force_login(user)
factories.UserFactory(email="john.doe@example.com")
factories.UserFactory(email="john.lennon@example.com")
query = "a" * 244
response = client.get(f"/api/v1.0/users/?q={query}@example.com")
assert response.status_code == 400
assert response.json() == {
"q": ["Ensure this value has at most 254 characters (it has 256)."]
}
def test_api_users_list_query_inactive():
"""Inactive users should not be listed."""
user = factories.UserFactory()

View File

@@ -6,6 +6,7 @@ Unit tests for the Document model
import random
import smtplib
from logging import Logger
from operator import itemgetter
from unittest import mock
from django.contrib.auth.models import AnonymousUser
@@ -13,12 +14,14 @@ from django.core import mail
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.db import transaction
from django.test.utils import override_settings
from django.utils import timezone
import pytest
from core import factories, models
from core.services.search_indexers import FindDocumentIndexer
pytestmark = pytest.mark.django_db
@@ -318,10 +321,6 @@ def test_models_documents_get_abilities_editor(
nb_queries = 1 if is_authenticated else 0
with django_assert_num_queries(nb_queries):
assert document.get_abilities(user) == expected_abilities
with django_assert_num_queries(0):
assert document.get_abilities(user) == expected_abilities
document.soft_delete()
document.refresh_from_db()
assert all(
@@ -685,98 +684,6 @@ def test_models_documents_get_abilities_children_destroy( # noqa: PLR0913
assert abilities["destroy"] is can_destroy
def test_models_documents_get_abilities_cache(django_assert_num_queries):
"""Abilities should be computed once and cached."""
user = factories.UserFactory()
parent = factories.DocumentFactory(link_reach="restricted", link_role="editor")
document = factories.DocumentFactory(
link_reach="restricted",
link_role="editor",
parent=parent,
)
access = factories.UserDocumentAccessFactory(
document=parent, user=user, role="editor"
)
# 1 for fetching the parent, 2 for computed the roles (parent and document)
with django_assert_num_queries(
3
): # 1 for fetching the parent, 2 for computed the roles (parent and document)
document.get_abilities(user)
with django_assert_num_queries(0):
document.get_abilities(user)
# modifying the access should invalidate the cache
access.role = "reader"
access.save()
# 2 for computed the roles (parent and document), the parent is still in document's cache
with django_assert_num_queries(2):
document.get_abilities(user)
with django_assert_num_queries(0):
document.get_abilities(user)
# because the parent abilities have alreadby been comptued when
# document abilities were computed, no query is done
with django_assert_num_queries(0):
parent.get_abilities(user)
# deleting the access should invalidate the cache
access.delete()
with django_assert_num_queries(2):
document.get_abilities(user)
with django_assert_num_queries(0):
document.get_abilities(user)
# Modifying the document link_reach should invalidate the cache
document.link_reach = "public"
document.save()
# 1 for computed the roles (document), parent abilities are still in cache
with django_assert_num_queries(1):
document.get_abilities(user)
with django_assert_num_queries(0):
document.get_abilities(user)
parent.link_reach = "public"
parent.save()
# 2 for computed the roles (parent and document)
with django_assert_num_queries(2):
document.get_abilities(user)
with django_assert_num_queries(0):
document.get_abilities(user)
# Modifying the parent link_role should invalidate the cache
document.link_role = "reader"
document.save()
# 1 for computed the roles (document), parent abilities are still in cache
with django_assert_num_queries(1):
document.get_abilities(user)
with django_assert_num_queries(0):
document.get_abilities(user)
parent.link_role = "reader"
parent.save()
# 2 for computed the roles (parent and document)
with django_assert_num_queries(2):
document.get_abilities(user)
with django_assert_num_queries(0):
document.get_abilities(user)
# Modifying any other property should not invalidate the cache
document.title = "new title"
document.save()
with django_assert_num_queries(0):
document.get_abilities(user)
@override_settings(AI_ALLOW_REACH_FROM="public")
@pytest.mark.parametrize(
"is_authenticated,reach",
@@ -1507,3 +1414,285 @@ def test_models_documents_compute_ancestors_links_paths_mapping_structure(
{"link_reach": sibling.link_reach, "link_role": sibling.link_role},
],
}
@mock.patch.object(FindDocumentIndexer, "push")
@pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer(mock_push, indexer_settings):
"""Test indexation task on document creation"""
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
with transaction.atomic():
doc1, doc2, doc3 = factories.DocumentFactory.create_batch(3)
accesses = {}
data = [call.args[0] for call in mock_push.call_args_list]
indexer = FindDocumentIndexer()
assert sorted(data, key=itemgetter("id")) == sorted(
[
indexer.serialize_document(doc1, accesses),
indexer.serialize_document(doc2, accesses),
indexer.serialize_document(doc3, accesses),
],
key=itemgetter("id"),
)
# The debounce counters should be reset
assert cache.get(f"doc-indexer-debounce-{doc1.pk}") == 0
assert cache.get(f"doc-indexer-debounce-{doc2.pk}") == 0
assert cache.get(f"doc-indexer-debounce-{doc3.pk}") == 0
@mock.patch.object(FindDocumentIndexer, "push")
@pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer_not_configured(mock_push, indexer_settings):
"""Task should not start an indexation when disabled"""
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
indexer_settings.SEARCH_INDEXER_CLASS = None
with transaction.atomic():
factories.DocumentFactory()
assert mock_push.call_args_list == []
@mock.patch.object(FindDocumentIndexer, "push")
@pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer_with_accesses(mock_push, indexer_settings):
"""Test indexation task on document creation"""
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
user = factories.UserFactory()
with transaction.atomic():
doc1, doc2, doc3 = factories.DocumentFactory.create_batch(3)
factories.UserDocumentAccessFactory(document=doc1, user=user)
factories.UserDocumentAccessFactory(document=doc2, user=user)
factories.UserDocumentAccessFactory(document=doc3, user=user)
accesses = {
str(doc1.path): {"users": [user.sub]},
str(doc2.path): {"users": [user.sub]},
str(doc3.path): {"users": [user.sub]},
}
data = [call.args[0] for call in mock_push.call_args_list]
indexer = FindDocumentIndexer()
assert sorted(data, key=itemgetter("id")) == sorted(
[
indexer.serialize_document(doc1, accesses),
indexer.serialize_document(doc2, accesses),
indexer.serialize_document(doc3, accesses),
],
key=itemgetter("id"),
)
# The debounce counters should be reset
assert cache.get(f"doc-indexer-debounce-{doc1.pk}") == 0
assert cache.get(f"doc-indexer-debounce-{doc2.pk}") == 0
assert cache.get(f"doc-indexer-debounce-{doc3.pk}") == 0
@mock.patch.object(FindDocumentIndexer, "push")
@pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer_deleted(mock_push, indexer_settings):
"""Indexation task on deleted or ancestor_deleted documents"""
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
user = factories.UserFactory()
with transaction.atomic():
doc = factories.DocumentFactory(
link_reach=models.LinkReachChoices.AUTHENTICATED
)
doc_deleted = factories.DocumentFactory(
link_reach=models.LinkReachChoices.AUTHENTICATED
)
doc_ancestor_deleted = factories.DocumentFactory(
parent=doc_deleted,
link_reach=models.LinkReachChoices.AUTHENTICATED,
)
doc_deleted.soft_delete()
doc_ancestor_deleted.ancestors_deleted_at = doc_deleted.deleted_at
factories.UserDocumentAccessFactory(document=doc, user=user)
factories.UserDocumentAccessFactory(document=doc_deleted, user=user)
factories.UserDocumentAccessFactory(document=doc_ancestor_deleted, user=user)
doc_deleted.refresh_from_db()
doc_ancestor_deleted.refresh_from_db()
assert doc_deleted.deleted_at is not None
assert doc_deleted.ancestors_deleted_at is not None
assert doc_ancestor_deleted.deleted_at is None
assert doc_ancestor_deleted.ancestors_deleted_at is not None
accesses = {
str(doc.path): {"users": [user.sub]},
str(doc_deleted.path): {"users": [user.sub]},
str(doc_ancestor_deleted.path): {"users": [user.sub]},
}
data = [call.args[0] for call in mock_push.call_args_list]
indexer = FindDocumentIndexer()
# Even deleted document are re-indexed : only update their status in the future ?
assert sorted(data, key=itemgetter("id")) == sorted(
[
indexer.serialize_document(doc, accesses),
indexer.serialize_document(doc_deleted, accesses),
indexer.serialize_document(doc_ancestor_deleted, accesses),
indexer.serialize_document(doc_deleted, accesses), # soft_delete()
],
key=itemgetter("id"),
)
# The debounce counters should be reset
assert cache.get(f"doc-indexer-debounce-{doc.pk}") == 0
assert cache.get(f"doc-indexer-debounce-{doc_deleted.pk}") == 0
assert cache.get(f"doc-indexer-debounce-{doc_ancestor_deleted.pk}") == 0
@mock.patch.object(FindDocumentIndexer, "push")
@pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer_restored(mock_push, indexer_settings):
"""Restart indexation task on restored documents"""
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
user = factories.UserFactory()
with transaction.atomic():
doc = factories.DocumentFactory(
link_reach=models.LinkReachChoices.AUTHENTICATED
)
doc_deleted = factories.DocumentFactory(
link_reach=models.LinkReachChoices.AUTHENTICATED
)
doc_ancestor_deleted = factories.DocumentFactory(
parent=doc_deleted,
link_reach=models.LinkReachChoices.AUTHENTICATED,
)
doc_deleted.soft_delete()
doc_ancestor_deleted.ancestors_deleted_at = doc_deleted.deleted_at
factories.UserDocumentAccessFactory(document=doc, user=user)
factories.UserDocumentAccessFactory(document=doc_deleted, user=user)
factories.UserDocumentAccessFactory(document=doc_ancestor_deleted, user=user)
doc_deleted.refresh_from_db()
doc_ancestor_deleted.refresh_from_db()
assert doc_deleted.deleted_at is not None
assert doc_deleted.ancestors_deleted_at is not None
assert doc_ancestor_deleted.deleted_at is None
assert doc_ancestor_deleted.ancestors_deleted_at is not None
doc_restored = models.Document.objects.get(pk=doc_deleted.pk)
doc_restored.restore()
doc_ancestor_restored = models.Document.objects.get(pk=doc_ancestor_deleted.pk)
assert doc_restored.deleted_at is None
assert doc_restored.ancestors_deleted_at is None
assert doc_ancestor_restored.deleted_at is None
assert doc_ancestor_restored.ancestors_deleted_at is None
accesses = {
str(doc.path): {"users": [user.sub]},
str(doc_deleted.path): {"users": [user.sub]},
str(doc_ancestor_deleted.path): {"users": [user.sub]},
}
data = [call.args[0] for call in mock_push.call_args_list]
indexer = FindDocumentIndexer()
# All docs are re-indexed
assert sorted(data, key=itemgetter("id")) == sorted(
[
indexer.serialize_document(doc, accesses),
indexer.serialize_document(doc_deleted, accesses),
indexer.serialize_document(doc_deleted, accesses), # soft_delete()
indexer.serialize_document(doc_restored, accesses), # restore()
indexer.serialize_document(doc_ancestor_deleted, accesses),
],
key=itemgetter("id"),
)
@pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer_debounce(indexer_settings):
"""Test indexation task skipping on document update"""
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
indexer = FindDocumentIndexer()
user = factories.UserFactory()
with mock.patch.object(FindDocumentIndexer, "push"):
with transaction.atomic():
doc = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=doc, user=user)
accesses = {
str(doc.path): {"users": [user.sub]},
}
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
# Simulate 1 waiting task
cache.set(f"doc-indexer-debounce-{doc.pk}", 1)
# save doc to trigger the indexer, but nothing should be done since
# the counter is over 0
with transaction.atomic():
doc.save()
assert [call.args[0] for call in mock_push.call_args_list] == []
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
# No waiting task
cache.set(f"doc-indexer-debounce-{doc.pk}", 0)
with transaction.atomic():
doc = models.Document.objects.get(pk=doc.pk)
doc.save()
assert [call.args[0] for call in mock_push.call_args_list] == [
indexer.serialize_document(doc, accesses),
]
@pytest.mark.django_db(transaction=True)
def test_models_documents_access_post_save_indexer(indexer_settings):
"""Test indexation task on DocumentAccess update"""
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
indexer = FindDocumentIndexer()
user = factories.UserFactory()
with mock.patch.object(FindDocumentIndexer, "push"):
with transaction.atomic():
doc = factories.DocumentFactory()
doc_access = factories.UserDocumentAccessFactory(document=doc, user=user)
accesses = {
str(doc.path): {"users": [user.sub]},
}
indexer = FindDocumentIndexer()
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
with transaction.atomic():
doc_access.save()
assert [call.args[0] for call in mock_push.call_args_list] == [
indexer.serialize_document(doc, accesses),
]

View File

@@ -0,0 +1,540 @@
"""Tests for Documents search indexers"""
from functools import partial
from json import dumps as json_dumps
from unittest.mock import patch
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ImproperlyConfigured
from django.utils.module_loading import import_string
import pytest
import responses
from requests import HTTPError
from core import factories, models, utils
from core.services.search_indexers import (
BaseDocumentIndexer,
FindDocumentIndexer,
get_document_indexer,
get_visited_document_ids_of,
)
pytestmark = pytest.mark.django_db
class FakeDocumentIndexer(BaseDocumentIndexer):
"""Fake indexer for test purpose"""
def serialize_document(self, document, accesses):
return {}
def push(self, data):
pass
def search_query(self, data, token):
return {}
def test_services_search_indexer_class_invalid(indexer_settings):
"""
Should raise RuntimeError if SEARCH_INDEXER_CLASS cannot be imported.
"""
indexer_settings.SEARCH_INDEXER_CLASS = "unknown.Unknown"
assert get_document_indexer() is None
def test_services_search_indexer_class(indexer_settings):
"""
Import indexer class defined in setting SEARCH_INDEXER_CLASS.
"""
indexer_settings.SEARCH_INDEXER_CLASS = (
"core.tests.test_services_search_indexers.FakeDocumentIndexer"
)
assert isinstance(
get_document_indexer(),
import_string("core.tests.test_services_search_indexers.FakeDocumentIndexer"),
)
def test_services_search_indexer_is_configured(indexer_settings):
"""
Should return true only when the indexer class and other configuration settings
are valid.
"""
indexer_settings.SEARCH_INDEXER_CLASS = None
# None
get_document_indexer.cache_clear()
assert not get_document_indexer()
# Empty
indexer_settings.SEARCH_INDEXER_CLASS = ""
get_document_indexer.cache_clear()
assert not get_document_indexer()
# Valid class
indexer_settings.SEARCH_INDEXER_CLASS = (
"core.services.search_indexers.FindDocumentIndexer"
)
get_document_indexer.cache_clear()
assert get_document_indexer() is not None
indexer_settings.SEARCH_INDEXER_URL = ""
# Invalid url
get_document_indexer.cache_clear()
assert not get_document_indexer()
def test_services_search_indexer_url_is_none(indexer_settings):
"""
Indexer should raise RuntimeError if SEARCH_INDEXER_URL is None or empty.
"""
indexer_settings.SEARCH_INDEXER_URL = None
with pytest.raises(ImproperlyConfigured) as exc_info:
FindDocumentIndexer()
assert "SEARCH_INDEXER_URL must be set in Django settings." in str(exc_info.value)
def test_services_search_indexer_url_is_empty(indexer_settings):
"""
Indexer should raise RuntimeError if SEARCH_INDEXER_URL is empty string.
"""
indexer_settings.SEARCH_INDEXER_URL = ""
with pytest.raises(ImproperlyConfigured) as exc_info:
FindDocumentIndexer()
assert "SEARCH_INDEXER_URL must be set in Django settings." in str(exc_info.value)
def test_services_search_indexer_secret_is_none(indexer_settings):
"""
Indexer should raise RuntimeError if SEARCH_INDEXER_SECRET is None.
"""
indexer_settings.SEARCH_INDEXER_SECRET = None
with pytest.raises(ImproperlyConfigured) as exc_info:
FindDocumentIndexer()
assert "SEARCH_INDEXER_SECRET must be set in Django settings." in str(
exc_info.value
)
def test_services_search_indexer_secret_is_empty(indexer_settings):
"""
Indexer should raise RuntimeError if SEARCH_INDEXER_SECRET is empty string.
"""
indexer_settings.SEARCH_INDEXER_SECRET = ""
with pytest.raises(ImproperlyConfigured) as exc_info:
FindDocumentIndexer()
assert "SEARCH_INDEXER_SECRET must be set in Django settings." in str(
exc_info.value
)
def test_services_search_endpoint_is_none(indexer_settings):
"""
Indexer should raise RuntimeError if SEARCH_INDEXER_QUERY_URL is None.
"""
indexer_settings.SEARCH_INDEXER_QUERY_URL = None
with pytest.raises(ImproperlyConfigured) as exc_info:
FindDocumentIndexer()
assert "SEARCH_INDEXER_QUERY_URL must be set in Django settings." in str(
exc_info.value
)
def test_services_search_endpoint_is_empty(indexer_settings):
"""
Indexer should raise RuntimeError if SEARCH_INDEXER_QUERY_URL is empty.
"""
indexer_settings.SEARCH_INDEXER_QUERY_URL = ""
with pytest.raises(ImproperlyConfigured) as exc_info:
FindDocumentIndexer()
assert "SEARCH_INDEXER_QUERY_URL must be set in Django settings." in str(
exc_info.value
)
@pytest.mark.usefixtures("indexer_settings")
def test_services_search_indexers_serialize_document_returns_expected_json():
"""
It should serialize documents with correct metadata and access control.
"""
user_a, user_b = factories.UserFactory.create_batch(2)
document = factories.DocumentFactory()
factories.DocumentFactory(parent=document)
factories.UserDocumentAccessFactory(document=document, user=user_a)
factories.UserDocumentAccessFactory(document=document, user=user_b)
factories.TeamDocumentAccessFactory(document=document, team="team1")
factories.TeamDocumentAccessFactory(document=document, team="team2")
accesses = {
document.path: {
"users": {str(user_a.sub), str(user_b.sub)},
"teams": {"team1", "team2"},
}
}
indexer = FindDocumentIndexer()
result = indexer.serialize_document(document, accesses)
assert set(result.pop("users")) == {str(user_a.sub), str(user_b.sub)}
assert set(result.pop("groups")) == {"team1", "team2"}
assert result == {
"id": str(document.id),
"title": document.title,
"depth": 1,
"path": document.path,
"numchild": 1,
"content": utils.base64_yjs_to_text(document.content),
"created_at": document.created_at.isoformat(),
"updated_at": document.updated_at.isoformat(),
"reach": document.link_reach,
"size": 13,
"is_active": True,
}
@pytest.mark.usefixtures("indexer_settings")
def test_services_search_indexers_serialize_document_deleted():
"""Deleted documents are marked as just in the serialized json."""
parent = factories.DocumentFactory()
document = factories.DocumentFactory(parent=parent)
parent.soft_delete()
document.refresh_from_db()
indexer = FindDocumentIndexer()
result = indexer.serialize_document(document, {})
assert result["is_active"] is False
@pytest.mark.usefixtures("indexer_settings")
def test_services_search_indexers_serialize_document_empty():
"""Empty documents returns empty content in the serialized json."""
document = factories.DocumentFactory(content="", title=None)
indexer = FindDocumentIndexer()
result = indexer.serialize_document(document, {})
assert result["content"] == ""
assert result["title"] == ""
@responses.activate
def test_services_search_indexers_index_errors(indexer_settings):
"""
Documents indexing response handling on Find API HTTP errors.
"""
factories.DocumentFactory()
indexer_settings.SEARCH_INDEXER_URL = "http://app-find/api/v1.0/documents/index/"
responses.add(
responses.POST,
"http://app-find/api/v1.0/documents/index/",
status=401,
body=json_dumps({"message": "Authentication failed."}),
)
with pytest.raises(HTTPError):
FindDocumentIndexer().index()
@patch.object(FindDocumentIndexer, "push")
def test_services_search_indexers_batches_pass_only_batch_accesses(
mock_push, indexer_settings
):
"""
Documents indexing should be processed in batches,
and only the access data relevant to each batch should be used.
"""
indexer_settings.SEARCH_INDEXER_BATCH_SIZE = 2
documents = factories.DocumentFactory.create_batch(5)
# Attach a single user access to each document
expected_user_subs = {}
for document in documents:
access = factories.UserDocumentAccessFactory(document=document)
expected_user_subs[str(document.id)] = str(access.user.sub)
assert FindDocumentIndexer().index() == 5
# Should be 3 batches: 2 + 2 + 1
assert mock_push.call_count == 3
seen_doc_ids = set()
for call in mock_push.call_args_list:
batch = call.args[0]
assert isinstance(batch, list)
for doc_json in batch:
doc_id = doc_json["id"]
seen_doc_ids.add(doc_id)
# Only one user expected per document
assert doc_json["users"] == [expected_user_subs[doc_id]]
assert doc_json["groups"] == []
# Make sure all 5 documents were indexed
assert seen_doc_ids == {str(d.id) for d in documents}
@patch.object(FindDocumentIndexer, "push")
@pytest.mark.usefixtures("indexer_settings")
def test_services_search_indexers_ignore_empty_documents(mock_push):
"""
Documents indexing should be processed in batches,
and only the access data relevant to each batch should be used.
"""
document = factories.DocumentFactory()
factories.DocumentFactory(content="", title="")
empty_title = factories.DocumentFactory(title="")
empty_content = factories.DocumentFactory(content="")
assert FindDocumentIndexer().index() == 3
assert mock_push.call_count == 1
# Make sure only not eempty documents are indexed
results = {doc["id"] for doc in mock_push.call_args[0][0]}
assert results == {
str(d.id)
for d in (
document,
empty_content,
empty_title,
)
}
@patch.object(FindDocumentIndexer, "push")
@pytest.mark.usefixtures("indexer_settings")
def test_services_search_indexers_ancestors_link_reach(mock_push):
"""Document accesses and reach should take into account ancestors link reaches."""
great_grand_parent = factories.DocumentFactory(link_reach="restricted")
grand_parent = factories.DocumentFactory(
parent=great_grand_parent, link_reach="authenticated"
)
parent = factories.DocumentFactory(parent=grand_parent, link_reach="public")
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
assert FindDocumentIndexer().index() == 4
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
assert len(results) == 4
assert results[str(great_grand_parent.id)]["reach"] == "restricted"
assert results[str(grand_parent.id)]["reach"] == "authenticated"
assert results[str(parent.id)]["reach"] == "public"
assert results[str(document.id)]["reach"] == "public"
@patch.object(FindDocumentIndexer, "push")
@pytest.mark.usefixtures("indexer_settings")
def test_services_search_indexers_ancestors_users(mock_push):
"""Document accesses and reach should include users from ancestors."""
user_gp, user_p, user_d = factories.UserFactory.create_batch(3)
grand_parent = factories.DocumentFactory(users=[user_gp])
parent = factories.DocumentFactory(parent=grand_parent, users=[user_p])
document = factories.DocumentFactory(parent=parent, users=[user_d])
assert FindDocumentIndexer().index() == 3
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
assert len(results) == 3
assert results[str(grand_parent.id)]["users"] == [str(user_gp.sub)]
assert set(results[str(parent.id)]["users"]) == {str(user_gp.sub), str(user_p.sub)}
assert set(results[str(document.id)]["users"]) == {
str(user_gp.sub),
str(user_p.sub),
str(user_d.sub),
}
@patch.object(FindDocumentIndexer, "push")
@pytest.mark.usefixtures("indexer_settings")
def test_services_search_indexers_ancestors_teams(mock_push):
"""Document accesses and reach should include teams from ancestors."""
grand_parent = factories.DocumentFactory(teams=["team_gp"])
parent = factories.DocumentFactory(parent=grand_parent, teams=["team_p"])
document = factories.DocumentFactory(parent=parent, teams=["team_d"])
assert FindDocumentIndexer().index() == 3
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
assert len(results) == 3
assert results[str(grand_parent.id)]["groups"] == ["team_gp"]
assert set(results[str(parent.id)]["groups"]) == {"team_gp", "team_p"}
assert set(results[str(document.id)]["groups"]) == {"team_gp", "team_p", "team_d"}
@patch("requests.post")
def test_push_uses_correct_url_and_data(mock_post, indexer_settings):
"""
push() should call requests.post with the correct URL from settings
the timeout set to 10 seconds and the data as JSON.
"""
indexer_settings.SEARCH_INDEXER_URL = "http://example.com/index"
indexer = FindDocumentIndexer()
sample_data = [{"id": "123", "title": "Test"}]
mock_response = mock_post.return_value
mock_response.raise_for_status.return_value = None # No error
indexer.push(sample_data)
mock_post.assert_called_once()
args, kwargs = mock_post.call_args
assert args[0] == indexer_settings.SEARCH_INDEXER_URL
assert kwargs.get("json") == sample_data
assert kwargs.get("timeout") == 10
def test_get_visited_document_ids_of():
"""
get_visited_document_ids_of() returns the ids of the documents viewed
by the user BUT without specific access configuration (like public ones)
"""
user = factories.UserFactory()
other = factories.UserFactory()
anonymous = AnonymousUser()
queryset = models.Document.objects.all()
assert not get_visited_document_ids_of(queryset, anonymous)
assert not get_visited_document_ids_of(queryset, user)
doc1, doc2, _ = factories.DocumentFactory.create_batch(3)
create_link = partial(models.LinkTrace.objects.create, user=user, is_masked=False)
create_link(document=doc1)
create_link(document=doc2)
# The third document is not visited
assert sorted(get_visited_document_ids_of(queryset, user)) == sorted(
[str(doc1.pk), str(doc2.pk)]
)
factories.UserDocumentAccessFactory(user=other, document=doc1)
factories.UserDocumentAccessFactory(user=user, document=doc2)
# The second document have an access for the user
assert get_visited_document_ids_of(queryset, user) == [str(doc1.pk)]
@pytest.mark.usefixtures("indexer_settings")
def test_get_visited_document_ids_of_deleted():
"""
get_visited_document_ids_of() returns the ids of the documents viewed
by the user if they are not deleted.
"""
user = factories.UserFactory()
anonymous = AnonymousUser()
queryset = models.Document.objects.all()
assert not get_visited_document_ids_of(queryset, anonymous)
assert not get_visited_document_ids_of(queryset, user)
doc = factories.DocumentFactory()
doc_deleted = factories.DocumentFactory()
doc_ancestor_deleted = factories.DocumentFactory(parent=doc_deleted)
create_link = partial(models.LinkTrace.objects.create, user=user, is_masked=False)
create_link(document=doc)
create_link(document=doc_deleted)
create_link(document=doc_ancestor_deleted)
# The all documents are visited
assert sorted(get_visited_document_ids_of(queryset, user)) == sorted(
[str(doc.pk), str(doc_deleted.pk), str(doc_ancestor_deleted.pk)]
)
doc_deleted.soft_delete()
# Only the first document is not deleted
assert get_visited_document_ids_of(queryset, user) == [str(doc.pk)]
@responses.activate
def test_services_search_indexers_search_errors(indexer_settings):
"""
Documents indexing response handling on Find API HTTP errors.
"""
factories.DocumentFactory()
indexer_settings.SEARCH_INDEXER_QUERY_URL = (
"http://app-find/api/v1.0/documents/search/"
)
responses.add(
responses.POST,
"http://app-find/api/v1.0/documents/search/",
status=401,
body=json_dumps({"message": "Authentication failed."}),
)
with pytest.raises(HTTPError):
FindDocumentIndexer().search("alpha", token="mytoken")
@patch("requests.post")
def test_services_search_indexers_search(mock_post, indexer_settings):
"""
search() should call requests.post to SEARCH_INDEXER_QUERY_URL with the
document ids from linktraces.
"""
user = factories.UserFactory()
indexer = FindDocumentIndexer()
mock_response = mock_post.return_value
mock_response.raise_for_status.return_value = None # No error
doc1, doc2, _ = factories.DocumentFactory.create_batch(3)
create_link = partial(models.LinkTrace.objects.create, user=user, is_masked=False)
create_link(document=doc1)
create_link(document=doc2)
visited = get_visited_document_ids_of(models.Document.objects.all(), user)
indexer.search("alpha", visited=visited, token="mytoken")
args, kwargs = mock_post.call_args
assert args[0] == indexer_settings.SEARCH_INDEXER_QUERY_URL
query_data = kwargs.get("json")
assert query_data["q"] == "alpha"
assert sorted(query_data["visited"]) == sorted([str(doc1.pk), str(doc2.pk)])
assert query_data["services"] == ["docs"]
assert query_data["page_number"] == 1
assert query_data["page_size"] == 50
assert query_data["order_by"] == "updated_at"
assert query_data["order_direction"] == "desc"
assert kwargs.get("headers") == {"Authorization": "Bearer mytoken"}
assert kwargs.get("timeout") == 10

View File

@@ -75,3 +75,28 @@ def test_utils_extract_attachments():
base64_string = base64.b64encode(update).decode("utf-8")
# image_key2 is missing the "/media/" part and shouldn't get extracted
assert utils.extract_attachments(base64_string) == [image_key1, image_key3]
def test_utils_get_ancestor_to_descendants_map_single_path():
"""Test ancestor mapping of a single path."""
paths = ["000100020005"]
result = utils.get_ancestor_to_descendants_map(paths, steplen=4)
assert result == {
"0001": {"000100020005"},
"00010002": {"000100020005"},
"000100020005": {"000100020005"},
}
def test_utils_get_ancestor_to_descendants_map_multiple_paths():
"""Test ancestor mapping of multiple paths with shared prefixes."""
paths = ["000100020005", "00010003"]
result = utils.get_ancestor_to_descendants_map(paths, steplen=4)
assert result == {
"0001": {"000100020005", "00010003"},
"00010002": {"000100020005"},
"000100020005": {"000100020005"},
"00010003": {"00010003"},
}

View File

@@ -2,6 +2,7 @@
import base64
import re
from collections import defaultdict
import pycrdt
from bs4 import BeautifulSoup
@@ -9,6 +10,27 @@ from bs4 import BeautifulSoup
from core import enums
def get_ancestor_to_descendants_map(paths, steplen):
"""
Given a list of document paths, return a mapping of ancestor_path -> set of descendant_paths.
Each path is assumed to use materialized path format with fixed-length segments.
Args:
paths (list of str): List of full document paths.
steplen (int): Length of each path segment.
Returns:
dict[str, set[str]]: Mapping from ancestor path to its descendant paths (including itself).
"""
ancestor_map = defaultdict(set)
for path in paths:
for i in range(steplen, len(path) + 1, steplen):
ancestor = path[:i]
ancestor_map[ancestor].add(path)
return ancestor_map
def filter_descendants(paths, root_paths, skip_sorting=False):
"""
Filters paths to keep only those that are descendants of any path in root_paths.

View File

@@ -8,11 +8,19 @@ NB_OBJECTS = {
DEV_USERS = [
{"username": "impress", "email": "impress@impress.world", "language": "en-us"},
{"username": "user-e2e-webkit", "email": "user@webkit.test", "language": "en-us"},
{"username": "user-e2e-firefox", "email": "user@firefox.test", "language": "en-us"},
{
"username": "user-e2e-webkit",
"email": "user.test@webkit.test",
"language": "en-us",
},
{
"username": "user-e2e-firefox",
"email": "user.test@firefox.test",
"language": "en-us",
},
{
"username": "user-e2e-chromium",
"email": "user@chromium.test",
"email": "user.test@chromium.test",
"language": "en-us",
},
]

View File

@@ -1,16 +1,19 @@
# ruff: noqa: S311, S106
"""create_demo management command"""
import base64
import logging
import math
import random
import time
from collections import defaultdict
from uuid import uuid4
from django import db
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
import pycrdt
from faker import Faker
from core import models
@@ -27,6 +30,16 @@ def random_true_with_probability(probability):
return random.random() < probability
def get_ydoc_for_text(text):
"""Return a ydoc from plain text for demo purposes."""
ydoc = pycrdt.Doc()
paragraph = pycrdt.XmlElement("p", {}, [pycrdt.XmlText(text)])
fragment = pycrdt.XmlFragment([paragraph])
ydoc["document-store"] = fragment
update = ydoc.get_update()
return base64.b64encode(update).decode("utf-8")
class BulkQueue:
"""A utility class to create Django model instances in bulk by just pushing to a queue."""
@@ -48,7 +61,7 @@ class BulkQueue:
self.queue[objects[0]._meta.model.__name__] = [] # noqa: SLF001
def push(self, obj):
"""Add a model instance to queue to that it gets created in bulk."""
"""Add a model instance to queue so that it gets created in bulk."""
objects = self.queue[obj._meta.model.__name__] # noqa: SLF001
objects.append(obj)
if len(objects) > self.BATCH_SIZE:
@@ -119,8 +132,8 @@ def create_demo(stdout):
first_name = random.choice(first_names)
queue.push(
models.User(
admin_email=f"user{i:d}@example.com",
email=f"user{i:d}@example.com",
admin_email=f"user.test{i:d}@example.com",
email=f"user.test{i:d}@example.com",
password="!",
is_superuser=False,
is_active=True,
@@ -139,17 +152,19 @@ def create_demo(stdout):
# pylint: disable=protected-access
key = models.Document._int2str(i) # noqa: SLF001
padding = models.Document.alphabet[0] * (models.Document.steplen - len(key))
queue.push(
models.Document(
depth=1,
path=f"{padding}{key}",
creator_id=random.choice(users_ids),
title=fake.sentence(nb_words=4),
link_reach=models.LinkReachChoices.AUTHENTICATED
if random_true_with_probability(0.5)
else random.choice(models.LinkReachChoices.values),
)
title = fake.sentence(nb_words=4)
document = models.Document(
id=uuid4(),
depth=1,
path=f"{padding}{key}",
creator_id=random.choice(users_ids),
title=title,
link_reach=models.LinkReachChoices.AUTHENTICATED
if random_true_with_probability(0.5)
else random.choice(models.LinkReachChoices.values),
)
document.save_content(get_ydoc_for_text(f"Content for {title:s}"))
queue.push(document)
queue.flush()

View File

@@ -33,9 +33,9 @@ def test_commands_create_demo():
# assert dev users have doc accesses
user = models.User.objects.get(email="impress@impress.world")
assert models.DocumentAccess.objects.filter(user=user).exists()
user = models.User.objects.get(email="user@webkit.test")
user = models.User.objects.get(email="user.test@webkit.test")
assert models.DocumentAccess.objects.filter(user=user).exists()
user = models.User.objects.get(email="user@firefox.test")
user = models.User.objects.get(email="user.test@firefox.test")
assert models.DocumentAccess.objects.filter(user=user).exists()
user = models.User.objects.get(email="user@chromium.test")
user = models.User.objects.get(email="user.test@chromium.test")
assert models.DocumentAccess.objects.filter(user=user).exists()

View File

@@ -18,6 +18,7 @@ from django.utils.translation import gettext_lazy as _
import sentry_sdk
from configurations import Configuration, values
from cryptography.fernet import Fernet
from csp.constants import NONE
from lasuite.configuration.values import SecretFileValue
from sentry_sdk.integrations.django import DjangoIntegration
@@ -99,6 +100,28 @@ class Base(Configuration):
}
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
# Search
SEARCH_INDEXER_CLASS = values.Value(
default=None,
environ_name="SEARCH_INDEXER_CLASS",
environ_prefix=None,
)
SEARCH_INDEXER_BATCH_SIZE = values.IntegerValue(
default=100_000, environ_name="SEARCH_INDEXER_BATCH_SIZE", environ_prefix=None
)
SEARCH_INDEXER_URL = values.Value(
default=None, environ_name="SEARCH_INDEXER_URL", environ_prefix=None
)
SEARCH_INDEXER_COUNTDOWN = values.IntegerValue(
default=1, environ_name="SEARCH_INDEXER_COUNTDOWN", environ_prefix=None
)
SEARCH_INDEXER_SECRET = values.Value(
default=None, environ_name="SEARCH_INDEXER_SECRET", environ_prefix=None
)
SEARCH_INDEXER_QUERY_URL = values.Value(
default=None, environ_name="SEARCH_INDEXER_QUERY_URL", environ_prefix=None
)
# Static files (CSS, JavaScript, Images)
STATIC_URL = "/static/"
STATIC_ROOT = os.path.join(DATA_DIR, "static")
@@ -142,7 +165,7 @@ class Base(Configuration):
)
# Document images
DOCUMENT_IMAGE_MAX_SIZE = values.Value(
DOCUMENT_IMAGE_MAX_SIZE = values.IntegerValue(
10 * (2**20), # 10MB
environ_name="DOCUMENT_IMAGE_MAX_SIZE",
environ_prefix=None,
@@ -808,12 +831,6 @@ class Base(Configuration):
),
}
DOCUMENT_ABILITIES_CACHE_TIMEOUT = values.IntegerValue(
default=60 * 60, # 1 hour
environ_name="ABILITIES_CACHE_TIMEOUT",
environ_prefix=None,
)
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):
@@ -928,6 +945,14 @@ class Development(Base):
},
}
# There is no key for token storage in default configuration.
# In development environment we can create one if needed.
OIDC_STORE_REFRESH_TOKEN_KEY = values.Value(
default=Fernet.generate_key().decode(),
environ_name="OIDC_STORE_REFRESH_TOKEN_KEY",
environ_prefix=None,
)
def __init__(self):
# pylint: disable=invalid-name
self.INSTALLED_APPS += ["django_extensions", "drf_spectacular_sidecar"]

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-09-01 21:01+0000\n"
"PO-Revision-Date: 2025-09-04 10:03\n"
"POT-Creation-Date: 2025-09-10 14:29+0000\n"
"PO-Revision-Date: 2025-09-12 09:50\n"
"Last-Translator: \n"
"Language-Team: Breton\n"
"Language: br_FR\n"
@@ -70,7 +70,7 @@ msgstr "Doare korf"
msgid "Format"
msgstr "Stumm"
#: build/lib/core/api/viewsets.py:960 core/api/viewsets.py:960
#: build/lib/core/api/viewsets.py:965 core/api/viewsets.py:965
#, python-brace-format
msgid "copy of {title}"
msgstr "eilenn {title}"
@@ -225,8 +225,8 @@ msgstr "implijer"
msgid "users"
msgstr "implijerien"
#: build/lib/core/models.py:359 build/lib/core/models.py:1280
#: core/models.py:359 core/models.py:1280
#: build/lib/core/models.py:359 build/lib/core/models.py:1281
#: core/models.py:359 core/models.py:1281
msgid "title"
msgstr "titl"
@@ -242,155 +242,155 @@ msgstr "Restr"
msgid "Documents"
msgstr "Restroù"
#: build/lib/core/models.py:422 build/lib/core/models.py:818 core/models.py:422
#: core/models.py:818
#: build/lib/core/models.py:422 build/lib/core/models.py:819 core/models.py:422
#: core/models.py:819
msgid "Untitled Document"
msgstr "Restr hep titl"
#: build/lib/core/models.py:853 core/models.py:853
#: build/lib/core/models.py:854 core/models.py:854
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} en deus rannet ur restr ganeoc'h!"
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:858 core/models.py:858
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} en deus pedet ac'hanoc'h gant ar rol \"{role}\" war ar restr da-heul:"
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:864 core/models.py:864
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} en deus rannet ur restr ganeoc'h: {title}"
#: build/lib/core/models.py:963 core/models.py:963
#: build/lib/core/models.py:964 core/models.py:964
msgid "Document/user link trace"
msgstr "Roud liamm ar restr/an implijer"
#: build/lib/core/models.py:964 core/models.py:964
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link traces"
msgstr "Roudoù liamm ar restr/an implijer"
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:971 core/models.py:971
msgid "A link trace already exists for this document/user."
msgstr "Ur roud liamm a zo dija evit an restr/an implijer."
#: build/lib/core/models.py:993 core/models.py:993
#: build/lib/core/models.py:994 core/models.py:994
msgid "Document favorite"
msgstr "Restr muiañ-karet"
#: build/lib/core/models.py:994 core/models.py:994
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorites"
msgstr "Restroù muiañ-karet"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1001 core/models.py:1001
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Ar restr-mañ a zo ur restr muiañ karet gant an implijer-mañ."
#: build/lib/core/models.py:1022 core/models.py:1022
#: build/lib/core/models.py:1023 core/models.py:1023
msgid "Document/user relation"
msgstr "Liamm restr/implijer"
#: build/lib/core/models.py:1023 core/models.py:1023
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relations"
msgstr "Liammoù restr/implijer"
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1030 core/models.py:1030
msgid "This user is already in this document."
msgstr "An implijer-mañ a zo dija er restr-mañ."
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1036 core/models.py:1036
msgid "This team is already in this document."
msgstr "Ar skipailh-mañ a zo dija en restr-mañ."
#: build/lib/core/models.py:1041 build/lib/core/models.py:1366
#: core/models.py:1041 core/models.py:1366
#: build/lib/core/models.py:1042 build/lib/core/models.py:1367
#: core/models.py:1042 core/models.py:1367
msgid "Either user or team must be set, not both."
msgstr "An implijer pe ar skipailh a rank bezañ termenet, ket an daou avat."
#: build/lib/core/models.py:1187 core/models.py:1187
#: build/lib/core/models.py:1188 core/models.py:1188
msgid "Document ask for access"
msgstr "Goulenn tizhout ar restr"
#: build/lib/core/models.py:1188 core/models.py:1188
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for accesses"
msgstr "Goulennoù tizhout ar restr"
#: build/lib/core/models.py:1194 core/models.py:1194
#: build/lib/core/models.py:1195 core/models.py:1195
msgid "This user has already asked for access to this document."
msgstr "An implijer en deus goulennet tizhout ar restr-mañ."
#: build/lib/core/models.py:1259 core/models.py:1259
#: build/lib/core/models.py:1260 core/models.py:1260
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} en defe c'hoant da dizhout ar restr-mañ!"
#: build/lib/core/models.py:1263 core/models.py:1263
#: build/lib/core/models.py:1264 core/models.py:1264
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} en defe c'hoant da dizhout ar restr da-heul:"
#: build/lib/core/models.py:1269 core/models.py:1269
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} en defe c'hoant da dizhout ar restr: {title}"
#: build/lib/core/models.py:1281 core/models.py:1281
#: build/lib/core/models.py:1282 core/models.py:1282
msgid "description"
msgstr "deskrivadur"
#: build/lib/core/models.py:1282 core/models.py:1282
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "code"
msgstr "kod"
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "public"
msgstr "publik"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1288 core/models.py:1288
msgid "Whether this template is public for anyone to use."
msgstr "M'eo foran ar patrom-mañ hag implijus gant n'eus forzh piv."
#: build/lib/core/models.py:1293 core/models.py:1293
#: build/lib/core/models.py:1294 core/models.py:1294
msgid "Template"
msgstr "Patrom"
#: build/lib/core/models.py:1294 core/models.py:1294
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Templates"
msgstr "Patromoù"
#: build/lib/core/models.py:1347 core/models.py:1347
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Template/user relation"
msgstr "Liamm patrom/implijer"
#: build/lib/core/models.py:1348 core/models.py:1348
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relations"
msgstr "Liammoù patrom/implijer"
#: build/lib/core/models.py:1354 core/models.py:1354
#: build/lib/core/models.py:1355 core/models.py:1355
msgid "This user is already in this template."
msgstr "An implijer-mañ a zo dija er patrom-mañ."
#: build/lib/core/models.py:1360 core/models.py:1360
#: build/lib/core/models.py:1361 core/models.py:1361
msgid "This team is already in this template."
msgstr "Ar skipailh-mañ a zo dija er patrom-mañ."
#: build/lib/core/models.py:1437 core/models.py:1437
#: build/lib/core/models.py:1438 core/models.py:1438
msgid "email address"
msgstr "postel"
#: build/lib/core/models.py:1456 core/models.py:1456
#: build/lib/core/models.py:1457 core/models.py:1457
msgid "Document invitation"
msgstr "Pedadenn d'ur restr"
#: build/lib/core/models.py:1457 core/models.py:1457
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitations"
msgstr "Pedadennoù d'ur restr"
#: build/lib/core/models.py:1477 core/models.py:1477
#: build/lib/core/models.py:1478 core/models.py:1478
msgid "This email is already associated to a registered user."
msgstr "Ar postel-mañ a zo liammet ouzh un implijer enskrivet."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-09-01 21:01+0000\n"
"PO-Revision-Date: 2025-09-04 10:03\n"
"POT-Creation-Date: 2025-09-10 14:29+0000\n"
"PO-Revision-Date: 2025-09-12 09:50\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de_DE\n"
@@ -70,7 +70,7 @@ msgstr "Typ"
msgid "Format"
msgstr "Format"
#: build/lib/core/api/viewsets.py:960 core/api/viewsets.py:960
#: build/lib/core/api/viewsets.py:965 core/api/viewsets.py:965
#, python-brace-format
msgid "copy of {title}"
msgstr "Kopie von {title}"
@@ -225,8 +225,8 @@ msgstr "Benutzer"
msgid "users"
msgstr "Benutzer"
#: build/lib/core/models.py:359 build/lib/core/models.py:1280
#: core/models.py:359 core/models.py:1280
#: build/lib/core/models.py:359 build/lib/core/models.py:1281
#: core/models.py:359 core/models.py:1281
msgid "title"
msgstr "Titel"
@@ -242,155 +242,155 @@ msgstr "Dokument"
msgid "Documents"
msgstr "Dokumente"
#: build/lib/core/models.py:422 build/lib/core/models.py:818 core/models.py:422
#: core/models.py:818
#: build/lib/core/models.py:422 build/lib/core/models.py:819 core/models.py:422
#: core/models.py:819
msgid "Untitled Document"
msgstr "Unbenanntes Dokument"
#: build/lib/core/models.py:853 core/models.py:853
#: build/lib/core/models.py:854 core/models.py:854
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} hat ein Dokument mit Ihnen geteilt!"
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:858 core/models.py:858
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} hat Sie mit der Rolle \"{role}\" zu folgendem Dokument eingeladen:"
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:864 core/models.py:864
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} hat ein Dokument mit Ihnen geteilt: {title}"
#: build/lib/core/models.py:963 core/models.py:963
#: build/lib/core/models.py:964 core/models.py:964
msgid "Document/user link trace"
msgstr "Dokument/Benutzer Linkverfolgung"
#: build/lib/core/models.py:964 core/models.py:964
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link traces"
msgstr "Dokument/Benutzer Linkverfolgung"
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:971 core/models.py:971
msgid "A link trace already exists for this document/user."
msgstr "Für dieses Dokument/ diesen Benutzer ist bereits eine Linkverfolgung vorhanden."
#: build/lib/core/models.py:993 core/models.py:993
#: build/lib/core/models.py:994 core/models.py:994
msgid "Document favorite"
msgstr "Dokumentenfavorit"
#: build/lib/core/models.py:994 core/models.py:994
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorites"
msgstr "Dokumentfavoriten"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1001 core/models.py:1001
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Dieses Dokument ist bereits durch den gleichen Benutzer favorisiert worden."
#: build/lib/core/models.py:1022 core/models.py:1022
#: build/lib/core/models.py:1023 core/models.py:1023
msgid "Document/user relation"
msgstr "Dokument/Benutzerbeziehung"
#: build/lib/core/models.py:1023 core/models.py:1023
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relations"
msgstr "Dokument/Benutzerbeziehungen"
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1030 core/models.py:1030
msgid "This user is already in this document."
msgstr "Dieser Benutzer befindet sich bereits in diesem Dokument."
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1036 core/models.py:1036
msgid "This team is already in this document."
msgstr "Dieses Team befindet sich bereits in diesem Dokument."
#: build/lib/core/models.py:1041 build/lib/core/models.py:1366
#: core/models.py:1041 core/models.py:1366
#: build/lib/core/models.py:1042 build/lib/core/models.py:1367
#: core/models.py:1042 core/models.py:1367
msgid "Either user or team must be set, not both."
msgstr "Benutzer oder Team müssen gesetzt werden, nicht beides."
#: build/lib/core/models.py:1187 core/models.py:1187
#: build/lib/core/models.py:1188 core/models.py:1188
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1188 core/models.py:1188
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1194 core/models.py:1194
#: build/lib/core/models.py:1195 core/models.py:1195
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1259 core/models.py:1259
#: build/lib/core/models.py:1260 core/models.py:1260
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1263 core/models.py:1263
#: build/lib/core/models.py:1264 core/models.py:1264
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1269 core/models.py:1269
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1281 core/models.py:1281
#: build/lib/core/models.py:1282 core/models.py:1282
msgid "description"
msgstr "Beschreibung"
#: build/lib/core/models.py:1282 core/models.py:1282
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "code"
msgstr "Code"
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "css"
msgstr "CSS"
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "public"
msgstr "öffentlich"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1288 core/models.py:1288
msgid "Whether this template is public for anyone to use."
msgstr "Ob diese Vorlage für jedermann öffentlich ist."
#: build/lib/core/models.py:1293 core/models.py:1293
#: build/lib/core/models.py:1294 core/models.py:1294
msgid "Template"
msgstr "Vorlage"
#: build/lib/core/models.py:1294 core/models.py:1294
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Templates"
msgstr "Vorlagen"
#: build/lib/core/models.py:1347 core/models.py:1347
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Template/user relation"
msgstr "Vorlage/Benutzer-Beziehung"
#: build/lib/core/models.py:1348 core/models.py:1348
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relations"
msgstr "Vorlage/Benutzerbeziehungen"
#: build/lib/core/models.py:1354 core/models.py:1354
#: build/lib/core/models.py:1355 core/models.py:1355
msgid "This user is already in this template."
msgstr "Dieser Benutzer ist bereits in dieser Vorlage."
#: build/lib/core/models.py:1360 core/models.py:1360
#: build/lib/core/models.py:1361 core/models.py:1361
msgid "This team is already in this template."
msgstr "Dieses Team ist bereits in diesem Template."
#: build/lib/core/models.py:1437 core/models.py:1437
#: build/lib/core/models.py:1438 core/models.py:1438
msgid "email address"
msgstr "E-Mail-Adresse"
#: build/lib/core/models.py:1456 core/models.py:1456
#: build/lib/core/models.py:1457 core/models.py:1457
msgid "Document invitation"
msgstr "Einladung zum Dokument"
#: build/lib/core/models.py:1457 core/models.py:1457
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitations"
msgstr "Dokumenteinladungen"
#: build/lib/core/models.py:1477 core/models.py:1477
#: build/lib/core/models.py:1478 core/models.py:1478
msgid "This email is already associated to a registered user."
msgstr "Diese E-Mail ist bereits einem registrierten Benutzer zugeordnet."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-09-01 21:01+0000\n"
"PO-Revision-Date: 2025-09-04 10:03\n"
"POT-Creation-Date: 2025-09-10 14:29+0000\n"
"PO-Revision-Date: 2025-09-12 09:50\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
@@ -70,7 +70,7 @@ msgstr ""
msgid "Format"
msgstr ""
#: build/lib/core/api/viewsets.py:960 core/api/viewsets.py:960
#: build/lib/core/api/viewsets.py:965 core/api/viewsets.py:965
#, python-brace-format
msgid "copy of {title}"
msgstr ""
@@ -225,8 +225,8 @@ msgstr ""
msgid "users"
msgstr ""
#: build/lib/core/models.py:359 build/lib/core/models.py:1280
#: core/models.py:359 core/models.py:1280
#: build/lib/core/models.py:359 build/lib/core/models.py:1281
#: core/models.py:359 core/models.py:1281
msgid "title"
msgstr ""
@@ -242,155 +242,155 @@ msgstr ""
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:422 build/lib/core/models.py:818 core/models.py:422
#: core/models.py:818
#: build/lib/core/models.py:422 build/lib/core/models.py:819 core/models.py:422
#: core/models.py:819
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:853 core/models.py:853
#: build/lib/core/models.py:854 core/models.py:854
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:858 core/models.py:858
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:864 core/models.py:864
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:963 core/models.py:963
#: build/lib/core/models.py:964 core/models.py:964
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:964 core/models.py:964
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:971 core/models.py:971
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:993 core/models.py:993
#: build/lib/core/models.py:994 core/models.py:994
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:994 core/models.py:994
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1001 core/models.py:1001
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1022 core/models.py:1022
#: build/lib/core/models.py:1023 core/models.py:1023
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1023 core/models.py:1023
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1030 core/models.py:1030
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1036 core/models.py:1036
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1041 build/lib/core/models.py:1366
#: core/models.py:1041 core/models.py:1366
#: build/lib/core/models.py:1042 build/lib/core/models.py:1367
#: core/models.py:1042 core/models.py:1367
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1187 core/models.py:1187
#: build/lib/core/models.py:1188 core/models.py:1188
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1188 core/models.py:1188
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1194 core/models.py:1194
#: build/lib/core/models.py:1195 core/models.py:1195
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1259 core/models.py:1259
#: build/lib/core/models.py:1260 core/models.py:1260
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1263 core/models.py:1263
#: build/lib/core/models.py:1264 core/models.py:1264
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1269 core/models.py:1269
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1281 core/models.py:1281
#: build/lib/core/models.py:1282 core/models.py:1282
msgid "description"
msgstr ""
#: build/lib/core/models.py:1282 core/models.py:1282
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "code"
msgstr ""
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "css"
msgstr ""
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "public"
msgstr ""
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1288 core/models.py:1288
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1293 core/models.py:1293
#: build/lib/core/models.py:1294 core/models.py:1294
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1294 core/models.py:1294
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1347 core/models.py:1347
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1348 core/models.py:1348
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1354 core/models.py:1354
#: build/lib/core/models.py:1355 core/models.py:1355
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1360 core/models.py:1360
#: build/lib/core/models.py:1361 core/models.py:1361
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1437 core/models.py:1437
#: build/lib/core/models.py:1438 core/models.py:1438
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1456 core/models.py:1456
#: build/lib/core/models.py:1457 core/models.py:1457
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1457 core/models.py:1457
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1477 core/models.py:1477
#: build/lib/core/models.py:1478 core/models.py:1478
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-09-01 21:01+0000\n"
"PO-Revision-Date: 2025-09-04 10:03\n"
"POT-Creation-Date: 2025-09-10 14:29+0000\n"
"PO-Revision-Date: 2025-09-12 09:50\n"
"Last-Translator: \n"
"Language-Team: Spanish\n"
"Language: es_ES\n"
@@ -70,7 +70,7 @@ msgstr "Tipo de Cuerpo"
msgid "Format"
msgstr "Formato"
#: build/lib/core/api/viewsets.py:960 core/api/viewsets.py:960
#: build/lib/core/api/viewsets.py:965 core/api/viewsets.py:965
#, python-brace-format
msgid "copy of {title}"
msgstr "copia de {title}"
@@ -163,7 +163,7 @@ msgstr "sub (UUID)"
#: build/lib/core/models.py:142 core/models.py:142
msgid "Required. 255 characters or fewer. ASCII characters only."
msgstr ""
msgstr "Obligatorio. 255 caracteres o menos. Solo caracteres ASCII."
#: build/lib/core/models.py:150 core/models.py:150
msgid "full name"
@@ -225,8 +225,8 @@ msgstr "usuario"
msgid "users"
msgstr "usuarios"
#: build/lib/core/models.py:359 build/lib/core/models.py:1280
#: core/models.py:359 core/models.py:1280
#: build/lib/core/models.py:359 build/lib/core/models.py:1281
#: core/models.py:359 core/models.py:1281
msgid "title"
msgstr "título"
@@ -242,155 +242,155 @@ msgstr "Documento"
msgid "Documents"
msgstr "Documentos"
#: build/lib/core/models.py:422 build/lib/core/models.py:818 core/models.py:422
#: core/models.py:818
#: build/lib/core/models.py:422 build/lib/core/models.py:819 core/models.py:422
#: core/models.py:819
msgid "Untitled Document"
msgstr "Documento sin título"
#: build/lib/core/models.py:853 core/models.py:853
#: build/lib/core/models.py:854 core/models.py:854
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "¡{name} ha compartido un documento contigo!"
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:858 core/models.py:858
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "Te ha invitado {name} al siguiente documento con el rol \"{role}\" :"
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:864 core/models.py:864
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} ha compartido un documento contigo: {title}"
#: build/lib/core/models.py:963 core/models.py:963
#: build/lib/core/models.py:964 core/models.py:964
msgid "Document/user link trace"
msgstr "Traza del enlace de documento/usuario"
#: build/lib/core/models.py:964 core/models.py:964
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link traces"
msgstr "Trazas del enlace de documento/usuario"
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:971 core/models.py:971
msgid "A link trace already exists for this document/user."
msgstr "Ya existe una traza de enlace para este documento/usuario."
#: build/lib/core/models.py:993 core/models.py:993
#: build/lib/core/models.py:994 core/models.py:994
msgid "Document favorite"
msgstr "Documento favorito"
#: build/lib/core/models.py:994 core/models.py:994
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorites"
msgstr "Documentos favoritos"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1001 core/models.py:1001
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Este documento ya ha sido marcado como favorito por el usuario."
#: build/lib/core/models.py:1022 core/models.py:1022
#: build/lib/core/models.py:1023 core/models.py:1023
msgid "Document/user relation"
msgstr "Relación documento/usuario"
#: build/lib/core/models.py:1023 core/models.py:1023
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relations"
msgstr "Relaciones documento/usuario"
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1030 core/models.py:1030
msgid "This user is already in this document."
msgstr "Este usuario ya forma parte del documento."
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1036 core/models.py:1036
msgid "This team is already in this document."
msgstr "Este equipo ya forma parte del documento."
#: build/lib/core/models.py:1041 build/lib/core/models.py:1366
#: core/models.py:1041 core/models.py:1366
#: build/lib/core/models.py:1042 build/lib/core/models.py:1367
#: core/models.py:1042 core/models.py:1367
msgid "Either user or team must be set, not both."
msgstr "Debe establecerse un usuario o un equipo, no ambos."
#: build/lib/core/models.py:1187 core/models.py:1187
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1188 core/models.py:1188
msgid "Document ask for accesses"
msgstr ""
msgid "Document ask for access"
msgstr "Solicitud de acceso"
#: build/lib/core/models.py:1194 core/models.py:1194
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for accesses"
msgstr "Solicitud de accesos"
#: build/lib/core/models.py:1195 core/models.py:1195
msgid "This user has already asked for access to this document."
msgstr "Este usuario ya ha solicitado acceso a este documento."
#: build/lib/core/models.py:1259 core/models.py:1259
#: build/lib/core/models.py:1260 core/models.py:1260
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "¡{name} desea acceder a un documento!"
#: build/lib/core/models.py:1263 core/models.py:1263
#: build/lib/core/models.py:1264 core/models.py:1264
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
msgstr "{name} desea acceso al siguiente documento:"
#: build/lib/core/models.py:1269 core/models.py:1269
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
msgstr "{name} está pidiendo acceso al documento: {title}"
#: build/lib/core/models.py:1281 core/models.py:1281
#: build/lib/core/models.py:1282 core/models.py:1282
msgid "description"
msgstr "descripción"
#: build/lib/core/models.py:1282 core/models.py:1282
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "code"
msgstr "código"
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "public"
msgstr "público"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1288 core/models.py:1288
msgid "Whether this template is public for anyone to use."
msgstr "Si esta plantilla es pública para que cualquiera la utilice."
#: build/lib/core/models.py:1293 core/models.py:1293
#: build/lib/core/models.py:1294 core/models.py:1294
msgid "Template"
msgstr "Plantilla"
#: build/lib/core/models.py:1294 core/models.py:1294
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Templates"
msgstr "Plantillas"
#: build/lib/core/models.py:1347 core/models.py:1347
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Template/user relation"
msgstr "Relación plantilla/usuario"
#: build/lib/core/models.py:1348 core/models.py:1348
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relations"
msgstr "Relaciones plantilla/usuario"
#: build/lib/core/models.py:1354 core/models.py:1354
#: build/lib/core/models.py:1355 core/models.py:1355
msgid "This user is already in this template."
msgstr "Este usuario ya forma parte de la plantilla."
#: build/lib/core/models.py:1360 core/models.py:1360
#: build/lib/core/models.py:1361 core/models.py:1361
msgid "This team is already in this template."
msgstr "Este equipo ya se encuentra en esta plantilla."
#: build/lib/core/models.py:1437 core/models.py:1437
#: build/lib/core/models.py:1438 core/models.py:1438
msgid "email address"
msgstr "dirección de correo electrónico"
#: build/lib/core/models.py:1456 core/models.py:1456
#: build/lib/core/models.py:1457 core/models.py:1457
msgid "Document invitation"
msgstr "Invitación al documento"
#: build/lib/core/models.py:1457 core/models.py:1457
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitations"
msgstr "Invitaciones a documentos"
#: build/lib/core/models.py:1477 core/models.py:1477
#: build/lib/core/models.py:1478 core/models.py:1478
msgid "This email is already associated to a registered user."
msgstr "Este correo electrónico está asociado a un usuario registrado."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-09-01 21:01+0000\n"
"PO-Revision-Date: 2025-09-04 11:45\n"
"POT-Creation-Date: 2025-09-10 14:29+0000\n"
"PO-Revision-Date: 2025-09-12 09:50\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
@@ -70,7 +70,7 @@ msgstr "Type de corps"
msgid "Format"
msgstr "Format"
#: build/lib/core/api/viewsets.py:960 core/api/viewsets.py:960
#: build/lib/core/api/viewsets.py:965 core/api/viewsets.py:965
#, python-brace-format
msgid "copy of {title}"
msgstr "copie de {title}"
@@ -225,8 +225,8 @@ msgstr "utilisateur"
msgid "users"
msgstr "utilisateurs"
#: build/lib/core/models.py:359 build/lib/core/models.py:1280
#: core/models.py:359 core/models.py:1280
#: build/lib/core/models.py:359 build/lib/core/models.py:1281
#: core/models.py:359 core/models.py:1281
msgid "title"
msgstr "titre"
@@ -242,155 +242,155 @@ msgstr "Document"
msgid "Documents"
msgstr "Documents"
#: build/lib/core/models.py:422 build/lib/core/models.py:818 core/models.py:422
#: core/models.py:818
#: build/lib/core/models.py:422 build/lib/core/models.py:819 core/models.py:422
#: core/models.py:819
msgid "Untitled Document"
msgstr "Document sans titre"
#: build/lib/core/models.py:853 core/models.py:853
#: build/lib/core/models.py:854 core/models.py:854
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} a partagé un document avec vous!"
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:858 core/models.py:858
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} vous a invité avec le rôle \"{role}\" sur le document suivant :"
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:864 core/models.py:864
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} a partagé un document avec vous : {title}"
#: build/lib/core/models.py:963 core/models.py:963
#: build/lib/core/models.py:964 core/models.py:964
msgid "Document/user link trace"
msgstr "Trace du lien document/utilisateur"
#: build/lib/core/models.py:964 core/models.py:964
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link traces"
msgstr "Traces du lien document/utilisateur"
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:971 core/models.py:971
msgid "A link trace already exists for this document/user."
msgstr "Une trace de lien existe déjà pour ce document/utilisateur."
#: build/lib/core/models.py:993 core/models.py:993
#: build/lib/core/models.py:994 core/models.py:994
msgid "Document favorite"
msgstr "Document favori"
#: build/lib/core/models.py:994 core/models.py:994
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorites"
msgstr "Documents favoris"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1001 core/models.py:1001
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Ce document est déjà un favori de cet utilisateur."
#: build/lib/core/models.py:1022 core/models.py:1022
#: build/lib/core/models.py:1023 core/models.py:1023
msgid "Document/user relation"
msgstr "Relation document/utilisateur"
#: build/lib/core/models.py:1023 core/models.py:1023
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relations"
msgstr "Relations document/utilisateur"
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1030 core/models.py:1030
msgid "This user is already in this document."
msgstr "Cet utilisateur est déjà dans ce document."
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1036 core/models.py:1036
msgid "This team is already in this document."
msgstr "Cette équipe est déjà dans ce document."
#: build/lib/core/models.py:1041 build/lib/core/models.py:1366
#: core/models.py:1041 core/models.py:1366
#: build/lib/core/models.py:1042 build/lib/core/models.py:1367
#: core/models.py:1042 core/models.py:1367
msgid "Either user or team must be set, not both."
msgstr "L'utilisateur ou l'équipe doivent être définis, pas les deux."
#: build/lib/core/models.py:1187 core/models.py:1187
#: build/lib/core/models.py:1188 core/models.py:1188
msgid "Document ask for access"
msgstr "Demande d'accès au document"
#: build/lib/core/models.py:1188 core/models.py:1188
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for accesses"
msgstr "Demande d'accès au document"
#: build/lib/core/models.py:1194 core/models.py:1194
#: build/lib/core/models.py:1195 core/models.py:1195
msgid "This user has already asked for access to this document."
msgstr "Cet utilisateur a déjà demandé l'accès à ce document."
#: build/lib/core/models.py:1259 core/models.py:1259
#: build/lib/core/models.py:1260 core/models.py:1260
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} souhaiterait accéder au document suivant !"
#: build/lib/core/models.py:1263 core/models.py:1263
#: build/lib/core/models.py:1264 core/models.py:1264
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} souhaiterait accéder au document suivant :"
#: build/lib/core/models.py:1269 core/models.py:1269
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} demande l'accès au document : {title}"
#: build/lib/core/models.py:1281 core/models.py:1281
#: build/lib/core/models.py:1282 core/models.py:1282
msgid "description"
msgstr "description"
#: build/lib/core/models.py:1282 core/models.py:1282
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "code"
msgstr "code"
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "css"
msgstr "CSS"
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "public"
msgstr "public"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1288 core/models.py:1288
msgid "Whether this template is public for anyone to use."
msgstr "Si ce modèle est public, utilisable par n'importe qui."
#: build/lib/core/models.py:1293 core/models.py:1293
#: build/lib/core/models.py:1294 core/models.py:1294
msgid "Template"
msgstr "Modèle"
#: build/lib/core/models.py:1294 core/models.py:1294
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Templates"
msgstr "Modèles"
#: build/lib/core/models.py:1347 core/models.py:1347
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Template/user relation"
msgstr "Relation modèle/utilisateur"
#: build/lib/core/models.py:1348 core/models.py:1348
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relations"
msgstr "Relations modèle/utilisateur"
#: build/lib/core/models.py:1354 core/models.py:1354
#: build/lib/core/models.py:1355 core/models.py:1355
msgid "This user is already in this template."
msgstr "Cet utilisateur est déjà dans ce modèle."
#: build/lib/core/models.py:1360 core/models.py:1360
#: build/lib/core/models.py:1361 core/models.py:1361
msgid "This team is already in this template."
msgstr "Cette équipe est déjà modèle."
#: build/lib/core/models.py:1437 core/models.py:1437
#: build/lib/core/models.py:1438 core/models.py:1438
msgid "email address"
msgstr "adresse e-mail"
#: build/lib/core/models.py:1456 core/models.py:1456
#: build/lib/core/models.py:1457 core/models.py:1457
msgid "Document invitation"
msgstr "Invitation à un document"
#: build/lib/core/models.py:1457 core/models.py:1457
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitations"
msgstr "Invitations à un document"
#: build/lib/core/models.py:1477 core/models.py:1477
#: build/lib/core/models.py:1478 core/models.py:1478
msgid "This email is already associated to a registered user."
msgstr "Cette adresse email est déjà associée à un utilisateur inscrit."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-09-01 21:01+0000\n"
"PO-Revision-Date: 2025-09-04 10:03\n"
"POT-Creation-Date: 2025-09-10 14:29+0000\n"
"PO-Revision-Date: 2025-09-12 09:50\n"
"Last-Translator: \n"
"Language-Team: Italian\n"
"Language: it_IT\n"
@@ -70,7 +70,7 @@ msgstr ""
msgid "Format"
msgstr "Formato"
#: build/lib/core/api/viewsets.py:960 core/api/viewsets.py:960
#: build/lib/core/api/viewsets.py:965 core/api/viewsets.py:965
#, python-brace-format
msgid "copy of {title}"
msgstr "copia di {title}"
@@ -225,8 +225,8 @@ msgstr "utente"
msgid "users"
msgstr "utenti"
#: build/lib/core/models.py:359 build/lib/core/models.py:1280
#: core/models.py:359 core/models.py:1280
#: build/lib/core/models.py:359 build/lib/core/models.py:1281
#: core/models.py:359 core/models.py:1281
msgid "title"
msgstr "titolo"
@@ -242,155 +242,155 @@ msgstr "Documento"
msgid "Documents"
msgstr "Documenti"
#: build/lib/core/models.py:422 build/lib/core/models.py:818 core/models.py:422
#: core/models.py:818
#: build/lib/core/models.py:422 build/lib/core/models.py:819 core/models.py:422
#: core/models.py:819
msgid "Untitled Document"
msgstr "Documento senza titolo"
#: build/lib/core/models.py:853 core/models.py:853
#: build/lib/core/models.py:854 core/models.py:854
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} ha condiviso un documento con te!"
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:858 core/models.py:858
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} ti ha invitato con il ruolo \"{role}\" nel seguente documento:"
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:864 core/models.py:864
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} ha condiviso un documento con te: {title}"
#: build/lib/core/models.py:963 core/models.py:963
#: build/lib/core/models.py:964 core/models.py:964
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:964 core/models.py:964
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:971 core/models.py:971
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:993 core/models.py:993
#: build/lib/core/models.py:994 core/models.py:994
msgid "Document favorite"
msgstr "Documento preferito"
#: build/lib/core/models.py:994 core/models.py:994
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorites"
msgstr "Documenti preferiti"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1001 core/models.py:1001
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1022 core/models.py:1022
#: build/lib/core/models.py:1023 core/models.py:1023
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1023 core/models.py:1023
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1030 core/models.py:1030
msgid "This user is already in this document."
msgstr "Questo utente è già presente in questo documento."
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1036 core/models.py:1036
msgid "This team is already in this document."
msgstr "Questo team è già presente in questo documento."
#: build/lib/core/models.py:1041 build/lib/core/models.py:1366
#: core/models.py:1041 core/models.py:1366
#: build/lib/core/models.py:1042 build/lib/core/models.py:1367
#: core/models.py:1042 core/models.py:1367
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1187 core/models.py:1187
#: build/lib/core/models.py:1188 core/models.py:1188
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1188 core/models.py:1188
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1194 core/models.py:1194
#: build/lib/core/models.py:1195 core/models.py:1195
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1259 core/models.py:1259
#: build/lib/core/models.py:1260 core/models.py:1260
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1263 core/models.py:1263
#: build/lib/core/models.py:1264 core/models.py:1264
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1269 core/models.py:1269
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1281 core/models.py:1281
#: build/lib/core/models.py:1282 core/models.py:1282
msgid "description"
msgstr "descrizione"
#: build/lib/core/models.py:1282 core/models.py:1282
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "code"
msgstr "code"
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "public"
msgstr "pubblico"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1288 core/models.py:1288
msgid "Whether this template is public for anyone to use."
msgstr "Indica se questo modello è pubblico per chiunque."
#: build/lib/core/models.py:1293 core/models.py:1293
#: build/lib/core/models.py:1294 core/models.py:1294
msgid "Template"
msgstr "Modello"
#: build/lib/core/models.py:1294 core/models.py:1294
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Templates"
msgstr "Modelli"
#: build/lib/core/models.py:1347 core/models.py:1347
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1348 core/models.py:1348
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1354 core/models.py:1354
#: build/lib/core/models.py:1355 core/models.py:1355
msgid "This user is already in this template."
msgstr "Questo utente è già in questo modello."
#: build/lib/core/models.py:1360 core/models.py:1360
#: build/lib/core/models.py:1361 core/models.py:1361
msgid "This team is already in this template."
msgstr "Questo team è già in questo modello."
#: build/lib/core/models.py:1437 core/models.py:1437
#: build/lib/core/models.py:1438 core/models.py:1438
msgid "email address"
msgstr "indirizzo e-mail"
#: build/lib/core/models.py:1456 core/models.py:1456
#: build/lib/core/models.py:1457 core/models.py:1457
msgid "Document invitation"
msgstr "Invito al documento"
#: build/lib/core/models.py:1457 core/models.py:1457
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitations"
msgstr "Inviti al documento"
#: build/lib/core/models.py:1477 core/models.py:1477
#: build/lib/core/models.py:1478 core/models.py:1478
msgid "This email is already associated to a registered user."
msgstr "Questa email è già associata a un utente registrato."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-09-01 21:01+0000\n"
"PO-Revision-Date: 2025-09-04 10:03\n"
"POT-Creation-Date: 2025-09-10 14:29+0000\n"
"PO-Revision-Date: 2025-09-12 09:50\n"
"Last-Translator: \n"
"Language-Team: Dutch\n"
"Language: nl_NL\n"
@@ -70,7 +70,7 @@ msgstr "Text type"
msgid "Format"
msgstr "Formaat"
#: build/lib/core/api/viewsets.py:960 core/api/viewsets.py:960
#: build/lib/core/api/viewsets.py:965 core/api/viewsets.py:965
#, python-brace-format
msgid "copy of {title}"
msgstr "kopie van {title}"
@@ -225,8 +225,8 @@ msgstr "gebruiker"
msgid "users"
msgstr "gebruikers"
#: build/lib/core/models.py:359 build/lib/core/models.py:1280
#: core/models.py:359 core/models.py:1280
#: build/lib/core/models.py:359 build/lib/core/models.py:1281
#: core/models.py:359 core/models.py:1281
msgid "title"
msgstr "titel"
@@ -242,155 +242,155 @@ msgstr "Document"
msgid "Documents"
msgstr "Documenten"
#: build/lib/core/models.py:422 build/lib/core/models.py:818 core/models.py:422
#: core/models.py:818
#: build/lib/core/models.py:422 build/lib/core/models.py:819 core/models.py:422
#: core/models.py:819
msgid "Untitled Document"
msgstr "Naamloos Document"
#: build/lib/core/models.py:853 core/models.py:853
#: build/lib/core/models.py:854 core/models.py:854
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} heeft een document met gedeeld!"
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:858 core/models.py:858
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} heeft u uitgenodigd met de rol \"{role}\" op het volgende document:"
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:864 core/models.py:864
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} heeft een document met u gedeeld: {title}"
#: build/lib/core/models.py:963 core/models.py:963
#: build/lib/core/models.py:964 core/models.py:964
msgid "Document/user link trace"
msgstr "Document/gebruiker url"
#: build/lib/core/models.py:964 core/models.py:964
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link traces"
msgstr "Document/gebruiker url"
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:971 core/models.py:971
msgid "A link trace already exists for this document/user."
msgstr "Een url bestaat al voor dit document/deze gebruiker."
#: build/lib/core/models.py:993 core/models.py:993
#: build/lib/core/models.py:994 core/models.py:994
msgid "Document favorite"
msgstr "Document favoriet"
#: build/lib/core/models.py:994 core/models.py:994
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorites"
msgstr "Document favorieten"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1001 core/models.py:1001
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Dit document is al in gebruik als favoriete door dezelfde gebruiker."
#: build/lib/core/models.py:1022 core/models.py:1022
#: build/lib/core/models.py:1023 core/models.py:1023
msgid "Document/user relation"
msgstr "Document/gebruiker relatie"
#: build/lib/core/models.py:1023 core/models.py:1023
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relations"
msgstr "Document/gebruiker relaties"
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1030 core/models.py:1030
msgid "This user is already in this document."
msgstr "De gebruiker is al in dit document."
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1036 core/models.py:1036
msgid "This team is already in this document."
msgstr "Het team is al in dit document."
#: build/lib/core/models.py:1041 build/lib/core/models.py:1366
#: core/models.py:1041 core/models.py:1366
#: build/lib/core/models.py:1042 build/lib/core/models.py:1367
#: core/models.py:1042 core/models.py:1367
msgid "Either user or team must be set, not both."
msgstr "Een gebruiker of team moet gekozen worden, maar niet beide."
#: build/lib/core/models.py:1187 core/models.py:1187
#: build/lib/core/models.py:1188 core/models.py:1188
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1188 core/models.py:1188
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1194 core/models.py:1194
#: build/lib/core/models.py:1195 core/models.py:1195
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1259 core/models.py:1259
#: build/lib/core/models.py:1260 core/models.py:1260
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1263 core/models.py:1263
#: build/lib/core/models.py:1264 core/models.py:1264
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1269 core/models.py:1269
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1281 core/models.py:1281
#: build/lib/core/models.py:1282 core/models.py:1282
msgid "description"
msgstr "omschrijving"
#: build/lib/core/models.py:1282 core/models.py:1282
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "code"
msgstr "code"
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "public"
msgstr "publiek"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1288 core/models.py:1288
msgid "Whether this template is public for anyone to use."
msgstr "Of dit template als publiek is en door iedereen te gebruiken is."
#: build/lib/core/models.py:1293 core/models.py:1293
#: build/lib/core/models.py:1294 core/models.py:1294
msgid "Template"
msgstr "Template"
#: build/lib/core/models.py:1294 core/models.py:1294
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Templates"
msgstr "Templates"
#: build/lib/core/models.py:1347 core/models.py:1347
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Template/user relation"
msgstr "Template/gebruiker relatie"
#: build/lib/core/models.py:1348 core/models.py:1348
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relations"
msgstr "Template/gebruiker relaties"
#: build/lib/core/models.py:1354 core/models.py:1354
#: build/lib/core/models.py:1355 core/models.py:1355
msgid "This user is already in this template."
msgstr "De gebruiker bestaat al in dit template."
#: build/lib/core/models.py:1360 core/models.py:1360
#: build/lib/core/models.py:1361 core/models.py:1361
msgid "This team is already in this template."
msgstr "Het team bestaat al in dit template."
#: build/lib/core/models.py:1437 core/models.py:1437
#: build/lib/core/models.py:1438 core/models.py:1438
msgid "email address"
msgstr "email adres"
#: build/lib/core/models.py:1456 core/models.py:1456
#: build/lib/core/models.py:1457 core/models.py:1457
msgid "Document invitation"
msgstr "Document uitnodiging"
#: build/lib/core/models.py:1457 core/models.py:1457
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitations"
msgstr "Document uitnodigingen"
#: build/lib/core/models.py:1477 core/models.py:1477
#: build/lib/core/models.py:1478 core/models.py:1478
msgid "This email is already associated to a registered user."
msgstr "Deze email is al geassocieerd met een geregistreerde gebruiker."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-09-01 21:01+0000\n"
"PO-Revision-Date: 2025-09-04 10:03\n"
"POT-Creation-Date: 2025-09-10 14:29+0000\n"
"PO-Revision-Date: 2025-09-12 09:50\n"
"Last-Translator: \n"
"Language-Team: Portuguese\n"
"Language: pt_PT\n"
@@ -70,7 +70,7 @@ msgstr "Tipo de corpo"
msgid "Format"
msgstr "Formato"
#: build/lib/core/api/viewsets.py:960 core/api/viewsets.py:960
#: build/lib/core/api/viewsets.py:965 core/api/viewsets.py:965
#, python-brace-format
msgid "copy of {title}"
msgstr "cópia de {title}"
@@ -225,8 +225,8 @@ msgstr ""
msgid "users"
msgstr ""
#: build/lib/core/models.py:359 build/lib/core/models.py:1280
#: core/models.py:359 core/models.py:1280
#: build/lib/core/models.py:359 build/lib/core/models.py:1281
#: core/models.py:359 core/models.py:1281
msgid "title"
msgstr ""
@@ -242,155 +242,155 @@ msgstr ""
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:422 build/lib/core/models.py:818 core/models.py:422
#: core/models.py:818
#: build/lib/core/models.py:422 build/lib/core/models.py:819 core/models.py:422
#: core/models.py:819
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:853 core/models.py:853
#: build/lib/core/models.py:854 core/models.py:854
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:858 core/models.py:858
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:864 core/models.py:864
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:963 core/models.py:963
#: build/lib/core/models.py:964 core/models.py:964
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:964 core/models.py:964
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:971 core/models.py:971
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:993 core/models.py:993
#: build/lib/core/models.py:994 core/models.py:994
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:994 core/models.py:994
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1001 core/models.py:1001
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1022 core/models.py:1022
#: build/lib/core/models.py:1023 core/models.py:1023
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1023 core/models.py:1023
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1030 core/models.py:1030
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1036 core/models.py:1036
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1041 build/lib/core/models.py:1366
#: core/models.py:1041 core/models.py:1366
#: build/lib/core/models.py:1042 build/lib/core/models.py:1367
#: core/models.py:1042 core/models.py:1367
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1187 core/models.py:1187
#: build/lib/core/models.py:1188 core/models.py:1188
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1188 core/models.py:1188
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1194 core/models.py:1194
#: build/lib/core/models.py:1195 core/models.py:1195
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1259 core/models.py:1259
#: build/lib/core/models.py:1260 core/models.py:1260
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1263 core/models.py:1263
#: build/lib/core/models.py:1264 core/models.py:1264
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1269 core/models.py:1269
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1281 core/models.py:1281
#: build/lib/core/models.py:1282 core/models.py:1282
msgid "description"
msgstr ""
#: build/lib/core/models.py:1282 core/models.py:1282
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "code"
msgstr ""
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "css"
msgstr ""
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "public"
msgstr ""
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1288 core/models.py:1288
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1293 core/models.py:1293
#: build/lib/core/models.py:1294 core/models.py:1294
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1294 core/models.py:1294
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1347 core/models.py:1347
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1348 core/models.py:1348
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1354 core/models.py:1354
#: build/lib/core/models.py:1355 core/models.py:1355
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1360 core/models.py:1360
#: build/lib/core/models.py:1361 core/models.py:1361
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1437 core/models.py:1437
#: build/lib/core/models.py:1438 core/models.py:1438
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1456 core/models.py:1456
#: build/lib/core/models.py:1457 core/models.py:1457
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1457 core/models.py:1457
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1477 core/models.py:1477
#: build/lib/core/models.py:1478 core/models.py:1478
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-09-01 21:01+0000\n"
"PO-Revision-Date: 2025-09-04 10:03\n"
"POT-Creation-Date: 2025-09-10 14:29+0000\n"
"PO-Revision-Date: 2025-09-12 09:50\n"
"Last-Translator: \n"
"Language-Team: Russian\n"
"Language: ru_RU\n"
@@ -70,7 +70,7 @@ msgstr "Тип сообщения"
msgid "Format"
msgstr "Формат"
#: build/lib/core/api/viewsets.py:960 core/api/viewsets.py:960
#: build/lib/core/api/viewsets.py:965 core/api/viewsets.py:965
#, python-brace-format
msgid "copy of {title}"
msgstr "копия {title}"
@@ -225,8 +225,8 @@ msgstr "пользователь"
msgid "users"
msgstr "пользователи"
#: build/lib/core/models.py:359 build/lib/core/models.py:1280
#: core/models.py:359 core/models.py:1280
#: build/lib/core/models.py:359 build/lib/core/models.py:1281
#: core/models.py:359 core/models.py:1281
msgid "title"
msgstr "заголовок"
@@ -242,155 +242,155 @@ msgstr "Документ"
msgid "Documents"
msgstr "Документы"
#: build/lib/core/models.py:422 build/lib/core/models.py:818 core/models.py:422
#: core/models.py:818
#: build/lib/core/models.py:422 build/lib/core/models.py:819 core/models.py:422
#: core/models.py:819
msgid "Untitled Document"
msgstr "Безымянный документ"
#: build/lib/core/models.py:853 core/models.py:853
#: build/lib/core/models.py:854 core/models.py:854
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} делится с вами документом!"
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:858 core/models.py:858
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} приглашает вас присоединиться к следующему документу с ролью \"{role}\":"
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:864 core/models.py:864
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} делится с вами документом: {title}"
#: build/lib/core/models.py:963 core/models.py:963
#: build/lib/core/models.py:964 core/models.py:964
msgid "Document/user link trace"
msgstr "Трассировка связи документ/пользователь"
#: build/lib/core/models.py:964 core/models.py:964
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link traces"
msgstr "Трассировка связей документ/пользователь"
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:971 core/models.py:971
msgid "A link trace already exists for this document/user."
msgstr "Для этого документа/пользователя уже существует трассировка ссылки."
#: build/lib/core/models.py:993 core/models.py:993
#: build/lib/core/models.py:994 core/models.py:994
msgid "Document favorite"
msgstr "Избранный документ"
#: build/lib/core/models.py:994 core/models.py:994
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorites"
msgstr "Избранные документы"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1001 core/models.py:1001
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Этот документ уже помечен как избранный для этого пользователя."
#: build/lib/core/models.py:1022 core/models.py:1022
#: build/lib/core/models.py:1023 core/models.py:1023
msgid "Document/user relation"
msgstr "Отношение документ/пользователь"
#: build/lib/core/models.py:1023 core/models.py:1023
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relations"
msgstr "Отношения документ/пользователь"
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1030 core/models.py:1030
msgid "This user is already in this document."
msgstr "Этот пользователь уже имеет доступ к этому документу."
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1036 core/models.py:1036
msgid "This team is already in this document."
msgstr "Эта команда уже имеет доступ к этому документу."
#: build/lib/core/models.py:1041 build/lib/core/models.py:1366
#: core/models.py:1041 core/models.py:1366
#: build/lib/core/models.py:1042 build/lib/core/models.py:1367
#: core/models.py:1042 core/models.py:1367
msgid "Either user or team must be set, not both."
msgstr "Может быть выбран либо пользователь, либо команда, но не оба варианта сразу."
#: build/lib/core/models.py:1187 core/models.py:1187
#: build/lib/core/models.py:1188 core/models.py:1188
msgid "Document ask for access"
msgstr "Документ запрашивает доступ"
#: build/lib/core/models.py:1188 core/models.py:1188
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for accesses"
msgstr "Документ запрашивает доступы"
#: build/lib/core/models.py:1194 core/models.py:1194
#: build/lib/core/models.py:1195 core/models.py:1195
msgid "This user has already asked for access to this document."
msgstr "Этот пользователь уже запросил доступ к этому документу."
#: build/lib/core/models.py:1259 core/models.py:1259
#: build/lib/core/models.py:1260 core/models.py:1260
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} хочет получить доступ к документу!"
#: build/lib/core/models.py:1263 core/models.py:1263
#: build/lib/core/models.py:1264 core/models.py:1264
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} хочет получить доступ к следующему документу:"
#: build/lib/core/models.py:1269 core/models.py:1269
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} запрашивает доступ к документу: {title}"
#: build/lib/core/models.py:1281 core/models.py:1281
#: build/lib/core/models.py:1282 core/models.py:1282
msgid "description"
msgstr "описание"
#: build/lib/core/models.py:1282 core/models.py:1282
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "code"
msgstr "код"
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "public"
msgstr "доступно всем"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1288 core/models.py:1288
msgid "Whether this template is public for anyone to use."
msgstr "Этот шаблон доступен всем пользователям."
#: build/lib/core/models.py:1293 core/models.py:1293
#: build/lib/core/models.py:1294 core/models.py:1294
msgid "Template"
msgstr "Шаблон"
#: build/lib/core/models.py:1294 core/models.py:1294
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Templates"
msgstr "Шаблоны"
#: build/lib/core/models.py:1347 core/models.py:1347
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Template/user relation"
msgstr "Отношение шаблон/пользователь"
#: build/lib/core/models.py:1348 core/models.py:1348
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relations"
msgstr "Отношения шаблон/пользователь"
#: build/lib/core/models.py:1354 core/models.py:1354
#: build/lib/core/models.py:1355 core/models.py:1355
msgid "This user is already in this template."
msgstr "Этот пользователь уже указан в этом шаблоне."
#: build/lib/core/models.py:1360 core/models.py:1360
#: build/lib/core/models.py:1361 core/models.py:1361
msgid "This team is already in this template."
msgstr "Эта команда уже указана в этом шаблоне."
#: build/lib/core/models.py:1437 core/models.py:1437
#: build/lib/core/models.py:1438 core/models.py:1438
msgid "email address"
msgstr "адрес электронной почты"
#: build/lib/core/models.py:1456 core/models.py:1456
#: build/lib/core/models.py:1457 core/models.py:1457
msgid "Document invitation"
msgstr "Приглашение для документа"
#: build/lib/core/models.py:1457 core/models.py:1457
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitations"
msgstr "Приглашения для документов"
#: build/lib/core/models.py:1477 core/models.py:1477
#: build/lib/core/models.py:1478 core/models.py:1478
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-09-01 21:01+0000\n"
"PO-Revision-Date: 2025-09-04 10:03\n"
"POT-Creation-Date: 2025-09-10 14:29+0000\n"
"PO-Revision-Date: 2025-09-12 09:50\n"
"Last-Translator: \n"
"Language-Team: Slovenian\n"
"Language: sl_SI\n"
@@ -70,7 +70,7 @@ msgstr "Vrsta telesa"
msgid "Format"
msgstr "Oblika"
#: build/lib/core/api/viewsets.py:960 core/api/viewsets.py:960
#: build/lib/core/api/viewsets.py:965 core/api/viewsets.py:965
#, python-brace-format
msgid "copy of {title}"
msgstr ""
@@ -225,8 +225,8 @@ msgstr "uporabnik"
msgid "users"
msgstr "uporabniki"
#: build/lib/core/models.py:359 build/lib/core/models.py:1280
#: core/models.py:359 core/models.py:1280
#: build/lib/core/models.py:359 build/lib/core/models.py:1281
#: core/models.py:359 core/models.py:1281
msgid "title"
msgstr "naslov"
@@ -242,155 +242,155 @@ msgstr "Dokument"
msgid "Documents"
msgstr "Dokumenti"
#: build/lib/core/models.py:422 build/lib/core/models.py:818 core/models.py:422
#: core/models.py:818
#: build/lib/core/models.py:422 build/lib/core/models.py:819 core/models.py:422
#: core/models.py:819
msgid "Untitled Document"
msgstr "Dokument brez naslova"
#: build/lib/core/models.py:853 core/models.py:853
#: build/lib/core/models.py:854 core/models.py:854
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} je delil dokument z vami!"
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:858 core/models.py:858
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} vas je povabil z vlogo \"{role}\" na naslednjem dokumentu:"
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:864 core/models.py:864
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} je delil dokument z vami: {title}"
#: build/lib/core/models.py:963 core/models.py:963
#: build/lib/core/models.py:964 core/models.py:964
msgid "Document/user link trace"
msgstr "Dokument/sled povezave uporabnika"
#: build/lib/core/models.py:964 core/models.py:964
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link traces"
msgstr "Sledi povezav dokumenta/uporabnika"
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:971 core/models.py:971
msgid "A link trace already exists for this document/user."
msgstr "Za ta dokument/uporabnika že obstaja sled povezave."
#: build/lib/core/models.py:993 core/models.py:993
#: build/lib/core/models.py:994 core/models.py:994
msgid "Document favorite"
msgstr "Priljubljeni dokument"
#: build/lib/core/models.py:994 core/models.py:994
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorites"
msgstr "Priljubljeni dokumenti"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1001 core/models.py:1001
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Ta dokument je že ciljno usmerjen s priljubljenim primerkom relacije za istega uporabnika."
#: build/lib/core/models.py:1022 core/models.py:1022
#: build/lib/core/models.py:1023 core/models.py:1023
msgid "Document/user relation"
msgstr "Odnos dokument/uporabnik"
#: build/lib/core/models.py:1023 core/models.py:1023
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relations"
msgstr "Odnosi dokument/uporabnik"
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1030 core/models.py:1030
msgid "This user is already in this document."
msgstr "Ta uporabnik je že v tem dokumentu."
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1036 core/models.py:1036
msgid "This team is already in this document."
msgstr "Ta ekipa je že v tem dokumentu."
#: build/lib/core/models.py:1041 build/lib/core/models.py:1366
#: core/models.py:1041 core/models.py:1366
#: build/lib/core/models.py:1042 build/lib/core/models.py:1367
#: core/models.py:1042 core/models.py:1367
msgid "Either user or team must be set, not both."
msgstr "Nastaviti je treba bodisi uporabnika ali ekipo, a ne obojega."
#: build/lib/core/models.py:1187 core/models.py:1187
#: build/lib/core/models.py:1188 core/models.py:1188
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1188 core/models.py:1188
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1194 core/models.py:1194
#: build/lib/core/models.py:1195 core/models.py:1195
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1259 core/models.py:1259
#: build/lib/core/models.py:1260 core/models.py:1260
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1263 core/models.py:1263
#: build/lib/core/models.py:1264 core/models.py:1264
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1269 core/models.py:1269
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1281 core/models.py:1281
#: build/lib/core/models.py:1282 core/models.py:1282
msgid "description"
msgstr "opis"
#: build/lib/core/models.py:1282 core/models.py:1282
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "code"
msgstr "koda"
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "public"
msgstr "javno"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1288 core/models.py:1288
msgid "Whether this template is public for anyone to use."
msgstr "Ali je ta predloga javna za uporabo."
#: build/lib/core/models.py:1293 core/models.py:1293
#: build/lib/core/models.py:1294 core/models.py:1294
msgid "Template"
msgstr "Predloga"
#: build/lib/core/models.py:1294 core/models.py:1294
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Templates"
msgstr "Predloge"
#: build/lib/core/models.py:1347 core/models.py:1347
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Template/user relation"
msgstr "Odnos predloga/uporabnik"
#: build/lib/core/models.py:1348 core/models.py:1348
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relations"
msgstr "Odnosi med predlogo in uporabnikom"
#: build/lib/core/models.py:1354 core/models.py:1354
#: build/lib/core/models.py:1355 core/models.py:1355
msgid "This user is already in this template."
msgstr "Ta uporabnik je že v tej predlogi."
#: build/lib/core/models.py:1360 core/models.py:1360
#: build/lib/core/models.py:1361 core/models.py:1361
msgid "This team is already in this template."
msgstr "Ta ekipa je že v tej predlogi."
#: build/lib/core/models.py:1437 core/models.py:1437
#: build/lib/core/models.py:1438 core/models.py:1438
msgid "email address"
msgstr "elektronski naslov"
#: build/lib/core/models.py:1456 core/models.py:1456
#: build/lib/core/models.py:1457 core/models.py:1457
msgid "Document invitation"
msgstr "Vabilo na dokument"
#: build/lib/core/models.py:1457 core/models.py:1457
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitations"
msgstr "Vabila na dokument"
#: build/lib/core/models.py:1477 core/models.py:1477
#: build/lib/core/models.py:1478 core/models.py:1478
msgid "This email is already associated to a registered user."
msgstr "Ta e-poštni naslov je že povezan z registriranim uporabnikom."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-09-01 21:01+0000\n"
"PO-Revision-Date: 2025-09-04 10:03\n"
"POT-Creation-Date: 2025-09-10 14:29+0000\n"
"PO-Revision-Date: 2025-09-12 09:50\n"
"Last-Translator: \n"
"Language-Team: Swedish\n"
"Language: sv_SE\n"
@@ -70,7 +70,7 @@ msgstr ""
msgid "Format"
msgstr "Format"
#: build/lib/core/api/viewsets.py:960 core/api/viewsets.py:960
#: build/lib/core/api/viewsets.py:965 core/api/viewsets.py:965
#, python-brace-format
msgid "copy of {title}"
msgstr ""
@@ -225,8 +225,8 @@ msgstr ""
msgid "users"
msgstr ""
#: build/lib/core/models.py:359 build/lib/core/models.py:1280
#: core/models.py:359 core/models.py:1280
#: build/lib/core/models.py:359 build/lib/core/models.py:1281
#: core/models.py:359 core/models.py:1281
msgid "title"
msgstr ""
@@ -242,155 +242,155 @@ msgstr ""
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:422 build/lib/core/models.py:818 core/models.py:422
#: core/models.py:818
#: build/lib/core/models.py:422 build/lib/core/models.py:819 core/models.py:422
#: core/models.py:819
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:853 core/models.py:853
#: build/lib/core/models.py:854 core/models.py:854
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:858 core/models.py:858
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:864 core/models.py:864
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:963 core/models.py:963
#: build/lib/core/models.py:964 core/models.py:964
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:964 core/models.py:964
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:971 core/models.py:971
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:993 core/models.py:993
#: build/lib/core/models.py:994 core/models.py:994
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:994 core/models.py:994
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1001 core/models.py:1001
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1022 core/models.py:1022
#: build/lib/core/models.py:1023 core/models.py:1023
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1023 core/models.py:1023
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1030 core/models.py:1030
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1036 core/models.py:1036
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1041 build/lib/core/models.py:1366
#: core/models.py:1041 core/models.py:1366
#: build/lib/core/models.py:1042 build/lib/core/models.py:1367
#: core/models.py:1042 core/models.py:1367
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1187 core/models.py:1187
#: build/lib/core/models.py:1188 core/models.py:1188
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1188 core/models.py:1188
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1194 core/models.py:1194
#: build/lib/core/models.py:1195 core/models.py:1195
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1259 core/models.py:1259
#: build/lib/core/models.py:1260 core/models.py:1260
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1263 core/models.py:1263
#: build/lib/core/models.py:1264 core/models.py:1264
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1269 core/models.py:1269
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1281 core/models.py:1281
#: build/lib/core/models.py:1282 core/models.py:1282
msgid "description"
msgstr ""
#: build/lib/core/models.py:1282 core/models.py:1282
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "code"
msgstr ""
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "css"
msgstr ""
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "public"
msgstr ""
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1288 core/models.py:1288
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1293 core/models.py:1293
#: build/lib/core/models.py:1294 core/models.py:1294
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1294 core/models.py:1294
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1347 core/models.py:1347
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1348 core/models.py:1348
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1354 core/models.py:1354
#: build/lib/core/models.py:1355 core/models.py:1355
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1360 core/models.py:1360
#: build/lib/core/models.py:1361 core/models.py:1361
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1437 core/models.py:1437
#: build/lib/core/models.py:1438 core/models.py:1438
msgid "email address"
msgstr "e-postadress"
#: build/lib/core/models.py:1456 core/models.py:1456
#: build/lib/core/models.py:1457 core/models.py:1457
msgid "Document invitation"
msgstr "Bjud in dokument"
#: build/lib/core/models.py:1457 core/models.py:1457
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitations"
msgstr "Inbjudningar dokument"
#: build/lib/core/models.py:1477 core/models.py:1477
#: build/lib/core/models.py:1478 core/models.py:1478
msgid "This email is already associated to a registered user."
msgstr "Denna e-postadress är redan associerad med en registrerad användare."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-09-01 21:01+0000\n"
"PO-Revision-Date: 2025-09-04 10:03\n"
"POT-Creation-Date: 2025-09-10 14:29+0000\n"
"PO-Revision-Date: 2025-09-12 09:50\n"
"Last-Translator: \n"
"Language-Team: Turkish\n"
"Language: tr_TR\n"
@@ -70,7 +70,7 @@ msgstr ""
msgid "Format"
msgstr ""
#: build/lib/core/api/viewsets.py:960 core/api/viewsets.py:960
#: build/lib/core/api/viewsets.py:965 core/api/viewsets.py:965
#, python-brace-format
msgid "copy of {title}"
msgstr ""
@@ -225,8 +225,8 @@ msgstr ""
msgid "users"
msgstr ""
#: build/lib/core/models.py:359 build/lib/core/models.py:1280
#: core/models.py:359 core/models.py:1280
#: build/lib/core/models.py:359 build/lib/core/models.py:1281
#: core/models.py:359 core/models.py:1281
msgid "title"
msgstr ""
@@ -242,155 +242,155 @@ msgstr ""
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:422 build/lib/core/models.py:818 core/models.py:422
#: core/models.py:818
#: build/lib/core/models.py:422 build/lib/core/models.py:819 core/models.py:422
#: core/models.py:819
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:853 core/models.py:853
#: build/lib/core/models.py:854 core/models.py:854
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:858 core/models.py:858
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:864 core/models.py:864
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:963 core/models.py:963
#: build/lib/core/models.py:964 core/models.py:964
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:964 core/models.py:964
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:971 core/models.py:971
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:993 core/models.py:993
#: build/lib/core/models.py:994 core/models.py:994
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:994 core/models.py:994
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1001 core/models.py:1001
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1022 core/models.py:1022
#: build/lib/core/models.py:1023 core/models.py:1023
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1023 core/models.py:1023
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1030 core/models.py:1030
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1036 core/models.py:1036
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1041 build/lib/core/models.py:1366
#: core/models.py:1041 core/models.py:1366
#: build/lib/core/models.py:1042 build/lib/core/models.py:1367
#: core/models.py:1042 core/models.py:1367
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1187 core/models.py:1187
#: build/lib/core/models.py:1188 core/models.py:1188
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1188 core/models.py:1188
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1194 core/models.py:1194
#: build/lib/core/models.py:1195 core/models.py:1195
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1259 core/models.py:1259
#: build/lib/core/models.py:1260 core/models.py:1260
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1263 core/models.py:1263
#: build/lib/core/models.py:1264 core/models.py:1264
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1269 core/models.py:1269
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1281 core/models.py:1281
#: build/lib/core/models.py:1282 core/models.py:1282
msgid "description"
msgstr ""
#: build/lib/core/models.py:1282 core/models.py:1282
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "code"
msgstr ""
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "css"
msgstr ""
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "public"
msgstr ""
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1288 core/models.py:1288
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1293 core/models.py:1293
#: build/lib/core/models.py:1294 core/models.py:1294
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1294 core/models.py:1294
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1347 core/models.py:1347
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1348 core/models.py:1348
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1354 core/models.py:1354
#: build/lib/core/models.py:1355 core/models.py:1355
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1360 core/models.py:1360
#: build/lib/core/models.py:1361 core/models.py:1361
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1437 core/models.py:1437
#: build/lib/core/models.py:1438 core/models.py:1438
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1456 core/models.py:1456
#: build/lib/core/models.py:1457 core/models.py:1457
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1457 core/models.py:1457
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1477 core/models.py:1477
#: build/lib/core/models.py:1478 core/models.py:1478
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-09-01 21:01+0000\n"
"PO-Revision-Date: 2025-09-04 10:03\n"
"POT-Creation-Date: 2025-09-10 14:29+0000\n"
"PO-Revision-Date: 2025-09-12 09:50\n"
"Last-Translator: \n"
"Language-Team: Ukrainian\n"
"Language: uk_UA\n"
@@ -70,7 +70,7 @@ msgstr "Тип вмісту"
msgid "Format"
msgstr "Формат"
#: build/lib/core/api/viewsets.py:960 core/api/viewsets.py:960
#: build/lib/core/api/viewsets.py:965 core/api/viewsets.py:965
#, python-brace-format
msgid "copy of {title}"
msgstr "копія {title}"
@@ -225,8 +225,8 @@ msgstr "користувач"
msgid "users"
msgstr "користувачі"
#: build/lib/core/models.py:359 build/lib/core/models.py:1280
#: core/models.py:359 core/models.py:1280
#: build/lib/core/models.py:359 build/lib/core/models.py:1281
#: core/models.py:359 core/models.py:1281
msgid "title"
msgstr "заголовок"
@@ -242,155 +242,155 @@ msgstr "Документ"
msgid "Documents"
msgstr "Документи"
#: build/lib/core/models.py:422 build/lib/core/models.py:818 core/models.py:422
#: core/models.py:818
#: build/lib/core/models.py:422 build/lib/core/models.py:819 core/models.py:422
#: core/models.py:819
msgid "Untitled Document"
msgstr "Документ без назви"
#: build/lib/core/models.py:853 core/models.py:853
#: build/lib/core/models.py:854 core/models.py:854
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} ділиться з вами документом!"
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:858 core/models.py:858
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} запрошує вас для роботи з документом із роллю \"{role}\":"
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:864 core/models.py:864
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} ділиться з вами документом: {title}"
#: build/lib/core/models.py:963 core/models.py:963
#: build/lib/core/models.py:964 core/models.py:964
msgid "Document/user link trace"
msgstr "Трасування посилання Документ/користувач"
#: build/lib/core/models.py:964 core/models.py:964
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link traces"
msgstr "Трасування посилань Документ/користувач"
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:971 core/models.py:971
msgid "A link trace already exists for this document/user."
msgstr "Відстеження вже існуючих посилань для цього документа/користувача."
#: build/lib/core/models.py:993 core/models.py:993
#: build/lib/core/models.py:994 core/models.py:994
msgid "Document favorite"
msgstr "Обраний документ"
#: build/lib/core/models.py:994 core/models.py:994
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorites"
msgstr "Обрані документи"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1001 core/models.py:1001
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Цей документ вже вказаний як обраний для одного користувача."
#: build/lib/core/models.py:1022 core/models.py:1022
#: build/lib/core/models.py:1023 core/models.py:1023
msgid "Document/user relation"
msgstr "Відносини документ/користувач"
#: build/lib/core/models.py:1023 core/models.py:1023
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relations"
msgstr "Відносини документ/користувач"
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1030 core/models.py:1030
msgid "This user is already in this document."
msgstr "Цей користувач вже має доступ до цього документу."
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1036 core/models.py:1036
msgid "This team is already in this document."
msgstr "Ця команда вже має доступ до цього документа."
#: build/lib/core/models.py:1041 build/lib/core/models.py:1366
#: core/models.py:1041 core/models.py:1366
#: build/lib/core/models.py:1042 build/lib/core/models.py:1367
#: core/models.py:1042 core/models.py:1367
msgid "Either user or team must be set, not both."
msgstr "Вкажіть користувача або команду, а не обох."
#: build/lib/core/models.py:1187 core/models.py:1187
#: build/lib/core/models.py:1188 core/models.py:1188
msgid "Document ask for access"
msgstr "Запит доступу до документа"
#: build/lib/core/models.py:1188 core/models.py:1188
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for accesses"
msgstr "Запит доступу для документа"
#: build/lib/core/models.py:1194 core/models.py:1194
#: build/lib/core/models.py:1195 core/models.py:1195
msgid "This user has already asked for access to this document."
msgstr "Цей користувач вже попросив доступ до цього документа."
#: build/lib/core/models.py:1259 core/models.py:1259
#: build/lib/core/models.py:1260 core/models.py:1260
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} хоче отримати доступ до документа!"
#: build/lib/core/models.py:1263 core/models.py:1263
#: build/lib/core/models.py:1264 core/models.py:1264
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} бажає отримати доступ до наступного документа:"
#: build/lib/core/models.py:1269 core/models.py:1269
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} запитує доступ до документа: {title}"
#: build/lib/core/models.py:1281 core/models.py:1281
#: build/lib/core/models.py:1282 core/models.py:1282
msgid "description"
msgstr "опис"
#: build/lib/core/models.py:1282 core/models.py:1282
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "code"
msgstr "код"
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "public"
msgstr "публічне"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1288 core/models.py:1288
msgid "Whether this template is public for anyone to use."
msgstr "Чи є цей шаблон публічним для будь-кого користувача."
#: build/lib/core/models.py:1293 core/models.py:1293
#: build/lib/core/models.py:1294 core/models.py:1294
msgid "Template"
msgstr "Шаблон"
#: build/lib/core/models.py:1294 core/models.py:1294
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Templates"
msgstr "Шаблони"
#: build/lib/core/models.py:1347 core/models.py:1347
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Template/user relation"
msgstr "Відношення шаблон/користувач"
#: build/lib/core/models.py:1348 core/models.py:1348
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relations"
msgstr "Відношення шаблон/користувач"
#: build/lib/core/models.py:1354 core/models.py:1354
#: build/lib/core/models.py:1355 core/models.py:1355
msgid "This user is already in this template."
msgstr "Цей користувач вже має доступ до цього шаблону."
#: build/lib/core/models.py:1360 core/models.py:1360
#: build/lib/core/models.py:1361 core/models.py:1361
msgid "This team is already in this template."
msgstr "Ця команда вже має доступ до цього шаблону."
#: build/lib/core/models.py:1437 core/models.py:1437
#: build/lib/core/models.py:1438 core/models.py:1438
msgid "email address"
msgstr "електронна адреса"
#: build/lib/core/models.py:1456 core/models.py:1456
#: build/lib/core/models.py:1457 core/models.py:1457
msgid "Document invitation"
msgstr "Запрошення до редагування документа"
#: build/lib/core/models.py:1457 core/models.py:1457
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitations"
msgstr "Запрошення до редагування документів"
#: build/lib/core/models.py:1477 core/models.py:1477
#: build/lib/core/models.py:1478 core/models.py:1478
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-09-01 21:01+0000\n"
"PO-Revision-Date: 2025-09-04 10:03\n"
"POT-Creation-Date: 2025-09-10 14:29+0000\n"
"PO-Revision-Date: 2025-09-12 09:50\n"
"Last-Translator: \n"
"Language-Team: Chinese Simplified\n"
"Language: zh_CN\n"
@@ -70,7 +70,7 @@ msgstr "正文类型"
msgid "Format"
msgstr "格式"
#: build/lib/core/api/viewsets.py:960 core/api/viewsets.py:960
#: build/lib/core/api/viewsets.py:965 core/api/viewsets.py:965
#, python-brace-format
msgid "copy of {title}"
msgstr "{title} 的副本"
@@ -225,8 +225,8 @@ msgstr "用户"
msgid "users"
msgstr "个用户"
#: build/lib/core/models.py:359 build/lib/core/models.py:1280
#: core/models.py:359 core/models.py:1280
#: build/lib/core/models.py:359 build/lib/core/models.py:1281
#: core/models.py:359 core/models.py:1281
msgid "title"
msgstr "标题"
@@ -242,155 +242,155 @@ msgstr "文档"
msgid "Documents"
msgstr "个文档"
#: build/lib/core/models.py:422 build/lib/core/models.py:818 core/models.py:422
#: core/models.py:818
#: build/lib/core/models.py:422 build/lib/core/models.py:819 core/models.py:422
#: core/models.py:819
msgid "Untitled Document"
msgstr "未命名文档"
#: build/lib/core/models.py:853 core/models.py:853
#: build/lib/core/models.py:854 core/models.py:854
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} 与您共享了一个文档!"
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:858 core/models.py:858
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} 邀请您以“{role}”角色访问以下文档:"
#: build/lib/core/models.py:863 core/models.py:863
#: build/lib/core/models.py:864 core/models.py:864
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} 与您共享了一个文档:{title}"
#: build/lib/core/models.py:963 core/models.py:963
#: build/lib/core/models.py:964 core/models.py:964
msgid "Document/user link trace"
msgstr "文档/用户链接跟踪"
#: build/lib/core/models.py:964 core/models.py:964
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link traces"
msgstr "个文档/用户链接跟踪"
#: build/lib/core/models.py:970 core/models.py:970
#: build/lib/core/models.py:971 core/models.py:971
msgid "A link trace already exists for this document/user."
msgstr "此文档/用户的链接跟踪已存在。"
#: build/lib/core/models.py:993 core/models.py:993
#: build/lib/core/models.py:994 core/models.py:994
msgid "Document favorite"
msgstr "文档收藏"
#: build/lib/core/models.py:994 core/models.py:994
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorites"
msgstr "文档收藏夹"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1001 core/models.py:1001
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "该文档已被同一用户的收藏关系实例关联。"
#: build/lib/core/models.py:1022 core/models.py:1022
#: build/lib/core/models.py:1023 core/models.py:1023
msgid "Document/user relation"
msgstr "文档/用户关系"
#: build/lib/core/models.py:1023 core/models.py:1023
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relations"
msgstr "文档/用户关系集"
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1030 core/models.py:1030
msgid "This user is already in this document."
msgstr "该用户已在此文档中。"
#: build/lib/core/models.py:1035 core/models.py:1035
#: build/lib/core/models.py:1036 core/models.py:1036
msgid "This team is already in this document."
msgstr "该团队已在此文档中。"
#: build/lib/core/models.py:1041 build/lib/core/models.py:1366
#: core/models.py:1041 core/models.py:1366
#: build/lib/core/models.py:1042 build/lib/core/models.py:1367
#: core/models.py:1042 core/models.py:1367
msgid "Either user or team must be set, not both."
msgstr "必须设置用户或团队之一,不能同时设置两者。"
#: build/lib/core/models.py:1187 core/models.py:1187
#: build/lib/core/models.py:1188 core/models.py:1188
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1188 core/models.py:1188
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1194 core/models.py:1194
#: build/lib/core/models.py:1195 core/models.py:1195
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1259 core/models.py:1259
#: build/lib/core/models.py:1260 core/models.py:1260
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1263 core/models.py:1263
#: build/lib/core/models.py:1264 core/models.py:1264
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1269 core/models.py:1269
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1281 core/models.py:1281
#: build/lib/core/models.py:1282 core/models.py:1282
msgid "description"
msgstr "说明"
#: build/lib/core/models.py:1282 core/models.py:1282
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "code"
msgstr "代码"
#: build/lib/core/models.py:1283 core/models.py:1283
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1285 core/models.py:1285
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "public"
msgstr "公开"
#: build/lib/core/models.py:1287 core/models.py:1287
#: build/lib/core/models.py:1288 core/models.py:1288
msgid "Whether this template is public for anyone to use."
msgstr "该模板是否公开供任何人使用。"
#: build/lib/core/models.py:1293 core/models.py:1293
#: build/lib/core/models.py:1294 core/models.py:1294
msgid "Template"
msgstr "模板"
#: build/lib/core/models.py:1294 core/models.py:1294
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Templates"
msgstr "模板"
#: build/lib/core/models.py:1347 core/models.py:1347
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Template/user relation"
msgstr "模板/用户关系"
#: build/lib/core/models.py:1348 core/models.py:1348
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relations"
msgstr "模板/用户关系集"
#: build/lib/core/models.py:1354 core/models.py:1354
#: build/lib/core/models.py:1355 core/models.py:1355
msgid "This user is already in this template."
msgstr "该用户已在此模板中。"
#: build/lib/core/models.py:1360 core/models.py:1360
#: build/lib/core/models.py:1361 core/models.py:1361
msgid "This team is already in this template."
msgstr "该团队已在此模板中。"
#: build/lib/core/models.py:1437 core/models.py:1437
#: build/lib/core/models.py:1438 core/models.py:1438
msgid "email address"
msgstr "电子邮件地址"
#: build/lib/core/models.py:1456 core/models.py:1456
#: build/lib/core/models.py:1457 core/models.py:1457
msgid "Document invitation"
msgstr "文档邀请"
#: build/lib/core/models.py:1457 core/models.py:1457
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitations"
msgstr "文档邀请"
#: build/lib/core/models.py:1477 core/models.py:1477
#: build/lib/core/models.py:1478 core/models.py:1478
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 = "3.6.0"
version = "3.7.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -33,14 +33,13 @@ dependencies = [
"django-cors-headers==4.7.0",
"django-countries==7.6.1",
"django-csp==4.0",
"django-dirtyfields==1.9.7",
"django-filter==25.1",
"django-lasuite[all]==0.0.14",
"django-parler==2.3",
"django-redis==6.0.0",
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django==5.2.4",
"django==5.2.6",
"django-treebeard==4.7.1",
"djangorestframework==3.16.0",
"drf_spectacular==0.28.0",

View File

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

View File

@@ -45,8 +45,8 @@ test.describe('Doc Create', () => {
})
.click();
const input = page.getByRole('textbox', { name: 'doc title input' });
await expect(input).toHaveText('');
const input = page.getByRole('textbox', { name: 'Document title' });
await expect(input).toHaveText('', { timeout: 10000 });
await expect(
page.locator('.c__tree-view--row-content').getByText('Untitled document'),
).toBeVisible();
@@ -67,8 +67,8 @@ test.describe('Doc Create', () => {
.getByText('New sub-doc')
.click();
const input = page.getByRole('textbox', { name: 'doc title input' });
await expect(input).toHaveText('');
const input = page.getByRole('textbox', { name: 'Document title' });
await expect(input).toHaveText('', { timeout: 10000 });
await expect(
page.locator('.c__tree-view--row-content').getByText('Untitled document'),
).toBeVisible();
@@ -89,8 +89,8 @@ test.describe('Doc Create: Not logged', () => {
const data = {
title,
content: markdown,
sub: `user@${browserName}.test`,
email: `user@${browserName}.test`,
sub: `user.test@${browserName}.test`,
email: `user.test@${browserName}.test`,
};
const newDoc = await request.post(

View File

@@ -11,7 +11,8 @@ import {
overrideConfig,
verifyDocName,
} from './utils-common';
import { createRootSubPage } from './utils-sub-pages';
import { getEditor, openSuggestionMenu, writeInEditor } from './utils-editor';
import { createRootSubPage, navigateToPageFromTree } from './utils-sub-pages';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -86,8 +87,7 @@ test.describe('Doc Editor', () => {
// Is connected
let framesentPromise = webSocket.waitForEvent('framesent');
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
await writeInEditor({ page, text: 'Hello World' });
let framesent = await framesentPromise;
expect(framesent.payload).not.toBeNull();
@@ -100,7 +100,7 @@ test.describe('Doc Editor', () => {
const wsClosePromise = webSocket.waitForEvent('close');
await selectVisibility.click();
await page.getByLabel('Connected').click();
await page.getByRole('menuitem', { name: 'Connected' }).click();
// Assert that the doc reconnects to the ws
const wsClose = await wsClosePromise;
@@ -226,25 +226,19 @@ test.describe('Doc Editor', () => {
await editor.fill('Hello World Doc persisted 2');
await expect(editor.getByText('Hello World Doc persisted 2')).toBeVisible();
await page.waitForTimeout(1000);
const urlDoc = page.url();
await page.goto(urlDoc);
// Wait for editor to load
await expect(editor).toBeVisible();
await expect(editor.getByText('Hello World Doc persisted 2')).toBeVisible();
});
test('it cannot edit if viewer', async ({ page }) => {
await mockedDocument(page, {
abilities: {
destroy: false, // Means not owner
link_configuration: false,
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
accesses_manage: false, // Means not admin
update: false,
partial_update: false, // Means not editor
retrieve: true,
},
user_role: 'reader',
});
await goToGridDoc(page);
@@ -253,6 +247,9 @@ test.describe('Doc Editor', () => {
await expect(card).toBeVisible();
await expect(card.getByText('Reader')).toBeVisible();
const editor = page.locator('.ProseMirror');
await expect(editor).toHaveAttribute('contenteditable', 'false');
});
test('it adds an image to the doc editor', async ({ page, browserName }) => {
@@ -463,12 +460,14 @@ test.describe('Doc Editor', () => {
await expect(
page.getByRole('button', {
name: 'Download',
exact: true,
}),
).toBeVisible();
void page
.getByRole('button', {
name: 'Download',
exact: true,
})
.click();
@@ -506,10 +505,7 @@ test.describe('Doc Editor', () => {
await verifyDocName(page, randomDoc);
const editor = page.locator('.ProseMirror.bn-editor');
await editor.click();
await editor.locator('.bn-block-outer').last().fill('/');
const editor = await openSuggestionMenu({ page });
await page.getByText('Embedded file').click();
await page.getByText('Upload file').click();
@@ -676,9 +672,7 @@ test.describe('Doc Editor', () => {
test('it checks if callout custom block', async ({ page, browserName }) => {
await createDoc(page, 'doc-toolbar', browserName, 1);
const editor = page.locator('.ProseMirror');
await editor.click();
await page.locator('.bn-block-outer').last().fill('/');
await openSuggestionMenu({ page });
await page.getByText('Add a callout block').click();
const calloutBlock = page
@@ -697,8 +691,20 @@ test.describe('Doc Editor', () => {
const emojiButton = calloutBlock.getByRole('button');
await expect(emojiButton).toHaveText('💡');
await emojiButton.click();
await page.locator('button[aria-label="⚠️"]').click();
await expect(emojiButton).toHaveText('⚠️');
// Group smiley
await expect(page.getByRole('button', { name: '🤠' })).toBeVisible();
// Group animals
await page.getByText('Animals & Nature').scrollIntoViewIfNeeded();
await expect(page.getByRole('button', { name: '🦆' })).toBeVisible();
// Group travel
await page.getByText('Travel & Places').scrollIntoViewIfNeeded();
await expect(page.getByRole('button', { name: '🚝' })).toBeVisible();
// Group objects
await page.getByText('Objects').scrollIntoViewIfNeeded();
await expect(page.getByRole('button', { name: '🪇' })).toBeVisible();
// Group symbol
await page.getByText('Symbols').scrollIntoViewIfNeeded();
await expect(page.getByRole('button', { name: '🛃' })).toBeVisible();
await page.locator('.bn-side-menu > button').last().click();
await page.locator('.mantine-Menu-dropdown > button').last().click();
@@ -751,15 +757,21 @@ test.describe('Doc Editor', () => {
await expect(searchContainer.getByText(docChild2)).toBeVisible();
await expect(searchContainer.getByText(randomDoc)).toBeHidden();
// use keydown to select the second result
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Enter');
const interlink = page.getByRole('link', {
name: 'child-2',
// Wait for the search container to disappear, indicating selection was made
await expect(searchContainer).toBeHidden();
// Wait for the interlink to be created and rendered
const editor = page.locator('.ProseMirror.bn-editor');
const interlink = editor.getByRole('link', {
name: docChild2,
});
await expect(interlink).toBeVisible();
await expect(interlink).toBeVisible({ timeout: 10000 });
await interlink.click();
await verifyDocName(page, docChild2);
@@ -780,4 +792,86 @@ test.describe('Doc Editor', () => {
),
).toBeVisible();
});
test('it checks multiple big doc scroll to the top', async ({
page,
browserName,
}) => {
const [randomDoc] = await createDoc(page, 'doc-scroll', browserName, 1);
for (let i = 0; i < 15; i++) {
await page.keyboard.press('Enter');
await writeInEditor({ page, text: 'Hello Parent ' + i });
}
const editor = await getEditor({ page });
await expect(
editor.getByText('Hello Parent 1', { exact: true }),
).not.toBeInViewport();
await expect(editor.getByText('Hello Parent 14')).toBeInViewport();
const { name: docChild } = await createRootSubPage(
page,
browserName,
'doc-scroll-child',
);
for (let i = 0; i < 15; i++) {
await page.keyboard.press('Enter');
await writeInEditor({ page, text: 'Hello Child ' + i });
}
await expect(
editor.getByText('Hello Child 1', { exact: true }),
).not.toBeInViewport();
await expect(editor.getByText('Hello Child 14')).toBeInViewport();
await navigateToPageFromTree({ page, title: randomDoc });
await expect(
editor.getByText('Hello Parent 1', { exact: true }),
).toBeInViewport();
await expect(editor.getByText('Hello Parent 14')).not.toBeInViewport();
await navigateToPageFromTree({ page, title: docChild });
await expect(
editor.getByText('Hello Child 1', { exact: true }),
).toBeInViewport();
await expect(editor.getByText('Hello Child 14')).not.toBeInViewport();
});
test('it embeds PDF', async ({ page, browserName }) => {
await createDoc(page, 'doc-toolbar', browserName, 1);
await openSuggestionMenu({ page });
await page.getByText('Embed a PDF file').click();
const pdfBlock = page.locator('div[data-content-type="pdf"]').first();
await expect(pdfBlock).toBeVisible();
await page.getByText('Add PDF').click();
const fileChooserPromise = page.waitForEvent('filechooser');
await page.getByText('Upload file').click();
const fileChooser = await fileChooserPromise;
console.log(path.join(__dirname, 'assets/test-pdf.pdf'));
await fileChooser.setFiles(path.join(__dirname, 'assets/test-pdf.pdf'));
// Wait for the media-check to be processed
await page.waitForTimeout(1000);
const pdfEmbed = page
.locator('.--docs--editor-container embed.bn-visual-media')
.first();
// Check src of pdf
expect(await pdfEmbed.getAttribute('src')).toMatch(
/http:\/\/localhost:8083\/media\/.*\/attachments\/.*.pdf/,
);
await expect(pdfEmbed).toHaveAttribute('type', 'application/pdf');
await expect(pdfEmbed).toHaveAttribute('role', 'presentation');
});
});

View File

@@ -38,9 +38,11 @@ test.describe('Doc Export', () => {
).toBeVisible();
await expect(page.getByRole('combobox', { name: 'Format' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Close the modal' }),
page.getByRole('button', {
name: 'Close the download modal',
}),
).toBeVisible();
await expect(page.getByTestId('modal-download-button')).toBeVisible();
await expect(page.getByTestId('doc-export-download-button')).toBeVisible();
});
test('it exports the doc with pdf line break', async ({
@@ -81,12 +83,7 @@ test.describe('Doc Export', () => {
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
});
void page
.getByRole('button', {
name: 'Download',
exact: true,
})
.click();
void page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
@@ -96,6 +93,7 @@ test.describe('Doc Export', () => {
expect(pdfData.numpages).toBe(2);
expect(pdfData.text).toContain('\n\nHello\n\nWorld'); // This is the doc text
expect(pdfData.info.Title).toBe(randomDoc);
});
test('it exports the doc to docx', async ({ page, browserName }) => {
@@ -131,13 +129,13 @@ test.describe('Doc Export', () => {
await page.getByRole('combobox', { name: 'Format' }).click();
await page.getByRole('option', { name: 'Docx' }).click();
await expect(page.getByTestId('modal-download-button')).toBeVisible();
await expect(page.getByTestId('doc-export-download-button')).toBeVisible();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.docx`);
});
void page.getByTestId('modal-download-button').click();
void page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDoc}.docx`);
@@ -203,7 +201,7 @@ test.describe('Doc Export', () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
await expect(page.getByTestId('modal-download-button')).toBeVisible();
await expect(page.getByTestId('doc-export-download-button')).toBeVisible();
const responseCorsPromise = page.waitForResponse(
(response) =>
@@ -214,7 +212,7 @@ test.describe('Doc Export', () => {
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
});
void page.getByTestId('modal-download-button').click();
void page.getByTestId('doc-export-download-button').click();
const responseCors = await responseCorsPromise;
expect(responseCors.ok()).toBe(true);
@@ -256,13 +254,13 @@ test.describe('Doc Export', () => {
})
.click();
await expect(page.getByTestId('modal-download-button')).toBeVisible();
await expect(page.getByTestId('doc-export-download-button')).toBeVisible();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
});
void page.getByTestId('modal-download-button').click();
void page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
@@ -298,13 +296,15 @@ test.describe('Doc Export', () => {
})
.click();
await expect(page.getByTestId('modal-download-button')).toBeVisible();
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('modal-download-button').click();
void page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
@@ -350,13 +350,15 @@ test.describe('Doc Export', () => {
})
.click();
await expect(page.getByTestId('modal-download-button')).toBeVisible();
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('modal-download-button').click();
void page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
@@ -392,14 +394,9 @@ test.describe('Doc Export', () => {
})
.click();
await page.waitForURL('**/docs/**', {
timeout: 10000,
waitUntil: 'domcontentloaded',
});
const input = page.getByLabel('doc title input');
const input = page.getByRole('textbox', { name: 'Titre du document' });
await expect(input).toBeVisible();
await expect(input).toHaveText('');
await expect(input).toHaveText('', { timeout: 10000 });
await input.click();
await input.fill(randomDocFrench);
await input.blur();
@@ -414,11 +411,15 @@ test.describe('Doc Export', () => {
})
.click();
await expect(
page.getByTestId('doc-open-modal-download-button'),
).toBeVisible();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDocFrench}.pdf`);
});
void page.getByTestId('modal-download-button').click();
void page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDocFrench}.pdf`);
@@ -453,19 +454,23 @@ test.describe('Doc Export', () => {
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Link a doc').first().click();
await page
.locator(
"span[data-inline-content-type='interlinkingSearchInline'] input",
)
.fill('interlink-child');
const input = page.locator(
"span[data-inline-content-type='interlinkingSearchInline'] input",
);
const searchContainer = page.locator('.quick-search-container');
await page
.locator('.quick-search-container')
.getByText('interlink-child')
.click();
await input.fill('export-interlink');
const interlink = page.getByRole('link', {
name: 'interlink-child',
await expect(searchContainer).toBeVisible();
await expect(searchContainer.getByText(randomDoc)).toBeVisible();
// We are in docChild, we want to create a link to randomDoc (parent)
await searchContainer.getByText(randomDoc).click();
// Search the interlinking link in the editor (not in the document tree)
const editor = page.locator('.ProseMirror.bn-editor');
const interlink = editor.getByRole('link', {
name: randomDoc,
});
await expect(interlink).toBeVisible();
@@ -480,7 +485,7 @@ test.describe('Doc Export', () => {
})
.click();
void page.getByTestId('modal-download-button').click();
void page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${docChild}.pdf`);
@@ -488,6 +493,6 @@ test.describe('Doc Export', () => {
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfData = await pdf(pdfBuffer);
expect(pdfData.text).toContain('interlink-child'); // This is the pdf text
expect(pdfData.text).toContain(randomDoc);
});
});

View File

@@ -28,7 +28,7 @@ test.describe('Documents Grid mobile', () => {
id: '8c1e047a-24e7-4a80-942b-8e9c7ab43e1f',
user: {
id: '7380f42f-02eb-4ad5-b8f0-037a0e66066d',
email: 'test@test.test',
email: 'test.test@test.test',
full_name: 'John Doe',
short_name: 'John',
},
@@ -80,9 +80,7 @@ test.describe('Documents Grid mobile', () => {
hasText: 'My mocked document',
});
await expect(
row.locator('[aria-describedby="doc-title"]').nth(0),
).toHaveText('My mocked document');
await expect(row.getByTestId('doc-title')).toHaveText('My mocked document');
});
});
@@ -119,7 +117,7 @@ test.describe('Document grid item options', () => {
await page.getByText('push_pin').click();
// Check is pinned
await expect(row.locator('[data-testid^="doc-pinned-"]')).toBeVisible();
await expect(row.getByTestId('doc-pinned-icon')).toBeVisible();
const leftPanelFavorites = page.getByTestId('left-panel-favorites');
await expect(leftPanelFavorites.getByText(docTitle)).toBeVisible();
@@ -128,7 +126,7 @@ test.describe('Document grid item options', () => {
await page.getByText('Unpin').click();
// Check is unpinned
await expect(row.locator('[data-testid^="doc-pinned-"]')).toBeHidden();
await expect(row.getByTestId('doc-pinned-icon')).toBeHidden();
await expect(leftPanelFavorites.getByText(docTitle)).toBeHidden();
});
@@ -149,7 +147,7 @@ test.describe('Document grid item options', () => {
await page
.getByRole('button', {
name: 'Confirm deletion',
name: 'Delete document',
})
.click();
@@ -295,7 +293,7 @@ test.describe('Documents Grid', () => {
docs = result.results as SmallDoc[];
await expect(page.getByTestId('grid-loader')).toBeHidden();
await expect(page.locator('h4').getByText('All docs')).toBeVisible();
await expect(page.locator('h2').getByText('All docs')).toBeVisible();
const thead = page.getByTestId('docs-grid-header');
await expect(thead.getByText(/Name/i)).toBeVisible();

View File

@@ -25,7 +25,7 @@ test.describe('Doc Header', () => {
'It is the card information about the document.',
);
const docTitle = card.getByRole('textbox', { name: 'doc title input' });
const docTitle = card.getByRole('textbox', { name: 'Document title' });
await expect(docTitle).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
@@ -54,7 +54,7 @@ test.describe('Doc Header', () => {
test('it updates the title doc', async ({ page, browserName }) => {
await createDoc(page, 'doc-update', browserName, 1);
const docTitle = page.getByRole('textbox', { name: 'doc title input' });
const docTitle = page.getByRole('textbox', { name: 'Document title' });
await expect(docTitle).toBeVisible();
await docTitle.fill('Hello World');
await docTitle.blur();
@@ -66,7 +66,7 @@ test.describe('Doc Header', () => {
browserName,
}) => {
await createDoc(page, 'doc-update', browserName, 1);
const docTitle = page.getByRole('textbox', { name: 'doc title input' });
const docTitle = page.getByRole('textbox', { name: 'Document title' });
await expect(docTitle).toBeVisible();
await docTitle.fill('👍 Hello Emoji World');
await docTitle.blur();
@@ -75,22 +75,22 @@ test.describe('Doc Header', () => {
// Check the tree
const docTree = page.getByTestId('doc-tree');
await expect(docTree.getByText('Hello Emoji World')).toBeVisible();
await expect(docTree.getByLabel('Document emoji icon')).toBeVisible();
await expect(docTree.getByLabel('Simple document icon')).toBeHidden();
await expect(docTree.getByTestId('doc-emoji-icon')).toBeVisible();
await expect(docTree.getByTestId('doc-simple-icon')).toBeHidden();
await page.getByTestId('home-button').click();
// Check the documents grid
const gridRow = await getGridRow(page, 'Hello Emoji World');
await expect(gridRow.getByLabel('Document emoji icon')).toBeVisible();
await expect(gridRow.getByLabel('Simple document icon')).toBeHidden();
await expect(gridRow.getByTestId('doc-emoji-icon')).toBeVisible();
await expect(gridRow.getByTestId('doc-simple-icon')).toBeHidden();
});
test('it deletes the doc', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-delete', browserName, 1);
await page.getByLabel('Open the document options').click();
await page.getByLabel('Delete document').click();
await page.getByRole('menuitem', { name: 'Delete document' }).click();
await expect(
page.getByRole('heading', { name: 'Delete a doc' }),
@@ -100,7 +100,7 @@ test.describe('Doc Header', () => {
await page
.getByRole('button', {
name: 'Confirm deletion',
name: 'Delete document',
})
.click();
@@ -148,7 +148,9 @@ test.describe('Doc Header', () => {
await page.getByLabel('Open the document options').click();
await expect(page.getByLabel('Delete document')).toBeDisabled();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
@@ -164,7 +166,7 @@ test.describe('Doc Header', () => {
const invitationCard = shareModal.getByLabel('List invitation card');
await expect(invitationCard).toBeVisible();
await expect(
invitationCard.getByText('test@invitation.test').first(),
invitationCard.getByText('test.test@invitation.test').first(),
).toBeVisible();
const invitationRole = invitationCard.getByLabel('doc-role-dropdown');
await expect(invitationRole).toBeVisible();
@@ -178,7 +180,7 @@ test.describe('Doc Header', () => {
const roles = memberCard.getByLabel('doc-role-dropdown');
await expect(memberCard).toBeVisible();
await expect(
memberCard.getByText('test@accesses.test').first(),
memberCard.getByText('test.test@accesses.test').first(),
).toBeVisible();
await expect(roles).toBeVisible();
@@ -221,30 +223,36 @@ test.describe('Doc Header', () => {
).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(page.getByLabel('Delete document')).toBeDisabled();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
await page.getByRole('button', { name: 'Share' }).click();
const shareModal = page.getByLabel('Share modal');
const shareModal = page.getByRole('dialog', {
name: 'Share modal content',
});
await expect(shareModal).toBeVisible();
await expect(page.getByText('Share the document')).toBeVisible();
await expect(page.getByPlaceholder('Type a name or email')).toBeHidden();
const invitationCard = shareModal.getByLabel('List invitation card');
await expect(invitationCard).toBeVisible();
await expect(
invitationCard.getByText('test@invitation.test').first(),
invitationCard.getByText('test.test@invitation.test').first(),
).toBeVisible();
await expect(invitationCard.getByLabel('doc-role-text')).toBeVisible();
await expect(invitationCard.getByLabel('Document role text')).toBeVisible();
await expect(
invitationCard.getByRole('button', { name: 'more_horiz' }),
).toBeHidden();
const memberCard = shareModal.getByLabel('List members card');
await expect(memberCard.getByText('test@accesses.test')).toBeVisible();
await expect(memberCard.getByLabel('doc-role-text')).toBeVisible();
await expect(memberCard.getByText('test.test@accesses.test')).toBeVisible();
await expect(memberCard.getByLabel('Document role text')).toBeVisible();
await expect(
memberCard.getByRole('button', { name: 'more_horiz' }),
).toBeHidden();
@@ -283,7 +291,9 @@ test.describe('Doc Header', () => {
).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(page.getByLabel('Delete document')).toBeDisabled();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
@@ -296,17 +306,18 @@ test.describe('Doc Header', () => {
await expect(page.getByPlaceholder('Type a name or email')).toBeHidden();
const invitationCard = shareModal.getByLabel('List invitation card');
await expect(invitationCard).toBeVisible();
await expect(
invitationCard.getByText('test@invitation.test').first(),
invitationCard.getByText('test.test@invitation.test').first(),
).toBeVisible();
await expect(invitationCard.getByLabel('doc-role-text')).toBeVisible();
await expect(invitationCard.getByLabel('Document role text')).toBeVisible();
await expect(
invitationCard.getByRole('button', { name: 'more_horiz' }),
).toBeHidden();
const memberCard = shareModal.getByLabel('List members card');
await expect(memberCard.getByText('test@accesses.test')).toBeVisible();
await expect(memberCard.getByLabel('doc-role-text')).toBeVisible();
await expect(memberCard.getByText('test.test@accesses.test')).toBeVisible();
await expect(memberCard.getByLabel('Document role text')).toBeVisible();
await expect(
memberCard.getByRole('button', { name: 'more_horiz' }),
).toBeHidden();
@@ -338,7 +349,7 @@ test.describe('Doc Header', () => {
// Copy content to clipboard
await page.getByLabel('Open the document options').click();
await page.getByLabel('Copy as Markdown').click();
await page.getByRole('menuitem', { name: 'Copy as Markdown' }).click();
await expect(page.getByText('Copied to clipboard')).toBeVisible();
// Test that clipboard is in Markdown format
@@ -372,7 +383,7 @@ test.describe('Doc Header', () => {
// Copy content to clipboard
await page.getByLabel('Open the document options').click();
await page.getByLabel('Copy as HTML').click();
await page.getByRole('menuitem', { name: 'Copy as HTML' }).click();
await expect(page.getByText('Copied to clipboard')).toBeVisible();
// Test that clipboard is in HTML format
@@ -429,11 +440,15 @@ test.describe('Doc Header', () => {
test('it pins a document', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, `Pin doc`, browserName);
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', { name: 'Open the document options' })
.click();
// Pin
await page.getByText('push_pin').click();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', { name: 'Open the document options' })
.click();
await expect(page.getByText('Unpin')).toBeVisible();
await page.goto('/');
@@ -441,22 +456,26 @@ test.describe('Doc Header', () => {
const row = await getGridRow(page, docTitle);
// Check is pinned
await expect(row.locator('[data-testid^="doc-pinned-"]')).toBeVisible();
await expect(row.getByTestId('doc-pinned-icon')).toBeVisible();
const leftPanelFavorites = page.getByTestId('left-panel-favorites');
await expect(leftPanelFavorites.getByText(docTitle)).toBeVisible();
await row.getByText(docTitle).click();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', { name: 'Open the document options' })
.click();
// Unpin
await page.getByText('Unpin').click();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', { name: 'Open the document options' })
.click();
await expect(page.getByText('push_pin')).toBeVisible();
await page.goto('/');
// Check is unpinned
await expect(row.locator('[data-testid^="doc-pinned-"]')).toBeHidden();
await expect(row.getByTestId('doc-pinned-icon')).toBeHidden();
await expect(leftPanelFavorites.getByText(docTitle)).toBeHidden();
});
@@ -555,7 +574,7 @@ test.describe('Documents Header mobile', () => {
await expect(
page.getByRole('menuitem', { name: 'Copy link' }),
).toBeVisible();
await page.getByLabel('Share').click();
await page.getByRole('menuitem', { name: 'Share' }).click();
await expect(page.getByRole('button', { name: 'Copy link' })).toBeVisible();
});
@@ -578,7 +597,7 @@ test.describe('Documents Header mobile', () => {
await goToGridDoc(page);
await page.getByLabel('Open the document options').click();
await page.getByLabel('Share').click();
await page.getByRole('menuitem', { name: 'Share' }).click();
const shareModal = page.getByRole('dialog', {
name: 'Share modal content',

View File

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

View File

@@ -7,6 +7,7 @@ import {
randomName,
verifyDocName,
} from './utils-common';
import { connectOtherUserToDoc, updateRoleUser } from './utils-share';
import { createRootSubPage } from './utils-sub-pages';
test.describe('Document create member', () => {
@@ -15,7 +16,7 @@ test.describe('Document create member', () => {
});
test('it selects 2 users and 1 invitation', async ({ page, browserName }) => {
const inputFill = 'user ';
const inputFill = 'user.test';
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes(`/users/?q=${encodeURIComponent(inputFill)}`) &&
@@ -25,9 +26,8 @@ test.describe('Document create member', () => {
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByRole('combobox', {
name: 'Quick search input',
});
const inputSearch = page.getByTestId('quick-search-input');
await expect(inputSearch).toBeVisible();
// Select user 1 and verify tag
@@ -74,13 +74,15 @@ test.describe('Document create member', () => {
// Check roles are displayed
await list.getByLabel('doc-role-dropdown').click();
await expect(page.getByLabel('Reader')).toBeVisible();
await expect(page.getByLabel('Editor')).toBeVisible();
await expect(page.getByLabel('Owner')).toBeVisible();
await expect(page.getByLabel('Administrator')).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Reader' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Editor' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Owner' })).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Administrator' }),
).toBeVisible();
// Validate
await page.getByLabel('Administrator').click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Invite' }).click();
// Check invitation added
@@ -117,9 +119,7 @@ test.describe('Document create member', () => {
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByRole('combobox', {
name: 'Quick search input',
});
const inputSearch = page.getByTestId('quick-search-input');
const [email] = randomName('test@test.fr', browserName, 1);
await inputSearch.fill(email);
@@ -128,7 +128,7 @@ test.describe('Document create member', () => {
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByLabel('doc-role-dropdown').click();
await page.getByLabel('Owner').click();
await page.getByRole('menuitem', { name: 'Owner' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
@@ -146,7 +146,7 @@ test.describe('Document create member', () => {
// Choose a role
await container.getByLabel('doc-role-dropdown').click();
await page.getByLabel('Owner').click();
await page.getByRole('menuitem', { name: 'Owner' }).click();
const responsePromiseCreateInvitationFail = page.waitForResponse(
(response) =>
@@ -167,9 +167,7 @@ test.describe('Document create member', () => {
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByRole('combobox', {
name: 'Quick search input',
});
const inputSearch = page.getByTestId('quick-search-input');
const email = randomName('test@test.fr', browserName, 1)[0];
await inputSearch.fill(email);
@@ -178,7 +176,7 @@ test.describe('Document create member', () => {
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByLabel('doc-role-dropdown').click();
await page.getByLabel('Administrator').click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
@@ -198,21 +196,17 @@ test.describe('Document create member', () => {
await expect(userInvitation).toBeVisible();
await userInvitation.getByLabel('doc-role-dropdown').click();
await page.getByLabel('Reader').click();
await page.getByRole('menuitem', { name: 'Reader' }).click();
const moreActions = userInvitation.getByRole('button', {
name: 'more_horiz',
name: 'Open invitation actions menu',
});
await moreActions.click();
await page.getByLabel('Delete').click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await expect(userInvitation).toBeHidden();
});
});
test.describe('Document create member: Multiple login', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('It creates a member from a request coming from a 403 page', async ({
page,
@@ -220,9 +214,6 @@ test.describe('Document create member: Multiple login', () => {
}) => {
test.slow();
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docTitle] = await createDoc(
page,
'Member access request',
@@ -232,67 +223,67 @@ test.describe('Document create member: Multiple login', () => {
await verifyDocName(page, docTitle);
await page
.locator('.ProseMirror')
.locator('.bn-block-outer')
.last()
.fill('Hello World');
const urlDoc = page.url();
await page
.getByRole('button', {
name: 'Logout',
})
.click();
const otherBrowser = BROWSERS.find((b) => b !== browserName);
await keyCloakSignIn(page, otherBrowser!);
await expect(page.getByTestId('header-logo-link')).toBeVisible();
await page.goto(urlDoc);
// Other user will request access
const { otherPage, otherBrowserName, cleanup } =
await connectOtherUserToDoc(browserName, urlDoc);
await expect(
page.getByText('Insufficient access rights to view the document.'),
otherPage.getByText('Insufficient access rights to view the document.'),
).toBeVisible({
timeout: 10000,
});
await page.getByRole('button', { name: 'Request access' }).click();
await otherPage.getByRole('button', { name: 'Request access' }).click();
await expect(
page.getByText('Your access request for this document is pending.'),
otherPage.getByText('Your access request for this document is pending.'),
).toBeVisible();
await page
.getByRole('button', {
name: 'Logout',
})
.click();
await page.goto('/');
await keyCloakSignIn(page, browserName);
await expect(page.getByTestId('header-logo-link')).toBeVisible({
timeout: 10000,
});
await page.goto(urlDoc);
// First user approves the request
await page.getByRole('button', { name: 'Share' }).click();
await expect(page.getByText('Access Requests')).toBeVisible();
await expect(page.getByText(`E2E ${otherBrowser}`)).toBeVisible();
await expect(page.getByText(`E2E ${otherBrowserName}`)).toBeVisible();
const emailRequest = `user@${otherBrowser}.test`;
const emailRequest = `user.test@${otherBrowserName}.test`;
await expect(page.getByText(emailRequest)).toBeVisible();
const container = page.getByTestId(
`doc-share-access-request-row-${emailRequest}`,
);
await container.getByLabel('doc-role-dropdown').click();
await page.getByLabel('Administrator').click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await container.getByRole('button', { name: 'Approve' }).click();
await expect(page.getByText('Access Requests')).toBeHidden();
await expect(page.getByText('Share with 2 users')).toBeVisible();
await expect(page.getByText(`E2E ${otherBrowser}`)).toBeVisible();
await expect(page.getByText(`E2E ${otherBrowserName}`)).toBeVisible();
// Other user verifies he has access
await otherPage.reload();
await verifyDocName(otherPage, docTitle);
await expect(otherPage.getByText('Hello World')).toBeVisible();
// Revoke access
await updateRoleUser(page, 'Remove access', emailRequest);
await expect(
otherPage.getByText('Insufficient access rights to view the document.'),
).toBeVisible();
// Cleanup: other user logout
await cleanup();
});
});
test.describe('Document create member: Multiple login', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('It cannot request member access on child doc on a 403 page', async ({
page,

View File

@@ -139,7 +139,7 @@ test.describe('Document list members', () => {
const list = page.getByTestId('doc-share-quick-search');
await expect(list).toBeVisible();
const currentUser = list.getByTestId(
`doc-share-member-row-user@${browserName}.test`,
`doc-share-member-row-user.test@${browserName}.test`,
);
const currentUserRole = currentUser.getByLabel('doc-role-dropdown');
await expect(currentUser).toBeVisible();
@@ -171,12 +171,12 @@ test.describe('Document list members', () => {
});
await currentUserRole.click();
await page.getByLabel('Administrator').click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await list.click();
await expect(currentUserRole).toBeVisible();
await currentUserRole.click();
await page.getByLabel('Reader').click();
await page.getByRole('menuitem', { name: 'Reader' }).click();
await list.click();
await expect(currentUserRole).toBeHidden();
});
@@ -190,7 +190,7 @@ test.describe('Document list members', () => {
const list = page.getByTestId('doc-share-quick-search');
const emailMyself = `user@${browserName}.test`;
const emailMyself = `user.test@${browserName}.test`;
const mySelf = list.getByTestId(`doc-share-member-row-${emailMyself}`);
const mySelfRole = mySelf.getByRole('button', {
name: 'doc-role-dropdown',

View File

@@ -60,34 +60,45 @@ test.describe('Doc Routing', () => {
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
const responsePromise = page.route(
/.*\/documents\/.*\/$|users\/me\/$/,
async (route) => {
const request = route.request();
// Wait for the doc link (via its dynamic title) to be visible
const docLink = page.getByRole('link', { name: docTitle });
await expect(docLink).toBeVisible();
if (
request.method().includes('PATCH') ||
request.method().includes('GET')
) {
await route.fulfill({
status: 401,
json: {
detail: 'Log in to access the document',
},
});
} else {
await route.continue();
}
},
// Intercept GET/PATCH requests to return 401
await page.route(/.*\/documents\/.*\/$|users\/me\/$/, async (route) => {
const request = route.request();
if (
request.method().includes('PATCH') ||
request.method().includes('GET')
) {
await route.fulfill({
status: 401,
json: { detail: 'Log in to access the document' },
});
} else {
await route.continue();
}
});
// Explicitly wait for a 401 response after clicking
const wait401 = page.waitForResponse(
(resp) =>
resp.status() === 401 &&
/\/(documents\/[^/]+\/|users\/me\/)$/.test(resp.url()),
);
await page.getByRole('link', { name: '401-doc-parent' }).click();
await docLink.click();
await wait401;
await responsePromise;
await expect(page.getByText('Log in to access the document')).toBeVisible({
await expect(page.getByText('Log in to access the document.')).toBeVisible({
timeout: 10000,
});
await expect(page.locator('meta[name="robots"]')).toHaveAttribute(
'content',
'noindex',
);
await expect(page).toHaveTitle(/401 Unauthorized - Docs/);
});
});

View File

@@ -33,7 +33,7 @@ test.describe('Document search', () => {
).toBeVisible();
await expect(
page.getByLabel('Search modal').getByText('search'),
page.getByRole('heading', { name: 'Search docs' }),
).toBeVisible();
const inputSearch = page.getByPlaceholder('Type the name of a document');
@@ -79,7 +79,7 @@ test.describe('Document search', () => {
await page.keyboard.press('Control+k');
await expect(
page.getByLabel('Search modal').getByText('search'),
page.getByRole('heading', { name: 'Search docs' }),
).toBeVisible();
await page.keyboard.press('Escape');
@@ -173,12 +173,13 @@ test.describe('Document search', () => {
.getByRole('combobox', { name: 'Quick search input' })
.fill('sub page search');
// Expect to find the first doc
// Expect to find the first and second docs in the results list
const resultsList = page.getByRole('listbox');
await expect(
page.getByRole('presentation').getByLabel(firstDocTitle),
resultsList.getByRole('option', { name: firstDocTitle }),
).toBeVisible();
await expect(
page.getByRole('presentation').getByLabel(secondDocTitle),
resultsList.getByRole('option', { name: secondDocTitle }),
).toBeVisible();
await page.getByRole('button', { name: 'close' }).click();
@@ -195,14 +196,15 @@ test.describe('Document search', () => {
.fill('second');
// Now there is a sub page - expect to have the focus on the current doc
const updatedResultsList = page.getByRole('listbox');
await expect(
page.getByRole('presentation').getByLabel(secondDocTitle),
updatedResultsList.getByRole('option', { name: secondDocTitle }),
).toBeVisible();
await expect(
page.getByRole('presentation').getByLabel(secondChildDocTitle),
updatedResultsList.getByRole('option', { name: secondChildDocTitle }),
).toBeVisible();
await expect(
page.getByRole('presentation').getByLabel(firstDocTitle),
updatedResultsList.getByRole('option', { name: firstDocTitle }),
).toBeHidden();
});
});

View File

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

View File

@@ -50,7 +50,7 @@ test.describe('Doc Tree', () => {
await expect(subPageItem).toBeVisible();
await subPageItem.click();
await verifyDocName(page, '');
const input = page.getByRole('textbox', { name: 'doc title input' });
const input = page.getByRole('textbox', { name: 'Document title' });
await input.click();
const [randomDocName] = randomName('doc-tree-test', browserName, 1);
await input.fill(randomDocName);
@@ -196,7 +196,7 @@ test.describe('Doc Tree', () => {
await page.getByText('Move to my docs').click();
await expect(
page.getByRole('textbox', { name: 'doc title input' }),
page.getByRole('textbox', { name: 'Document title' }),
).not.toHaveText(docChild);
const header = page.locator('header').first();
@@ -220,11 +220,11 @@ test.describe('Doc Tree', () => {
const list = page.getByTestId('doc-share-quick-search');
const currentUser = list.getByTestId(
`doc-share-member-row-user@${browserName}.test`,
`doc-share-member-row-user.test@${browserName}.test`,
);
const currentUserRole = currentUser.getByLabel('doc-role-dropdown');
await currentUserRole.click();
await page.getByLabel('Administrator').click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await list.click();
await page.getByRole('button', { name: 'Ok' }).click();
@@ -235,6 +235,12 @@ test.describe('Doc Tree', () => {
'doc-tree-detach-child',
);
await expect(
page
.getByLabel('It is the card information about the document.')
.getByText('Administrator ·'),
).toBeVisible();
const docTree = page.getByTestId('doc-tree');
await expect(docTree.getByText(docChild)).toBeVisible();
await docTree.click();
@@ -252,6 +258,46 @@ test.describe('Doc Tree', () => {
page.getByRole('menuitem', { name: 'Move to my docs' }),
).toHaveAttribute('aria-disabled', 'true');
});
test('keyboard navigation with Enter key opens documents', async ({
page,
browserName,
}) => {
// Create a parent document
const [docParent] = await createDoc(
page,
'doc-tree-keyboard-nav',
browserName,
1,
);
await verifyDocName(page, docParent);
// Create a sub-document
const { name: docChild } = await createRootSubPage(
page,
browserName,
'doc-tree-keyboard-child',
);
const docTree = page.getByTestId('doc-tree');
await expect(docTree).toBeVisible();
// Test keyboard navigation on root document
const rootItem = page.getByTestId('doc-tree-root-item');
await expect(rootItem).toBeVisible();
// Focus on the root item and press Enter
await rootItem.focus();
await expect(rootItem).toBeFocused();
await page.keyboard.press('Enter');
// Verify we navigated to the root document
await verifyDocName(page, docParent);
await expect(page).toHaveURL(/\/docs\/[^/]+\/?$/);
// Now test keyboard navigation on sub-document
await expect(docTree.getByText(docChild)).toBeVisible();
});
});
test.describe('Doc Tree: Inheritance', () => {

View File

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

View File

@@ -7,6 +7,7 @@ import {
keyCloakSignIn,
verifyDocName,
} from './utils-common';
import { addNewMember, connectOtherUserToDoc } from './utils-share';
import { createRootSubPage } from './utils-sub-pages';
test.describe('Doc Visibility', () => {
@@ -44,17 +45,21 @@ test.describe('Doc Visibility', () => {
await expect(selectVisibility.getByText('Private')).toBeVisible();
await expect(page.getByLabel('Read only')).toBeHidden();
await expect(page.getByLabel('Can read and edit')).toBeHidden();
await expect(
page.getByRole('menuitem', { name: 'Read only' }),
).toBeHidden();
await expect(
page.getByRole('menuitem', { name: 'Can read and edit' }),
).toBeHidden();
await selectVisibility.click();
await page.getByLabel('Connected').click();
await page.getByRole('menuitem', { name: 'Connected' }).click();
await expect(page.getByTestId('doc-access-mode')).toBeVisible();
await selectVisibility.click();
await page.getByLabel('Public', { exact: true }).click();
await page.getByRole('menuitem', { name: 'Public' }).click();
await expect(page.getByTestId('doc-access-mode')).toBeVisible();
});
@@ -146,47 +151,31 @@ test.describe('Doc Visibility: Restricted', () => {
await verifyDocName(page, docTitle);
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByRole('combobox', {
name: 'Quick search input',
});
const otherBrowser = BROWSERS.find((b) => b !== browserName);
if (!otherBrowser) {
throw new Error('No alternative browser found');
}
const username = `user@${otherBrowser}.test`;
await inputSearch.fill(username);
await page.getByRole('option', { name: username }).click();
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByLabel('doc-role-dropdown').click();
await page.getByLabel('Reader').click();
await page.getByRole('button', { name: 'Invite' }).click();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
await page
.locator('.ProseMirror')
.locator('.bn-block-outer')
.last()
.fill('Hello World');
const urlDoc = page.url();
await page
.getByRole('button', {
name: 'Logout',
})
.click();
const { otherBrowserName, otherPage } = await connectOtherUserToDoc(
browserName,
urlDoc,
);
await keyCloakSignIn(page, otherBrowser);
await expect(
otherPage.getByText('Insufficient access rights to view the document.'),
).toBeVisible({
timeout: 10000,
});
await expect(page.getByTestId('header-logo-link')).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await page.goto(urlDoc);
await addNewMember(page, 0, 'Reader', otherBrowserName);
await verifyDocName(page, docTitle);
await expect(page.getByLabel('Share button')).toBeVisible();
await otherPage.reload();
await expect(otherPage.getByText('Hello World')).toBeVisible();
});
});
@@ -308,7 +297,7 @@ test.describe('Doc Visibility: Public', () => {
).toBeVisible();
await page.getByTestId('doc-access-mode').click();
await page.getByLabel('Editing').click();
await page.getByRole('menuitem', { name: 'Editing' }).click();
await expect(
page.getByText('The document visibility has been updated.').first(),
@@ -531,7 +520,7 @@ test.describe('Doc Visibility: Authenticated', () => {
const urlDoc = page.url();
await page.getByTestId('doc-access-mode').click();
await page.getByLabel('Editing').click();
await page.getByRole('menuitem', { name: 'Editing' }).click();
await expect(
page.getByText('The document visibility has been updated.').first(),

View File

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

View File

@@ -131,7 +131,7 @@ test.describe('Home page', () => {
// Keyclock login page
await expect(
page.locator('.login-pf-page-header').getByText('impress'),
page.locator('.login-pf #kc-header-wrapper').getByText('impress'),
).toBeVisible();
});
});

View File

@@ -1,6 +1,7 @@
import { Page, expect } from '@playwright/test';
export const BROWSERS = ['chromium', 'webkit', 'firefox'];
export type BrowserName = 'chromium' | 'firefox' | 'webkit';
export const BROWSERS: BrowserName[] = ['chromium', 'webkit', 'firefox'];
export const CONFIG = {
AI_FEATURE_ENABLED: true,
@@ -56,7 +57,7 @@ export const keyCloakSignIn = async (
const password = `password-e2e-${browserName}`;
await expect(
page.locator('.login-pf-page-header').getByText('impress'),
page.locator('.login-pf #kc-header-wrapper').getByText('impress'),
).toBeVisible();
if (await page.getByLabel('Restart login').isVisible()) {
@@ -65,7 +66,7 @@ export const keyCloakSignIn = async (
await page.getByRole('textbox', { name: 'username' }).fill(login);
await page.getByRole('textbox', { name: 'password' }).fill(password);
await page.click('input[type="submit"]', { force: true });
await page.click('button[type="submit"]', { force: true });
};
export const randomName = (name: string, browserName: string, length: number) =>
@@ -101,10 +102,9 @@ export const createDoc = async (
waitUntil: 'networkidle',
});
const input = page.getByLabel('doc title input');
const input = page.getByLabel('Document title');
await expect(input).toBeVisible();
await expect(input).toHaveText('');
await input.click();
await input.fill(randomDocs[i]);
await input.blur();
@@ -120,10 +120,11 @@ export const verifyDocName = async (page: Page, docName: string) => {
timeout: 10000,
});
/*replace toHaveText with toContainText to handle cases where emojis or other characters might be added*/
try {
await expect(
page.getByRole('textbox', { name: 'doc title input' }),
).toHaveText(docName);
page.getByRole('textbox', { name: 'Document title' }),
).toContainText(docName);
} catch {
await expect(page.getByRole('heading', { name: docName })).toBeVisible();
}
@@ -172,7 +173,7 @@ export const goToGridDoc = async (
await expect(row).toBeVisible();
const docTitleContent = row.locator('[aria-describedby="doc-title"]').first();
const docTitleContent = row.getByTestId('doc-title').first();
const docTitle = await docTitleContent.textContent();
expect(docTitle).toBeDefined();
@@ -182,9 +183,9 @@ export const goToGridDoc = async (
};
export const updateDocTitle = async (page: Page, title: string) => {
const input = page.getByLabel('doc title input');
await expect(input).toBeVisible();
const input = page.getByRole('textbox', { name: 'Document title' });
await expect(input).toHaveText('');
await expect(input).toBeVisible();
await input.click();
await input.fill(title);
await input.click();
@@ -322,5 +323,5 @@ export async function waitForLanguageSwitch(
await languagePicker.click();
await page.getByLabel(lang.label).click();
await page.getByRole('menuitem', { name: lang.label }).click();
}

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import { Page, expect } from '@playwright/test';
import {
randomName,
updateDocTitle,
verifyDocName,
waitForResponseCreateDoc,
} from './utils-common';
@@ -63,5 +64,17 @@ export const clickOnAddRootSubPage = async (page: Page) => {
const rootItem = page.getByTestId('doc-tree-root-item');
await expect(rootItem).toBeVisible();
await rootItem.hover();
await rootItem.getByRole('button', { name: 'add_box' }).click();
await rootItem.getByTestId('doc-tree-item-actions-add-child').click();
};
export const navigateToPageFromTree = async ({
page,
title,
}: {
page: Page;
title: string;
}) => {
const docTree = page.getByTestId('doc-tree');
await docTree.getByText(title).click();
await verifyDocName(page, title);
};

View File

@@ -1,6 +1,9 @@
{
"name": "app-e2e",
"version": "3.6.0",
"version": "3.7.0",
"repository": "https://github.com/suitenumerique/docs",
"author": "DINUM",
"license": "MIT",
"private": true,
"scripts": {
"lint": "eslint",
@@ -21,5 +24,6 @@
"dependencies": {
"convert-stream": "1.0.2",
"pdf-parse": "1.1.1"
}
},
"packageManager": "yarn@1.22.22"
}

View File

@@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.

View File

@@ -1,6 +1,9 @@
{
"name": "app-impress",
"version": "3.6.0",
"version": "3.7.0",
"repository": "https://github.com/suitenumerique/docs",
"author": "DINUM",
"license": "MIT",
"private": true,
"scripts": {
"dev": "next dev",
@@ -33,22 +36,22 @@
"@hocuspocus/provider": "2.15.2",
"@openfun/cunningham-react": "3.2.3",
"@react-pdf/renderer": "4.3.0",
"@sentry/nextjs": "10.8.0",
"@tanstack/react-query": "5.85.6",
"@sentry/nextjs": "10.11.0",
"@tanstack/react-query": "5.87.4",
"canvg": "4.0.3",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"crisp-sdk-web": "1.0.25",
"docx": "9.5.0",
"emoji-mart": "5.6.0",
"emoji-regex": "10.4.0",
"i18next": "25.4.2",
"emoji-regex": "10.5.0",
"i18next": "25.5.2",
"i18next-browser-languagedetector": "8.2.0",
"idb": "8.0.3",
"lodash": "4.17.21",
"luxon": "3.7.1",
"next": "15.4.7",
"posthog-js": "1.261.0",
"luxon": "3.7.2",
"next": "15.5.3",
"posthog-js": "1.264.2",
"react": "*",
"react-aria-components": "1.12.1",
"react-dom": "*",
@@ -56,14 +59,14 @@
"react-intersection-observer": "9.16.0",
"react-select": "5.10.2",
"styled-components": "6.1.19",
"use-debounce": "10.0.5",
"use-debounce": "10.0.6",
"y-protocols": "1.0.6",
"yjs": "*",
"zustand": "5.0.8"
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.85.6",
"@tanstack/react-query-devtools": "5.87.4",
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "6.8.0",
"@testing-library/react": "16.3.0",
@@ -75,13 +78,13 @@
"@types/react-dom": "*",
"@vitejs/plugin-react": "5.0.2",
"cross-env": "10.0.0",
"dotenv": "17.2.1",
"dotenv": "17.2.2",
"eslint-plugin-docs": "*",
"fetch-mock": "9.11.0",
"jsdom": "26.1.0",
"node-fetch": "2.7.0",
"prettier": "3.6.2",
"stylelint": "16.23.1",
"stylelint": "16.24.0",
"stylelint-config-standard": "39.0.0",
"stylelint-prettier": "5.0.3",
"typescript": "*",
@@ -89,5 +92,6 @@
"vitest": "3.2.4",
"webpack": "5.101.3",
"workbox-webpack-plugin": "7.1.0"
}
},
"packageManager": "yarn@1.22.22"
}

View File

@@ -28,17 +28,18 @@ const StyledButton = styled(Button)<StyledButtonProps>`
border: none;
background: none;
outline: none;
transition: all 0.2s ease-in-out;
font-weight: 500;
font-size: 0.938rem;
padding: 0;
${({ $css }) => $css};
&:hover {
background-color: var(
--c--components--button--primary-text--background--color-hover
);
}
&:focus-visible {
outline: 2px solid var(--c--theme--colors--primary-500);
outline-offset: 2px;
box-shadow: 0 0 0 2px var(--c--theme--colors--primary-400);
border-radius: 4px;
transition: none;
}
`;

View File

@@ -12,9 +12,14 @@ export const Icon = ({
variant = 'outlined',
...textProps
}: IconProps) => {
const hasLabel = 'aria-label' in textProps || 'aria-labelledby' in textProps;
const ariaHidden =
'aria-hidden' in textProps ? textProps['aria-hidden'] : !hasLabel;
return (
<Text
{...textProps}
aria-hidden={ariaHidden}
className={clsx('--docs--icon-bg', textProps.className, {
'material-icons-filled': variant === 'filled',
'material-icons': variant === 'outlined',

View File

@@ -13,6 +13,7 @@ export interface TextProps extends BoxProps {
$ellipsis?: boolean;
$weight?: CSSProperties['fontWeight'];
$textAlign?: CSSProperties['textAlign'];
$textTransform?: CSSProperties['textTransform'];
$size?: TextSizes | (string & {});
$theme?:
| 'primary'
@@ -43,6 +44,8 @@ export type TextType = ComponentPropsWithRef<typeof Text>;
export const TextStyled = styled(Box)<TextProps>`
${({ $textAlign }) => $textAlign && `text-align: ${$textAlign};`}
${({ $textTransform }) =>
$textTransform && `text-transform: ${$textTransform};`}
${({ $weight }) => $weight && `font-weight: ${$weight};`}
${({ $size }) =>
$size &&

View File

@@ -110,7 +110,6 @@ export const DropdownMenu = ({
$direction="row"
$align="center"
$position="relative"
aria-controls="menu"
>
<Box>{children}</Box>
<Icon
@@ -125,9 +124,7 @@ export const DropdownMenu = ({
/>
</Box>
) : (
<Box ref={blockButtonRef} aria-controls="menu">
{children}
</Box>
<Box ref={blockButtonRef}>{children}</Box>
)
}
>
@@ -165,7 +162,6 @@ export const DropdownMenu = ({
menuItemRefs.current[index] = el;
}}
role="menuitem"
aria-label={option.label}
data-testid={option.testId}
$direction="row"
disabled={isDisabled}
@@ -207,14 +203,13 @@ export const DropdownMenu = ({
}
&:focus-visible {
outline: 2px solid var(--c--theme--colors--primary-500);
outline: 2px solid var(--c--theme--colors--primary-400);
outline-offset: -2px;
background-color: var(--c--theme--colors--greyscale-050);
}
${isFocused &&
css`
outline: 2px solid var(--c--theme--colors--primary-500);
outline-offset: -2px;
background-color: var(--c--theme--colors--greyscale-050);
`}
@@ -231,6 +226,7 @@ export const DropdownMenu = ({
$theme="greyscale"
$variation={isDisabled ? '400' : '1000'}
iconName={option.icon}
aria-hidden="true"
/>
)}
<Text $variation={isDisabled ? '400' : '1000'}>
@@ -239,7 +235,12 @@ export const DropdownMenu = ({
</Box>
{(option.isSelected ||
selectedValues?.includes(option.value ?? '')) && (
<Icon iconName="check" $size="20px" $theme="greyscale" />
<Icon
iconName="check"
$size="20px"
$theme="greyscale"
aria-hidden="true"
/>
)}
</BoxButton>
{option.showSeparator && (

View File

@@ -30,15 +30,23 @@ export const AlertModal = ({
isOpen={isOpen}
size={ModalSize.MEDIUM}
onClose={onClose}
aria-describedby="alert-modal-title"
title={
<Text $size="h6" $align="flex-start" $variation="1000">
<Text
$size="h6"
as="h1"
$margin="0"
id="alert-modal-title"
$align="flex-start"
$variation="1000"
>
{title}
</Text>
}
rightActions={
<>
<Button
aria-label={t('Close the modal')}
aria-label={`${t('Cancel')} - ${title}`}
color="secondary"
fullWidth
onClick={() => onClose()}
@@ -55,12 +63,11 @@ export const AlertModal = ({
</>
}
>
<Box
aria-label={t('Confirmation button')}
className="--docs--alert-modal"
>
<Box className="--docs--alert-modal">
<Box>
<Text $variation="600">{description}</Text>
<Text $variation="600" as="p">
{description}
</Text>
</Box>
</Box>
</Modal>

View File

@@ -1,11 +1,5 @@
import { Command } from 'cmdk';
import {
PropsWithChildren,
ReactNode,
useEffect,
useRef,
useState,
} from 'react';
import { PropsWithChildren, ReactNode, useId, useRef, useState } from 'react';
import { hasChildrens } from '@/utils/children';
@@ -49,32 +43,23 @@ export const QuickSearch = ({
children,
}: PropsWithChildren<QuickSearchProps>) => {
const ref = useRef<HTMLDivElement | null>(null);
const [selectedValue, setSelectedValue] = useState<string>('');
const listId = useId();
const NO_SELECTION_VALUE = '__none__';
const [userInteracted, setUserInteracted] = useState(false);
const [selectedValue, setSelectedValue] = useState(NO_SELECTION_VALUE);
const isExpanded = userInteracted;
// Auto-select first item when children change
useEffect(() => {
if (!children) {
setSelectedValue('');
return;
const handleValueChange = (val: string) => {
if (userInteracted) {
setSelectedValue(val);
}
};
// Small delay for DOM to update
const timeoutId = setTimeout(() => {
const firstItem = ref.current?.querySelector('[cmdk-item]');
if (firstItem) {
const value =
firstItem.getAttribute('data-value') ||
firstItem.getAttribute('value') ||
firstItem.textContent?.trim() ||
'';
if (value) {
setSelectedValue(value);
}
}
}, 50);
return () => clearTimeout(timeoutId);
}, [children]);
const handleUserInteract = () => {
if (!userInteracted) {
setUserInteracted(true);
}
};
return (
<>
@@ -84,9 +69,9 @@ export const QuickSearch = ({
label={label}
shouldFilter={false}
ref={ref}
value={selectedValue}
onValueChange={setSelectedValue}
tabIndex={0}
value={selectedValue}
onValueChange={handleValueChange}
>
{showInput && (
<QuickSearchInput
@@ -95,11 +80,14 @@ export const QuickSearch = ({
inputValue={inputValue}
onFilter={onFilter}
placeholder={placeholder}
listId={listId}
isExpanded={isExpanded}
onUserInteract={handleUserInteract}
>
{inputContent}
</QuickSearchInput>
)}
<Command.List>
<Command.List id={listId} aria-label={label} role="listbox">
<Box>{children}</Box>
</Command.List>
</Command>

View File

@@ -16,6 +16,9 @@ type Props = {
placeholder?: string;
children?: ReactNode;
withSeparator?: boolean;
listId?: string;
onUserInteract?: () => void;
isExpanded?: boolean;
};
export const QuickSearchInput = ({
loading,
@@ -24,6 +27,9 @@ export const QuickSearchInput = ({
placeholder,
children,
withSeparator: separator = true,
listId,
onUserInteract,
isExpanded,
}: Props) => {
const { t } = useTranslation();
const { spacingsTokens } = useCunninghamTheme();
@@ -57,13 +63,19 @@ export const QuickSearchInput = ({
<Command.Input
autoFocus={true}
aria-label={t('Quick search input')}
aria-expanded={isExpanded}
aria-controls={listId}
onClick={(e) => {
e.stopPropagation();
onUserInteract?.();
}}
onKeyDown={() => onUserInteract?.()}
value={inputValue}
role="combobox"
placeholder={placeholder ?? t('Search')}
onValueChange={onFilter}
maxLength={254}
data-testid="quick-search-input"
/>
</Box>
{separator && <HorizontalSeparator $withPadding={false} />}

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