Compare commits

...

90 Commits

Author SHA1 Message Date
Anthony LC
2cbd43caae 🔧(y-provider) increase Node.js memory limit
By default, Node.js has a memory limit of
around 512MB, which can lead to out-of-memory
errors when processing large documents.
This commit increases the memory limit to
2GB for the y-provider server, allowing
it to handle larger documents without crashing.
2026-03-25 17:14:27 +01:00
Anthony LC
525d8c8417 🐛(y-provider) destroy Y.Doc instances after each convert request
The Yjs reader and writer in `convertHandler.ts`
were creating `Y.Doc`instances on every request
without calling `.destroy()`, causing a slow heap
leak that could crash the server.

Fixed by wrapping both sites in `try/finally`
blocks that call `ydoc.destroy()`.
Regression tests added to assert `destroy` is
called the expected number of times per request path.
2026-03-25 12:03:12 +01:00
Cyril
c886cbb41d ️(frontend) fix language dropdown ARIA for screen readers
Add missing attributes for language picker.
2026-03-25 11:08:17 +01:00
Cyril
98f3ca2763 ️(frontend) improve BoxButton a11y and native button semantics
Add type="button", aria-disabled, and align refs with HTMLButtonElement.
2026-03-25 10:05:49 +01:00
Anthony LC
fb92a43755 🚸(frontend) hint min char search users
We give a hint to the user about the minimum
number of characters required to perform a search
in the quick search input of the doc share modal.
This is to improve the user experience.
2026-03-25 09:33:14 +01:00
Anthony LC
03fd1fe50e (frontend) fix vitest tests
We upgraded vitest recently, we need to adapt
some of our tests to the new version.
We brought some modules improvments as well,
problemes that was highlighted by the new version
of vitest.
2026-03-24 16:48:40 +01:00
Anthony LC
fc803226ac 🔒️(js) fix security warning
Force the upgrade of some dependencies to fix
security warnings.
2026-03-24 15:54:34 +01:00
Anthony LC
fb725edda3 🚨(frontend) fix eslint errors
Recent upgrade of eslint-plugin-playwright
highlighted some errors.
This commit fixes those errors.
2026-03-24 13:01:52 +01:00
Anthony LC
6838b387a2 (linter) replace eslint-plugin-import by eslint-plugin-import-x
"eslint-plugin-import" is not well maintained anymore
better to use "eslint-plugin-import-x" which is a fork
of "eslint-plugin-import" and is actively maintained.
2026-03-24 13:01:51 +01:00
Anthony LC
87f570582f ⬇️(frontend) downgrade @react-pdf/renderer and pin it
@react-pdf/renderer is not compatible with the
Blocknote version. We need to downgrade it to a
compatible version and pin it to avoid future issues.
When Blocknote updates to a compatible version,
we can upgrade @react-pdf/renderer again.
2026-03-24 13:01:51 +01:00
Anthony LC
37f56fcc22 📌(frontend) blocked upgrade stylelint
stylelint introduces lot of breaking changes
in its latest version, and since
we use it only for linting css files,
so we can block its upgrade for now and upgrade
it later when we will have more time to handle
the breaking changes.
2026-03-24 13:00:46 +01:00
renovate[bot]
19aa3a36bc ⬆️(dependencies) update js dependencies 2026-03-24 13:00:04 +01:00
ZouicheOmar
0d09f761dc 💄(frontend) improve comments highlights
Updated comments styles to respect design proposal,
adding distinguishable highlighting, click and hover
style interactions.
2026-03-24 09:38:31 +01:00
Manuel Raynaud
ce5f9a1417 🔖(patch) release 4.8.3
Changed

- 💫(frontend) fix the help button to the bottom in tree #2073
- ️(frontend) improve version history list accessibility #2033
- ️(frontend) fix more options menu feedback for screen readers #2071
- (frontend) focus skip link on headings and skip grid dropzone #1983
- ️(frontend) fix search modal accessibility issues #2054
- ️(frontend) add sr-only format to export download button #2088
- ️(frontend) announce formatting shortcuts for screen readers #2070
- (frontend) add markdown copy icon for Copy as Markdown option #2096
- ♻️(backend) skip saving in database a document when payload is empty #2062

Fixed

- ️(frontend) fix aria-labels for table of contents #2065
- 🐛(backend) allow using search endpoint without refresh token enabled #2097
2026-03-23 17:32:50 +01:00
Anthony LC
83a24c3796 ️(frontend) add debounce WebSocket reconnect
We add a debounce mechanism to the WebSocket
reconnect logic in the `useProviderStore` to
prevent rapid reconnection attempts that can
lead to performance issues and potential server
overload.
2026-03-23 17:01:02 +01:00
Anthony LC
4a269e6b0e 🐛(y-provider) fix loop when no cookies
We observed a huge amount of logs sometimes in
the y-provider server logs, all related to the
same error: "No cookies".
When this happens, the client keeps trying to
reconnect, and the server keeps logging the error,
creating a loop.
We stop the loop by checking if the error is a
"No cookies" error, and if so, we don't
try to reconnect.
2026-03-23 11:53:55 +01:00
Anthony LC
d9d7b70b71 ♻️(frontend) refacto Version modal to fit with the design system
We refactored the version modal to fit
the design system. We removed some dead code and
fixed some state issues.
2026-03-23 10:58:50 +01:00
Anthony LC
a4326366c2 🐛(frontend) fix leftpanel button in doc version
The left panel button was shown in the doc version page.
This commit removes the button from the doc version
page by moving it to the DocLayout.
By moving it to the DocLayout, we do not have the
flickering when we switch between subpages.
2026-03-23 10:33:05 +01:00
Anthony LC
1d7b57e03d 🐛(frontend) fix close panel when click on subdoc
Recent refacto of left panel components caused
the close panel function to stop working when
clicking on a subdoc.
This commit fixes that issue by ensuring that the
close panel function is properly called when
a subdoc is clicked.
2026-03-23 10:11:19 +01:00
Manuel Raynaud
c4c6c22e42 ♻️(backend) skip saving in database a document when payload is empty
The frontend application is making PATCH request with an empty body.
This PATCH request is not making any change but an UPDATE sql query is
made, the `updated_at` field is the only one updated. When can skip this
save in the databse by returning the Document instance in the serializer
update method
2026-03-21 10:33:02 +01:00
Manuel Raynaud
10a8eccc71 (backend) add missing update api test using the PATCH method
No tests were made using the PATCH method to update a Document using the
API. The frontend appllication mostly use the patch method instead of
the PUT method.
2026-03-21 10:15:50 +01:00
Manuel Raynaud
728332f8f7 (backend) assert document path can not change during API update
We want to assert on every succesful update test that the document path
has not change.
2026-03-21 10:15:49 +01:00
Manuel Raynaud
487b95c207 🐛(backend) allow using search endpoint without refresh token enabled
The search endpoint was using the refresh_roken method decorator. This
decorator force having a valid refresh token stored in the session for
the entire viewset. The search endpoint still allow having the legacy
search behavior and for this we don't need to configure at all the OIDC
refrsh mechanism.
2026-03-21 08:22:45 +00:00
Cyril
d23b38e478 (frontend) add markdown copy icon for Copy as Markdown option
Replace generic copy icon with dedicated markdown_copy SVG in DocToolBox.
2026-03-20 15:41:03 +01:00
Cyril
d6333c9b81 ️(frontend) fix aria-labels for table of contents nav vs buttons
Screen readers announce nav as "Sommaire, navigation" and button as toggle.
2026-03-20 15:04:29 +01:00
renovate[bot]
03b6c6a206 ⬆️(dependencies) update next to v16.1.7 [SECURITY] 2026-03-20 13:12:08 +00:00
Cyril
aadabf8d3c ️(frontend) announce formatting shortcuts for screen readers
Announce formatting shortcuts (headings, lists, paragraph, code block).
2026-03-20 12:56:38 +01:00
Cyril
2a708d6e46 ️(frontend) add format to export download btn aria-label
Add format to export button aria-label for a11y. DRY format options.
2026-03-20 11:27:02 +01:00
Cyril
b47c730e19 ️(frontend) announce search results through a live region
Announce result count updates while focus stays in input (#2043)
2026-03-20 10:47:18 +01:00
Cyril
cef83067e6 ️(frontend) restore focus to input after search filter reset
Move filters out of listbox and refocus the search input (#2044)
2026-03-20 10:47:03 +01:00
Cyril
4cabfcc921 ️(frontend) update aria-expanded dynamically on search combobox
Override cmdk aria-expanded via ref to reflect list state (#2039)
2026-03-20 10:47:02 +01:00
Cyril
b8d4b0a044 ️(frontend) add label text to search input field
Pass label prop to QuickSearch to render non-empty label (#2041)
2026-03-20 10:47:01 +01:00
Cyril
71c4d2921b ️(frontend) add explicit aria-label to search reset button
Add aria-label to clarify the reset button resets filters (#2042)
2026-03-20 10:47:01 +01:00
Cyril
d1636dee13 ️(frontend) set empty alt on decorative search image
Set alt="" on decorative empty state image in search modal (#2038)
2026-03-20 10:46:49 +01:00
Cyril
bf93640af8 ️(frontend) skip link as anchor instead of button
Replace button with anchor link
2026-03-20 10:05:42 +01:00
Cyril
da79c310ae ️(frontend) focus skip link on headings and skip grid dropzone
We land keyboard users on page headings and keep the grid dropzone untabbable.
2026-03-20 10:05:33 +01:00
Cyril
99c486571d ️(frontend) fix more options menu feedback for screen readers
Pin/unpin: vocal announce only. Duplicate, copy: toast only.
2026-03-19 18:34:24 +01:00
Cyril
cdf3161869 ️(frontend) use aria-label trad for version history modal #2023
Replace hardcoded aria-label with aria-lab trad.
2026-03-19 15:52:28 +01:00
Cyril
ef108227b3 ️(frontend) improve version history list accessibility
Dynamic aria-label per version, aria-pressed + live region
2026-03-19 14:04:59 +01:00
Anthony LC
9991820cb1 🔊(CHANGELOG) fix entries changelog
The changelog was not updated correctly.
By not updating correctly, the changelog was not
showing the correct entries for the release,
leading to a patch release instead of a minor
release.
2026-03-19 13:36:48 +01:00
Anthony LC
2801ece358 ️(frontend) change aria-label for help menu button
The help menu button's aria-label was
previously "Open onboarding menu", which was not
accurate and could be confusing for screen reader
users. This commit updates the aria-label to
"Open help menu" to better reflect the button's
purpose and improve accessibility.
2026-03-19 13:31:03 +01:00
Anthony LC
0b37996899 💫(frontend) fix the help button to the bottom in tree
The tree take a bit of time to load, during this
time the help button was not at the bottom of
the left panel. To fix this issue, we addded a
skeleton for the tree in wait for the tree to
load, by doing this, the help button
is always at the bottom.
2026-03-19 13:28:22 +01:00
Manuel Raynaud
0867ccef1a 🔖(patch) release 4.8.2
Changed

- ️(frontend) ensure doc title is h1 for accessibility #2006
- ️(frontend) add nb accesses in share button aria-label #2017

Fixed

- 🐛(frontend) fix image resizing when caption #2045
- 🙈(docker) add \*\*/.next to .dockerignore #2034
- ️(frontend) fix share modal heading hierarchy #2007
- ️(frontend) fix Copy link toast accessibility for screen readers #2029
- ️(frontend) fix modal aria-label and name #2014
- ️(frontend) fix language dropdown ARIA for screen readers #2020
- ️(frontend) fix waffle aria-label spacing for new-window links #2030
- 🐛(backend) stop using add_sibling method to create sandbox document #2084
- 🐛(backend) duplicate a document as last-sibling #2084
2026-03-19 10:24:25 +01:00
Manuel Raynaud
b3ae6e1a30 🐛(backend) duplicate a document as last-sibling
When a document is duplicated, it is duplicated at the direct right of
the duplicated document. Doing this force to move all the other
documents at the right, if it is duplicated at the root this can impact
a lot of documents, create lot of locks in the database. If the process
is stop for any reason then the paths can be in an inconsistent paths in
the Document table
2026-03-19 10:14:56 +01:00
Manuel Raynaud
1df6242927 🐛(backend) stop using add_sibling method to create sandbox document
In a past release we added a feature to create a sandbox document to a
newly created used. To create this sandbox document, we duplicate an
existing document and this duplicate is using the add_sibling method
with the "right" agument on this original document. Adding a sibling at
the right to a document involve moving right every root document created
after the original document, so the path of all this documents are
recalculated and changed. This can lead to the lost of some leaf in a
tree because to do this operation, multiple locks are created on the
database, creating lot of connection to the database and if the max
number connection to the database is reached or if the memory allocated
by the database is too hight, the database can close all connections
leading to inconsistent paths in the Document table.
2026-03-19 10:14:54 +01:00
Cyril
35fba02085 ️(i18n) fix waffle aria-label spacing for new-window links
Include space and parentheses in translation value for proper aria-label.
2026-03-19 09:14:45 +01:00
Cyril
0e5c9ed834 ️(frontend) fix language dropdown ARIA for screen readers
Add aria-haspopup, aria-expanded and menuitemradio pattern for SR.
2026-03-18 17:06:53 +01:00
Sylvain Boissel
4e54a53072 (backend) add resource server api
Adds a resource server API similar to the one that already
exists for Drive.
2026-03-18 16:06:29 +01:00
Charles Englebert
4f8aea7b80 Search feature flags (#1897)
## Purpose

For beta testing purposes we need to be able to activate Find hybrid
search to some users, Find full-text search to some others and leave
remaining users on basic DRF title search.

## Proposal

The solution proposed is based on [django-waffle
](https://waffle.readthedocs.io/en/stable/types/flag.html).

- [x] install waffle and activate the default app in settings.
- [x] implement `_get_search_type` in `DocumentViewset` to determine
which search type (title, hybrid or full-text) to use.
- [x] send the `search_type` in the search query. 

## External contributions

Thank you for your contribution! 🎉  

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`
- [x] I have added a changelog entry under `## [Unreleased]` section (if
noticeable change)
- [x] I have added corresponding tests for new features or bug fixes (if
applicable)

---------

Signed-off-by: charles <charles.englebert@protonmail.com>
2026-03-18 15:04:55 +00:00
Cyril
1172fbe0b5 ️(frontend) add nb accesses in share button aria-label
Expose nb_accesses_direct to screen readers when share button shows count.
2026-03-18 14:21:04 +01:00
Cyril
7cf144e0de ️(frontend) fix modal aria-label object Object
Add aria-label on modals with JSX title to avoid returning object Object
2026-03-18 13:39:50 +01:00
Anthony LC
54c15c541e 🐛(frontend) fix image resizing when caption
When the caption was present, the image resizing
handles were not working.
This was because we were adding a Figure element
around the resizing div instead of the image itself.
2026-03-18 12:17:06 +01:00
Cyril
8472e661f5 ️(frontend) fix Copy link toast accessibility for screen readers
Add aria-live announcements so screen readers announce the toast feedback.
2026-03-18 11:51:15 +01:00
Cyril
1d819d8fa2 ️(frontend) fix share modal heading hierarchy
Improve h struct in docShareModal use h2 for group names and link settings
2026-03-18 10:47:39 +01:00
Cyril
5020bc1c1a ️(frontend) fix share modal heading hierarchy
Render QuickSearchGroup names and link settings as h2 headings.
2026-03-18 10:02:24 +01:00
Cyril
4cd72ffa4f ️(frontend) ensure doc title is h1 for accessibility
Fix heading hierarchy when withTitle is false in production
2026-03-18 10:02:08 +01:00
Anthony LC
c1998a9b24 🙈(docker) add **/.next to .dockerignore
All the ".next" files are generated by the build
process and should not be included in the Docker
context.
2026-03-18 09:04:46 +01:00
Charles Englebert
0fca6db79c Integrate Find (#1834)
## Purpose

integrate Find to Docs

## Proposal

- [x]  add a `useSeachDocs` hook in charged of calling the search
endpoint.
- [x]  add a optional `path` param to the `search` route. This param
represents the parent document path in case of a sub-documents
(descendants) search.
- [x] ️return Indexer results directly without DB calls to retrieve the
Document objects. All informations necessary for display are indexed in
Find. We can skip the DB calls and improve performance.
- [x] ♻️ refactor react `DocSearchContent` components.
`DocSearchContent` and `DocSearchSubContent` are now merged a unique
component handling all search scenarios and relying on the unique
`search` route.
- [x] 🔥remove pagination logic in the Indexer. Removing the DB calls
also removes the DRF queryset object which handles the pagination. Also
we consider pagination not to be necessary for search v1.
- [x] 🔥remove the `document/<document_id>/descendants` route. This route
is not used anymore. The logic of finding the descendants are moved to
the internal `_list_descendants` method. This method is based on the
parent `path` instead of the parent `id` which has some consequence
about the user access management. Relying on the path prevents the use
of the `self.get_object()` method which used to handle the user access
logic.
- [x] handle fallback logic on DRF based title search in case of
non-configured, badly configured or failing at run time indexer.
- [x] handle language extension in `title` field. Find returns titles
with a language extension (ex: `{ title.fr: "rapport d'activité" }`
instead of `{ "title": "rapport d'activité" }`.
- [x] 🔧 add a `common.test` file to allow running the tests without
docker
- [x] ♻️ rename `SearchIndexer` -> `FindDocumentIndexer`. This class has
to do with Find in particular and the convention is more coherent with
`BaseDocumentIndexer`
- [x] ♻️ rename `SEARCH_INDEXER_URL` -> `INDEXING_URL` and
`SEARCH_INDEXER_QUERY_URL` -> `SEARCH_URL`. I found the original names
very confusing.
- [x] 🔧 update the environment variables to activate the
FindDocumentIndexer.
- [x] automate the generation of encryption key during bootstrap.
OIDC_STORE_REFRESH_TOKEN_KEY is a mandatory secret key. We can not push
it on Github and we want any contributor to be able to run the app by
only running the `make bootstrap`. We chose to generate and wright it
into the `common.local` during bootstrap.

## External contributions

Thank you for your contribution! 🎉  

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`
- [x] I have added a changelog entry under `## [Unreleased]` section (if
noticeable change)
- [x] I have added corresponding tests for new features or bug fixes (if
applicable)

---------

Signed-off-by: charles <charles.englebert@protonmail.com>
2026-03-17 17:32:03 +01:00
Manuel Raynaud
ad36210e45 🔖(patch) release 4.8.1
Added

- 🔧(backend) add DB_PSYCOPG_POOL_ENABLED settings

Changed

- ⬇️(backend) downgrade django-treebeard to version < 5.0.0
2026-03-17 13:29:05 +01:00
Manuel Raynaud
73a7c250b5 🔧(backend) add DB_PSYCOPG_POOL_ENABLED settings
The psycopg pool config was enabled by default forcing its usage. Using
psycopg pool can be difficult, finding the good configuration take time.
By default its usage should be disable and the maintainer of the
instance should decide to enable it or not.
2026-03-17 13:19:17 +01:00
Manuel Raynaud
0c17d76f60 ⬇️(backend) downgrade django-treebeard to version < 5.0.0
Since we upgraded to django-treebeard version 5 we have anormal behavior
and a high error rate on the document.path property. We must downgrade
it and avoid future upgrade from renovate.
2026-03-17 13:17:05 +01:00
Manuel Raynaud
04c9dc3294 🔧(backend) allow to configure psycopg pool timeout
We want to allow the configuration of the psycopg pool timeout.
For this we created a new setting DB_PSYCOPG_POOL_TIMEOUT
2026-03-16 15:30:23 +01:00
Manuel Raynaud
32b2641fd8 (hub) increase max pool size
In order to run the tests we need to increase the max pool size. Only
having 4 connections in the pool is not enough and all the tests using a
transaction are failing with a tiemout error.
We have the same problem running locally so the same value is added to
the postgresql environment file
2026-03-16 15:30:23 +01:00
Manuel Raynaud
07966c5461 🔧(helm) update values.yaml annotations
The annotation in the values.yaml have not been updated since a while.
This commit update them and generate the readme using the generate-readme.sh
script
2026-03-16 15:30:23 +01:00
Manuel Raynaud
bcb50a5fce 🔧(helm) allow specific env var for the backend and celery deploy
We want the possibility to configure specific environment variables on
backend and celery deployment. Most of them are common but in the case
of the newly added settings DB_PSYCOPG_POOL_MIN_SIZE we want to
configure ot only on the backend deployment, not on the celery or with a
different value.
2026-03-16 15:30:22 +01:00
Manuel Raynaud
ba93bcf20b 🔧(backend) enable psycopg-pool allowing configuring min and max size
We enable the pool option on the DB configuration. We want to allow the
configuration of the min and max sixe in a first time. They can be
configured using the settings DB_PSYCOPG_POOL_MIN_SIZE and
DB_PSYCOPG_POOL_MAX_SIZE. They have their default value to 4 and None.
2026-03-16 15:30:22 +01:00
Manuel Raynaud
2e05aec303 (backend) install psycopg_pool
We want to use psycopg_pool, it can be installed as a psycopg extra
dependency.
2026-03-16 15:30:22 +01:00
Anthony LC
51e8332b95 🔖(minor) release 4.8.0
Added:
- (backend) add a is_first_connection flag to the User model
- (frontend) add onboarding modal with help menu button

Changed:
- (frontend) localize LaGaufre label fallback in Docs
- (backend) add a migration cleaning on-boarding
  document accesses
- ⬆️(frontend) upgrade Next.js to v16
- ️(frontend) fix aria-label and landmark on document
  banner state
- 🌐(i18n) add "new window" translation key for waffle
  aria-label

Fixed:
- 🐛(backend) create a link_trace record for on-boarding
  documents
- 🐛(backend) manage race condition when creating sandbox
  document
- 🐛(frontend) fix flickering left panel
- ️(frontend) improve doc tree keyboard navigation
2026-03-13 18:00:32 +01:00
AntoLC
eb2ee1bb7f 🌐(i18n) update translated strings
Update translated files with new translations
2026-03-13 18:00:32 +01:00
Anthony LC
d34f279455 📱(frontend) improve mobile design left panel
Improve the onboarding modal design for
mobile devices.
Improve as well the left panel on mobile devices
to fit more with the Figma design.
2026-03-13 17:22:55 +01:00
Anthony LC
3eed542800 (frontend) display onboarding modal when first connection
When the user connect for the first time, we
display a onboarding modal, that explains the
main functionnalities of Docs.
2026-03-13 17:22:54 +01:00
Anthony LC
5f2c472726 🌐(frontend) add currentLocale to CunninghamProvider
In order to have the text of components from the
Cunningham library translated, we need to pass the current
locale to the CunninghamProvider.
We need to create a new ThemeProvider component that
will wrap the CunninghamProvider in order to have
react-query fully loaded.
2026-03-13 17:22:54 +01:00
Cyril
9e313e30a7 (frontend) add e2e test for onboarding modal
Ensure onboarding entrypoint and modal navigation work end-to-end.
2026-03-13 17:22:54 +01:00
Cyril
6c493c24d5 (frontend) add onboarding modal with help menu button
integrate onboarding feature accessible from left panel help menu

(frontend) add docs onboarding and help memu

Introduce an onboarding to guide users through core features.
2026-03-13 16:27:21 +01:00
Anthony LC
c3acfe45d2 🐛(frontend) fix skeleton blocked on main page
If navigating quickly between documents, the
skeleton of the document page can be blocked
on the main page.
This commit fixes this issue by reseting the skeleton
state when unmounting the document page.
2026-03-13 11:10:13 +01:00
Anthony LC
a9d2517c7b 🐛(frontend) fix flickering left panel
In some cases, the left panel can flicker
when navigating from the index to a document page.
This is due to different state + a transition effect.
To fix this, we remove the transition effect
when mounting.
2026-03-13 10:34:56 +01:00
Cyril
a2ae41296d ️(frontend) fix doc tree keyboard navigation regressions
Shift+Tab from sub-doc returns focus to root item
2026-03-12 17:10:09 +01:00
Cyril
1016b1c25d ️(i18n) add "new window" translation key for waffle aria-label
Add key used by LaGaufreV2 for localized aria-label on external links.
2026-03-12 16:13:51 +01:00
Cyril
0c649a65b0 ️(frontend) fix redundant a-label and improper landmark on public alert
Remove aria-label and region role to avoid duplicate screen reader announcement
2026-03-12 15:10:25 +01:00
Anthony LC
11d899437a ️(frontend) improve bundle size
Improve bundle size by improving tree shaking
and code splitting.
2026-03-12 14:34:23 +01:00
Anthony LC
27c5e0ce5a (frontend) use eslint instead of next lint
Version 16 of Next.js stopped supporting eslint natively.
We need to implement it ourselves.
2026-03-12 14:34:23 +01:00
Anthony LC
9337c4b1d5 ♻️(frontend) adapt emoji copying to turbopack build
We were previously copying the emoji assets
in a webpack plugin, but that doesn't run with
turbopack. This commit moves the copying to a
pre-build script, which runs regardless of the
bundler used.
2026-03-12 14:34:23 +01:00
Anthony LC
679b29e2e0 ⬆️(frontend) upgrade Next.js to v16
Upgrade Next.js to v16, which includes Turbopack
support by default. It improves dev and build
performance considerably.
2026-03-12 14:34:23 +01:00
Manuel Raynaud
3cad1b8a39 (backend) add a migration for cleaning onboarding document accesses
We change the strategy on how the new users have access to the
onboarding documents. We should remove all created accesses we don't
want to have anymore. There is no need to add them in the link_trace
table, they are already present in the favorites and user have already
access to it.
2026-03-12 13:52:23 +01:00
Manuel Raynaud
2eb2641d2c 🐛(backend) manage race condition when creating sandbox document
When a user is created and a sandbox document should be created, we can
have a race condition on the document creation leading to an error for
the user. To avoid this we have to manage this part in a transaction and
locking the document table
2026-03-12 13:51:41 +01:00
Manuel Raynaud
e36366b293 🐛(backend) create a link_trace record for onboarded documents
When a user is created, we created accesses to a list of onboarding
documents. Doing this have side effect on the proximity search feature.
Instead of creating access, we should create link_reach
2026-03-12 13:51:41 +01:00
Cyril
6d73fb69b0 ️(frontend) localize LaGaufre label fallback in Docs
We pass a translated fallback label so the waffle follows the app locale.
2026-03-12 11:07:24 +01:00
Sylvain Boissel
b708c8b352 (backend) add a is_first_connection flag to the User model
Backend part of #1796.

This changes allows to display an onboarding modal the first time that
the get_me() API view is called.
I originally tried to check if `User.last_login` was `None`, but it is
updated as soon as the user is logged, so I chose to create a flag on
the model.
2026-03-11 14:34:55 +00:00
Manuel Raynaud
36c6762026 ⬇️(backend) downgrade langfuse to version 3.11.2
We to keep in sync the version of the sdk client and the version of the
langfuse server. For now we can't upgrade langfuse See
https://github.com/langfuse/langfuse/issues/11564
2026-03-11 09:54:26 +00:00
Hadrien Blanc
4637d6f1fe 📝 Fix documentation and comment typos (#1977)
Fix typos found in documentation and code comments across the codebase.
2026-03-11 09:29:57 +00:00
281 changed files with 13543 additions and 5243 deletions

View File

@@ -34,4 +34,4 @@ db.sqlite3
# Frontend # Frontend
node_modules node_modules
.next **/.next

View File

@@ -6,6 +6,109 @@ and this project adheres to
## [Unreleased] ## [Unreleased]
### Added
- 🚸(frontend) hint min char search users #2064
### Changed
- 💄(frontend) improve comments highlights #1961
- ♿️(frontend) improve BoxButton a11y and native button semantics #2103
- ♿️(frontend) improve language picker accessibility #2069
### Fixed
- 🐛(y-provider) destroy Y.Doc instances after each convert request #2129
## [v4.8.3] - 2026-03-23
### Changed
- ♿️(frontend) improve version history list accessibility #2033
- ♿(frontend) focus skip link on headings and skip grid dropzone #1983
- ♿️(frontend) add sr-only format to export download button #2088
- ♿️(frontend) announce formatting shortcuts for screen readers #2070
- ✨(frontend) add markdown copy icon for Copy as Markdown option #2096
- ♻️(backend) skip saving in database a document when payload is empty #2062
- ♻️(frontend) refacto Version modal to fit with the design system #2091
- ⚡️(frontend) add debounce WebSocket reconnect #2104
### Fixed
- ♿️(frontend) fix more options menu feedback for screen readers #2071
- ♿️(frontend) fix more options menu feedback for screen readers #2071
- 💫(frontend) fix the help button to the bottom in tree #2073
- ♿️(frontend) fix aria-labels for table of contents #2065
- 🐛(backend) allow using search endpoint without refresh token enabled #2097
- 🐛(frontend) fix close panel when click on subdoc #2094
- 🐛(frontend) fix leftpanel button in doc version #9238
- 🐛(y-provider) fix loop when no cookies #2101
## [v4.8.2] - 2026-03-19
### Added
- ✨(backend) add resource server api #1923
- ✨(frontend) activate Find search #1834
- ✨ handle searching on subdocuments #1834
- ✨(backend) add search feature flags #1897
### Changed
- ♿️(frontend) ensure doc title is h1 for accessibility #2006
- ♿️(frontend) add nb accesses in share button aria-label #2017
- ✨(backend) improve fallback logic on search endpoint #1834
### Fixed
- 🐛(frontend) fix image resizing when caption #2045
- 🙈(docker) add \*\*/.next to .dockerignore #2034
- ♿️(frontend) fix share modal heading hierarchy #2007
- ♿️(frontend) fix Copy link toast accessibility for screen readers #2029
- ♿️(frontend) fix modal aria-label and name #2014
- ♿️(frontend) fix language dropdown ARIA for screen readers #2020
- ♿️(frontend) fix waffle aria-label spacing for new-window links #2030
- 🐛(backend) stop using add_sibling method to create sandbox document #2084
- 🐛(backend) duplicate a document as last-sibling #2084
### Removed
- 🔥(api) remove `documents/<document_id>/descendants/` endpoint #1834
- 🔥(api) remove pagination on `documents/search/` endpoint #1834
## [v4.8.1] - 2026-03-17
### Added
- 🔧(backend) add DB_PSYCOPG_POOL_ENABLED settings #2035
### Changed
- ⬇️(backend) downgrade django-treebeard to version < 5.0.0 #2036
## [v4.8.0] - 2026-03-13
### Added
- ✨(backend) add a is_first_connection flag to the User model #1938
- ✨(frontend) add onboarding modal with help menu button #1868
### Changed
- ♿(frontend) localize LaGaufre label fallback in Docs #1979
- ✨(backend) add a migration cleaning on-boarding document accesses #1971
- ⬆️(frontend) upgrade Next.js to v16 #1980
- ♿️(frontend) fix aria-label and landmark on document banner state #1986
- 🌐(i18n) add "new window" translation key for waffle aria-label #1984
### Fixed
- 🐛(backend) create a link_trace record for on-boarding documents #1971
- 🐛(backend) manage race condition when creating sandbox document #1971
- 🐛(frontend) fix flickering left panel #1989
- ♿️(frontend) improve doc tree keyboard navigation #1981
- 🔧(helm) allow specific env var for the backend and celery deploy
## [v4.7.0] - 2026-03-09 ## [v4.7.0] - 2026-03-09
### Added ### Added
@@ -28,7 +131,6 @@ and this project adheres to
- 🐛(frontend) fix bug when language not supported by BN #1957 - 🐛(frontend) fix bug when language not supported by BN #1957
- 🐛 (backend) prevent privileged users from requesting access #1898 - 🐛 (backend) prevent privileged users from requesting access #1898
## [v4.6.0] - 2026-03-03 ## [v4.6.0] - 2026-03-03
### Added ### Added
@@ -94,6 +196,8 @@ and this project adheres to
### Removed ### Removed
- 🔥(project) remove all code related to template #1780 - 🔥(project) remove all code related to template #1780
- 🔥(api) remove `documents/<document_id>/descendants/` endpoint #1834
- 🔥(api) remove pagination on `documents/search/` endpoint #1834
### Security ### Security
@@ -304,7 +408,7 @@ and this project adheres to
- ♻️(frontend) Refactor Auth component for improved redirection logic #1461 - ♻️(frontend) Refactor Auth component for improved redirection logic #1461
- ♻️(frontend) replace Arial font-family with token font #1411 - ♻️(frontend) replace Arial font-family with token font #1411
- ♿(frontend) improve accessibility: - ♿(frontend) improve accessibility:
- ♿(frontend) enable enter key to open documentss #1354 - ♿(frontend) enable enter key to open documents #1354
- ♿(frontend) improve modal a11y: structure, labels, title #1349 - ♿(frontend) improve modal a11y: structure, labels, title #1349
- ♿improve NVDA navigation in DocShareModal #1396 - ♿improve NVDA navigation in DocShareModal #1396
- ♿ improve accessibility by adding landmark roles to layout #1394 - ♿ improve accessibility by adding landmark roles to layout #1394
@@ -512,10 +616,10 @@ and this project adheres to
- ✨(backend) add endpoint checking media status #984 - ✨(backend) add endpoint checking media status #984
- ✨(backend) allow setting session cookie age via env var #977 - ✨(backend) allow setting session cookie age via env var #977
- ✨(backend) allow theme customnization using a configuration file #948 - ✨(backend) allow theme customization using a configuration file #948
- ✨(frontend) Add a custom callout block to the editor #892 - ✨(frontend) Add a custom callout block to the editor #892
- 🚩(frontend) version MIT only #911 - 🚩(frontend) version MIT only #911
- ✨(backend) integrate maleware_detection from django-lasuite #936 - ✨(backend) integrate malware_detection from django-lasuite #936
- 🏗️(frontend) Footer configurable #959 - 🏗️(frontend) Footer configurable #959
- 🩺(CI) add lint spell mistakes #954 - 🩺(CI) add lint spell mistakes #954
- ✨(frontend) create generic theme #792 - ✨(frontend) create generic theme #792
@@ -1083,7 +1187,11 @@ and this project adheres to
- ✨(frontend) Coming Soon page (#67) - ✨(frontend) Coming Soon page (#67)
- 🚀 Impress, project to manage your documents easily and collaboratively. - 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/suitenumerique/docs/compare/v4.7.0...main [unreleased]: https://github.com/suitenumerique/docs/compare/v4.8.3...main
[v4.8.3]: https://github.com/suitenumerique/docs/releases/v4.8.3
[v4.8.2]: https://github.com/suitenumerique/docs/releases/v4.8.2
[v4.8.1]: https://github.com/suitenumerique/docs/releases/v4.8.1
[v4.8.0]: https://github.com/suitenumerique/docs/releases/v4.8.0
[v4.7.0]: https://github.com/suitenumerique/docs/releases/v4.7.0 [v4.7.0]: https://github.com/suitenumerique/docs/releases/v4.7.0
[v4.6.0]: https://github.com/suitenumerique/docs/releases/v4.6.0 [v4.6.0]: https://github.com/suitenumerique/docs/releases/v4.6.0
[v4.5.0]: https://github.com/suitenumerique/docs/releases/v4.5.0 [v4.5.0]: https://github.com/suitenumerique/docs/releases/v4.5.0

View File

@@ -95,8 +95,8 @@ Thank you for your contributions! 👍
## Contribute to BlockNote ## Contribute to BlockNote
We use [BlockNote](https://www.blocknotejs.org/) for the text editing features of Docs. We use [BlockNote](https://www.blocknotejs.org/) for the text editing features of Docs.
If you find and issue with the editor you can [report it](https://github.com/TypeCellOS/BlockNote/issues) directly on their repository. If you find an issue with the editor you can [report it](https://github.com/TypeCellOS/BlockNote/issues) directly on their repository.
Please consider contributing to BlockNotejs, as a library, it's useful to many projects not just Docs. Please consider contributing to BlockNotejs, as a library, it's useful to many projects not just Docs.
The project is licended with Mozilla Public License Version 2.0 but be aware that [XL packages](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-docx-exporter/LICENSE) are dual licenced with GNU AFFERO GENERAL PUBLIC LICENCE Version 3 and proprietary licence if you are [sponsor](https://www.blocknotejs.org/pricing). The project is licensed with Mozilla Public License Version 2.0 but be aware that [XL packages](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-docx-exporter/LICENSE) are dual licensed with GNU AFFERO GENERAL PUBLIC LICENSE Version 3 and proprietary license if you are a [sponsor](https://www.blocknotejs.org/pricing).

View File

@@ -79,10 +79,16 @@ create-env-local-files:
@touch env.d/development/kc_postgresql.local @touch env.d/development/kc_postgresql.local
.PHONY: create-env-local-files .PHONY: create-env-local-files
generate-secret-keys:
generate-secret-keys: ## generate secret keys to be stored in common.local
@bin/generate-oidc-store-refresh-token-key.sh
.PHONY: generate-secret-keys
pre-bootstrap: \ pre-bootstrap: \
data/media \ data/media \
data/static \ data/static \
create-env-local-files create-env-local-files \
generate-secret-keys
.PHONY: pre-bootstrap .PHONY: pre-bootstrap
post-bootstrap: \ post-bootstrap: \
@@ -156,6 +162,10 @@ endif
@echo "" @echo ""
.PHONY: post-beautiful-bootstrap .PHONY: post-beautiful-bootstrap
create-docker-network: ## create the docker network if it doesn't exist
@docker network create lasuite-network || true
.PHONY: create-docker-network
bootstrap: ## Prepare the project for local development bootstrap: ## Prepare the project for local development
bootstrap: \ bootstrap: \
pre-beautiful-bootstrap \ pre-beautiful-bootstrap \
@@ -213,6 +223,7 @@ logs: ## display app-dev logs (follow mode)
.PHONY: logs .PHONY: logs
run-backend: ## Start only the backend application and all needed services run-backend: ## Start only the backend application and all needed services
@$(MAKE) create-docker-network
@$(COMPOSE) up --force-recreate -d docspec @$(COMPOSE) up --force-recreate -d docspec
@$(COMPOSE) up --force-recreate -d celery-dev @$(COMPOSE) up --force-recreate -d celery-dev
@$(COMPOSE) up --force-recreate -d y-provider-development @$(COMPOSE) up --force-recreate -d y-provider-development

View File

@@ -51,7 +51,7 @@ Docs is an open-source alternative to tools like Notion or Google Docs, focused
- Slash commands & block system - Slash commands & block system
- Beautiful formatting - Beautiful formatting
- Offline editing - Offline editing
- Optional AI writing helpers (rewirite, summarize, translate, fix typos) - Optional AI writing helpers (rewrite, summarize, translate, fix typos)
### Collaboration ### Collaboration
@@ -120,7 +120,7 @@ docker -v
docker compose version docker compose version
``` ```
> If you encounounter permission errors, you may need to use `sudo`, or add your user to the `docker` group. > If you encounter permission errors, you may need to use `sudo`, or add your user to the `docker` group.
### Bootstrap the project ### Bootstrap the project
@@ -130,9 +130,9 @@ The easiest way to start is using GNU Make:
make bootstrap FLUSH_ARGS='--no-input' make bootstrap FLUSH_ARGS='--no-input'
``` ```
This builds the `app-dev` and `fronted-dev` containers, installs dependencies, runs database migrations, and compiles translations. This builds the `app-dev` and `frontend-dev` containers, installs dependencies, runs database migrations, and compiles translations.
It is recommend to run this command after pulling new code. It is recommended to run this command after pulling new code.
Start services: Start services:
@@ -173,6 +173,11 @@ make frontend-test
make frontend-lint make frontend-lint
``` ```
Backend tests can be run without docker. This is useful to configure PyCharm or VSCode to do it.
Removing docker for testing requires to overwrite some URL and port values that are different in and out of
Docker. `env.d/development/common` contains all variables, some of them having to be overwritten by those in
`env.d/development/common.test`.
### Demo content ### Demo content
Create a basic demo site: Create a basic demo site:

View File

@@ -68,5 +68,5 @@ service.
- AI features are now limited to users who are authenticated. Before this release, even anonymous - AI features are now limited to users who are authenticated. Before this release, even anonymous
users who gained editor access on a document with link reach used to get AI feature. users who gained editor access on a document with link reach used to get AI feature.
IF you want anonymous users to keep access on AI features, you must now define the If you want anonymous users to keep access on AI features, you must now define the
`AI_ALLOW_REACH_FROM` setting to "public". `AI_ALLOW_REACH_FROM` setting to "public".

View File

@@ -1,6 +0,0 @@
#!/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

@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Generate the secret OIDC_STORE_REFRESH_TOKEN_KEY and store it to common.local
set -eo pipefail
COMMON_LOCAL="env.d/development/common.local"
OIDC_STORE_REFRESH_TOKEN_KEY=$(openssl rand -base64 32)
echo "" >> "${COMMON_LOCAL}"
echo "OIDC_STORE_REFRESH_TOKEN_KEY=${OIDC_STORE_REFRESH_TOKEN_KEY}" >> "${COMMON_LOCAL}"
echo "✓ OIDC_STORE_REFRESH_TOKEN_KEY generated and stored in ${COMMON_LOCAL}"

View File

@@ -47,6 +47,10 @@ server {
try_files $uri @proxy_to_docs_backend; try_files $uri @proxy_to_docs_backend;
} }
location /external_api {
try_files $uri @proxy_to_docs_backend;
}
location /static { location /static {
try_files $uri @proxy_to_docs_backend; try_files $uri @proxy_to_docs_backend;
} }

View File

@@ -46,6 +46,10 @@ These are the environment variables you can set for the `impress-backend` contai
| DB_NAME | Name of the database | impress | | DB_NAME | Name of the database | impress |
| DB_PASSWORD | Password to authenticate with | pass | | DB_PASSWORD | Password to authenticate with | pass |
| DB_PORT | Port of the database | 5432 | | DB_PORT | Port of the database | 5432 |
| DB_PSYCOPG_POOL_ENABLED | Enable or not the psycopg pool configuration in the default database options | False |
| DB_PSYCOPG_POOL_MIN_SIZE | The psycopg min pool size | 4 |
| DB_PSYCOPG_POOL_MAX_SIZE | The psycopg max pool size | None |
| DB_PSYCOPG_POOL_TIMEOUT | The default maximum time in seconds that a client can wait to receive a connection from the pool | 3 |
| DB_USER | User to authenticate with | dinum | | DB_USER | User to authenticate with | dinum |
| DJANGO_ALLOWED_HOSTS | Allowed hosts | [] | | DJANGO_ALLOWED_HOSTS | Allowed hosts | [] |
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker transport options | {} | | DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker transport options | {} |
@@ -104,6 +108,9 @@ These are the environment variables you can set for the `impress-backend` contai
| OIDC_RP_SCOPES | Scopes requested for OIDC | openid email | | OIDC_RP_SCOPES | Scopes requested for OIDC | openid email |
| OIDC_RP_SIGN_ALGO | verification algorithm used OIDC tokens | RS256 | | OIDC_RP_SIGN_ALGO | verification algorithm used OIDC tokens | RS256 |
| OIDC_STORE_ID_TOKEN | Store OIDC token | true | | OIDC_STORE_ID_TOKEN | Store OIDC token | true |
| OIDC_STORE_ACCESS_TOKEN | If True stores OIDC access token in session. | false |
| OIDC_STORE_REFRESH_TOKEN | If True stores OIDC refresh token in session. | false |
| OIDC_STORE_REFRESH_TOKEN_KEY | Key to encrypt refresh token stored in session, must be a valid Fernet key | |
| OIDC_USERINFO_FULLNAME_FIELDS | OIDC token claims to create full name | ["first_name", "last_name"] | | OIDC_USERINFO_FULLNAME_FIELDS | OIDC token claims to create full name | ["first_name", "last_name"] |
| OIDC_USERINFO_SHORTNAME_FIELD | OIDC token claims to create shortname | first_name | | OIDC_USERINFO_SHORTNAME_FIELD | OIDC token claims to create shortname | first_name |
| OIDC_USE_NONCE | Use nonce for OIDC | true | | OIDC_USE_NONCE | Use nonce for OIDC | true |
@@ -113,8 +120,9 @@ These are the environment variables you can set for the `impress-backend` contai
| SEARCH_INDEXER_CLASS | Class of the backend for document indexation & search | | | SEARCH_INDEXER_CLASS | Class of the backend for document indexation & search | |
| SEARCH_INDEXER_COUNTDOWN | Minimum debounce delay of indexation jobs (in seconds) | 1 | | SEARCH_INDEXER_COUNTDOWN | Minimum debounce delay of indexation jobs (in seconds) | 1 |
| SEARCH_INDEXER_QUERY_LIMIT | Maximum number of results expected from search endpoint | 50 | | SEARCH_INDEXER_QUERY_LIMIT | Maximum number of results expected from search endpoint | 50 |
| SEARCH_INDEXER_SECRET | Token for indexation queries | | | SEARCH_URL | Find application endpoint for search queries | |
| SEARCH_INDEXER_URL | Find application endpoint for indexation | | | SEARCH_INDEXER_SECRET | Token required for indexation queries | |
| INDEXING_URL | Find application endpoint for indexation | |
| SENTRY_DSN | Sentry host | | | SENTRY_DSN | Sentry host | |
| SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 | | SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 |
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | Register new user to the marketing onboarding. If True, see env LASUITE_MARKETING_* system | False | | SIGNUP_NEW_USER_TO_MARKETING_EMAIL | Register new user to the marketing onboarding. If True, see env LASUITE_MARKETING_* system | False |

View File

@@ -13,7 +13,7 @@ Please follow the instructions [here](/docs/installation/compose.md).
⚠️ Please keep in mind that we do not use it ourselves in production. Let us know in the issues if you run into troubles, we'll try to help. ⚠️ Please keep in mind that we do not use it ourselves in production. Let us know in the issues if you run into troubles, we'll try to help.
## Other ways to install Docs ## Other ways to install Docs
Community members have contributed several other ways to install Docs. While we owe them a big thanks 🙏, please keep in mind we (Docs maintainers) can't provide support on these installation methods as we don't use them ourselves and there are two many options out there for us to keep track of. Of course you can contact the contributors and the broader community for assistance. Community members have contributed several other ways to install Docs. While we owe them a big thanks 🙏, please keep in mind we (Docs maintainers) can't provide support on these installation methods as we don't use them ourselves and there are too many options out there for us to keep track of. Of course you can contact the contributors and the broader community for assistance.
Here is the list of other methods in alphabetical order: Here is the list of other methods in alphabetical order:
- Coop-Cloud: [code](https://git.coopcloud.tech/coop-cloud/lasuite-docs) - Coop-Cloud: [code](https://git.coopcloud.tech/coop-cloud/lasuite-docs)

View File

@@ -134,7 +134,7 @@ DJANGO_EMAIL_URL_APP=<url used in email templates to go to the app> # e.g. "http
Built-in AI actions let users generate, summarize, translate, and correct content. Built-in AI actions let users generate, summarize, translate, and correct content.
AI is disabled by default. To enable it, the following environment variables must be set in in `env.d/backend`: AI is disabled by default. To enable it, the following environment variables must be set in `env.d/backend`:
```env ```env
AI_FEATURE_ENABLED=true # is false by default AI_FEATURE_ENABLED=true # is false by default
@@ -152,7 +152,7 @@ You can [customize your Docs instance](../theming.md) with your own theme and cu
The following environment variables must be set in `env.d/backend`: The following environment variables must be set in `env.d/backend`:
```env ```env
FRONTEND_THEME=default # name of your theme built with cuningham FRONTEND_THEME=default # name of your theme built with Cunningham
FRONTEND_CSS_URL=https://storage.yourdomain.tld/themes/custom.css # custom css FRONTEND_CSS_URL=https://storage.yourdomain.tld/themes/custom.css # custom css
``` ```
@@ -206,7 +206,7 @@ Replace `<admin email>` with the email of your admin user and generate a secure
Your docs instance is now available on the domain you defined, https://docs.yourdomain.tld. Your docs instance is now available on the domain you defined, https://docs.yourdomain.tld.
THe admin interface is available on https://docs.yourdomain.tld/admin with the admin user you just created. The admin interface is available on https://docs.yourdomain.tld/admin with the admin user you just created.
## How to upgrade your Docs application ## How to upgrade your Docs application

View File

@@ -250,4 +250,4 @@ minio-dev-backend-minio-api <none> docs-minio.127.0.0.1.nip.io
minio-dev-backend-minio-console <none> docs-minio-console.127.0.0.1.nip.io localhost 80, 443 8m48s minio-dev-backend-minio-console <none> docs-minio-console.127.0.0.1.nip.io localhost 80, 443 8m48s
``` ```
You can use Docs at https://docs.127.0.0.1.nip.io. The provisionning user in keycloak is docs/docs. You can use Docs at https://docs.127.0.0.1.nip.io. The provisioning user in keycloak is docs/docs.

106
docs/resource_server.md Normal file
View File

@@ -0,0 +1,106 @@
# Use Docs as a Resource Server
Docs implements resource server, so it means it can be used from an external app to perform some operation using the dedicated API.
> **Note:** This feature might be subject to future evolutions. The API endpoints, configuration options, and behavior may change in future versions.
## Prerequisites
In order to activate the resource server on Docs you need to setup the following environment variables
```python
OIDC_RESOURCE_SERVER_ENABLED=True
OIDC_OP_URL=
OIDC_OP_INTROSPECTION_ENDPOINT=
OIDC_RS_CLIENT_ID=
OIDC_RS_CLIENT_SECRET=
OIDC_RS_AUDIENCE_CLAIM=
OIDC_RS_ALLOWED_AUDIENCES=
```
It implements the resource server using `django-lasuite`, see the [documentation](https://github.com/suitenumerique/django-lasuite/blob/main/documentation/how-to-use-oidc-resource-server-backend.md)
## Customise allowed routes
Configure the `EXTERNAL_API` setting to control which routes and actions are available in the external API. Set it via the `EXTERNAL_API` environment variable (as JSON) or in Django settings.
Default configuration:
```python
EXTERNAL_API = {
"documents": {
"enabled": True,
"actions": ["list", "retrieve", "create", "children"],
},
"document_access": {
"enabled": False,
"actions": [],
},
"document_invitation": {
"enabled": False,
"actions": [],
},
"users": {
"enabled": True,
"actions": ["get_me"],
},
}
```
**Endpoints:**
- `documents`: Controls `/external_api/v1.0/documents/`. Available actions: `list`, `retrieve`, `create`, `update`, `destroy`, `trashbin`, `children`, `restore`, `move`,`versions_list`, `versions_detail`, `favorite_detail`,`link_configuration`, `attachment_upload`, `media_auth`, `ai_transform`, `ai_translate`, `ai_proxy`. Always allowed actions: `favorite_list`, `duplicate`.
- `document_access`: `/external_api/v1.0/documents/{id}/accesses/`. Available actions: `list`, `retrieve`, `create`, `update`, `partial_update`, `destroy`
- `document_invitation`: Controls `/external_api/v1.0/documents/{id}/invitations/`. Available actions: `list`, `retrieve`, `create`, `partial_update`, `destroy`
- `users`: Controls `/external_api/v1.0/documents/`. Available actions: `get_me`.
Each endpoint has `enabled` (boolean) and `actions` (list of allowed actions). Only actions explicitly listed are accessible.
## Request Docs
In order to request Docs from an external resource provider, you need to implement the basic setup of `django-lasuite` [Using the OIDC Authentication Backend to request a resource server](https://github.com/suitenumerique/django-lasuite/blob/main/documentation/how-to-use-oidc-call-to-resource-server.md)
Then you can requests some routes that are available at `/external_api/v1.0/*`, here are some examples of what you can do.
### Create a document
Here is an example of a view that creates a document from a markdown file at the root level in Docs.
```python
@method_decorator(refresh_oidc_access_token)
def create_document_from_markdown(self, request):
"""
Create a new document from a Markdown file at root level.
"""
# Get the access token from the session
access_token = request.session.get('oidc_access_token')
# Create a new document from a file
file_content = b"# Test Document\n\nThis is a test."
file = BytesIO(file_content)
file.name = "readme.md"
response = requests.post(
f"{settings.DOCS_API}/documents/",
{
"file": file,
},
format="multipart",
)
response.raise_for_status()
data = response.json()
return {"id": data["id"]}
```
### Get user information
The same way, you can use the /me endpoint to get user information.
```python
response = requests.get(
"{settings.DOCS_API}/users/me/",
headers={"Authorization": f"Bearer {access_token}", "Content-Type": "application/json"},
)
```

View File

@@ -1,8 +1,8 @@
# Setup the Find search for Impress # Setup Find search for Docs
This configuration will enable the fulltext search feature for Docs : This configuration will enable Find searches:
- Each save on **core.Document** or **core.DocumentAccess** will trigger the indexer - Each save on **core.Document** or **core.DocumentAccess** will trigger the indexing of the document into Find.
- The `api/v1.0/documents/search/` will work as a proxy with the Find API for fulltext search. - The `api/v1.0/documents/search/` will be used as proxy for searching documents from Find indexes.
## Create an index service for Docs ## Create an index service for Docs
@@ -15,27 +15,38 @@ See [how-to-use-indexer.md](how-to-use-indexer.md) for details.
## Configure settings of Docs ## Configure settings of Docs
Add those Django settings the Docs application to enable the feature. Find uses a service provider authentication for indexing and a OIDC authentication for searching.
Add those Django settings to the Docs application to enable the feature.
```shell ```shell
SEARCH_INDEXER_CLASS="core.services.search_indexers.FindDocumentIndexer" SEARCH_INDEXER_CLASS="core.services.search_indexers.FindDocumentIndexer"
SEARCH_INDEXER_COUNTDOWN=10 # Debounce delay in seconds for the indexer calls. SEARCH_INDEXER_COUNTDOWN=10 # Debounce delay in seconds for the indexer calls.
SEARCH_INDEXER_QUERY_LIMIT=50 # Maximum number of results expected from the search endpoint
# The token from service "docs" of Find application (development). INDEXING_URL="http://find:8000/api/v1.0/documents/index/"
SEARCH_URL="http://find:8000/api/v1.0/documents/search/"
# Service provider authentication
SEARCH_INDEXER_SECRET="find-api-key-for-docs-with-exactly-50-chars-length" SEARCH_INDEXER_SECRET="find-api-key-for-docs-with-exactly-50-chars-length"
SEARCH_INDEXER_URL="http://find:8000/api/v1.0/documents/index/"
# Search endpoint. Uses the OIDC token for authentication # OIDC authentication
SEARCH_INDEXER_QUERY_URL="http://find:8000/api/v1.0/documents/search/" OIDC_STORE_ACCESS_TOKEN=True # Store the access token in the session
# Maximum number of results expected from the search endpoint OIDC_STORE_REFRESH_TOKEN=True # Store the encrypted refresh token in the session
SEARCH_INDEXER_QUERY_LIMIT=50 OIDC_STORE_REFRESH_TOKEN_KEY="<your-32-byte-encryption-key==>"
``` ```
We also need to enable the **OIDC Token** refresh or the authentication will fail quickly. `OIDC_STORE_REFRESH_TOKEN_KEY` must be a valid Fernet key (32 url-safe base64-encoded bytes).
To create one, use the `bin/generate-oidc-store-refresh-token-key.sh` command.
```shell ## Feature flags
# Store OIDC tokens in the session
OIDC_STORE_ACCESS_TOKEN = True # Store the access token in the session The Find search integration is controlled by two feature flags:
OIDC_STORE_REFRESH_TOKEN = True # Store the encrypted refresh token in the session - `flag_find_hybrid_search`
OIDC_STORE_REFRESH_TOKEN_KEY = "your-32-byte-encryption-key==" # Must be a valid Fernet key (32 url-safe base64-encoded bytes) - `flag_find_full_text_search`
```
If a user has both flags activated the most advanced search is used (hybrid > full text > title).
A user with no flag will default to the basic title search.
Feature flags can be activated through the admin interface.

View File

@@ -51,9 +51,18 @@ LOGOUT_REDIRECT_URL=http://localhost:3000
OIDC_REDIRECT_ALLOWED_HOSTS="localhost:8083,localhost:3000" OIDC_REDIRECT_ALLOWED_HOSTS="localhost:8083,localhost:3000"
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"} OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
# Resource Server Backend
OIDC_OP_URL=http://localhost:8083/realms/docs
OIDC_OP_INTROSPECTION_ENDPOINT = http://nginx:8083/realms/docs/protocol/openid-connect/token/introspect
OIDC_RESOURCE_SERVER_ENABLED=False
OIDC_RS_CLIENT_ID=docs
OIDC_RS_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
OIDC_RS_AUDIENCE_CLAIM="client_id" # The claim used to identify the audience
OIDC_RS_ALLOWED_AUDIENCES=""
# Store OIDC tokens in the session. Needed by search/ endpoint. # Store OIDC tokens in the session. Needed by search/ endpoint.
# OIDC_STORE_ACCESS_TOKEN = True # OIDC_STORE_ACCESS_TOKEN=True
# OIDC_STORE_REFRESH_TOKEN = True # Store the encrypted refresh token in the session. # 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) # Must be a valid Fernet key (32 url-safe base64-encoded bytes)
# To create one, use the bin/fernetkey command. # To create one, use the bin/fernetkey command.
@@ -87,8 +96,9 @@ DOCSPEC_API_URL=http://docspec:4000/conversion
# Theme customization # Theme customization
THEME_CUSTOMIZATION_CACHE_TIMEOUT=15 THEME_CUSTOMIZATION_CACHE_TIMEOUT=15
# Indexer (disabled) # Indexer (disabled by default)
# SEARCH_INDEXER_CLASS="core.services.search_indexers.SearchIndexer" # 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_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/" INDEXING_URL=http://find:8000/api/v1.0/documents/index/
SEARCH_INDEXER_QUERY_URL="http://find:8000/api/v1.0/documents/search/" SEARCH_URL=http://find:8000/api/v1.0/documents/search/
SEARCH_INDEXER_QUERY_LIMIT=50

View File

@@ -0,0 +1,7 @@
# Test environment configuration for running tests without docker
# Base configuration is loaded from 'common' file
DJANGO_SETTINGS_MODULE=impress.settings
DJANGO_CONFIGURATION=Test
DB_PORT=15432
AWS_S3_ENDPOINT_URL=http://localhost:9000

View File

@@ -43,18 +43,30 @@
"matchPackageNames": ["pydantic-ai-slim"], "matchPackageNames": ["pydantic-ai-slim"],
"allowedVersions": "<1.59.0" "allowedVersions": "<1.59.0"
}, },
{
"groupName": "allowed langfuse versions",
"matchManagers": ["pep621"],
"matchPackageNames": ["langfuse"],
"allowedVersions": "<3.12.0"
},
{
"groupName": "allowed django-treebeard versions",
"matchManagers": ["pep621"],
"matchPackageNames": ["django-treebeard"],
"allowedVersions": "<5.0.0"
},
{ {
"enabled": false, "enabled": false,
"groupName": "ignored js dependencies", "groupName": "ignored js dependencies",
"matchManagers": ["npm"], "matchManagers": ["npm"],
"matchPackageNames": [ "matchPackageNames": [
"@next/eslint-plugin-next", "@react-pdf/renderer",
"eslint-config-next",
"fetch-mock", "fetch-mock",
"next",
"node", "node",
"node-fetch", "node-fetch",
"react-resizable-panels", "react-resizable-panels",
"stylelint",
"stylelint-config-standard",
"workbox-webpack-plugin" "workbox-webpack-plugin"
] ]
} }

View File

@@ -47,10 +47,13 @@ class DocumentFilter(django_filters.FilterSet):
title = AccentInsensitiveCharFilter( title = AccentInsensitiveCharFilter(
field_name="title", lookup_expr="unaccent__icontains", label=_("Title") field_name="title", lookup_expr="unaccent__icontains", label=_("Title")
) )
q = AccentInsensitiveCharFilter(
field_name="title", lookup_expr="unaccent__icontains", label=_("Search")
)
class Meta: class Meta:
model = models.Document model = models.Document
fields = ["title"] fields = ["title", "q"]
class ListDocumentFilter(DocumentFilter): class ListDocumentFilter(DocumentFilter):
@@ -70,7 +73,7 @@ class ListDocumentFilter(DocumentFilter):
class Meta: class Meta:
model = models.Document model = models.Document
fields = ["is_creator_me", "is_favorite", "title"] fields = ["is_creator_me", "is_favorite", "title", "q"]
# pylint: disable=unused-argument # pylint: disable=unused-argument
def filter_is_creator_me(self, queryset, name, value): def filter_is_creator_me(self, queryset, name, value):

View File

@@ -32,8 +32,21 @@ class UserSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = models.User model = models.User
fields = ["id", "email", "full_name", "short_name", "language"] fields = [
read_only_fields = ["id", "email", "full_name", "short_name"] "id",
"email",
"full_name",
"short_name",
"language",
"is_first_connection",
]
read_only_fields = [
"id",
"email",
"full_name",
"short_name",
"is_first_connection",
]
def get_full_name(self, instance): def get_full_name(self, instance):
"""Return the full name of the user.""" """Return the full name of the user."""
@@ -287,6 +300,15 @@ class DocumentSerializer(ListDocumentSerializer):
return file return file
def update(self, instance, validated_data):
"""
When no data is sent on the update, skip making the update in the database and return
directly the instance unchanged.
"""
if not validated_data:
return instance # No data provided, skip the update
return super().update(instance, validated_data)
def save(self, **kwargs): def save(self, **kwargs):
""" """
Process the content field to extract attachment keys and update the document's Process the content field to extract attachment keys and update the document's
@@ -991,8 +1013,5 @@ class ThreadSerializer(serializers.ModelSerializer):
class SearchDocumentSerializer(serializers.Serializer): class SearchDocumentSerializer(serializers.Serializer):
"""Serializer for fulltext search requests through Find application""" """Serializer for fulltext search requests through Find application"""
q = serializers.CharField(required=True, allow_blank=False, trim_whitespace=True) q = serializers.CharField(required=True, allow_blank=True, trim_whitespace=True)
page_size = serializers.IntegerField( path = serializers.CharField(required=False, allow_blank=False)
required=False, min_value=1, max_value=50, default=20
)
page = serializers.IntegerField(required=False, min_value=1, default=1)

View File

@@ -6,8 +6,10 @@ from abc import ABC, abstractmethod
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.core.files.storage import default_storage from django.core.files.storage import default_storage
from django.utils.decorators import method_decorator
import botocore import botocore
from lasuite.oidc_login.decorators import refresh_oidc_access_token
from rest_framework.throttling import BaseThrottle from rest_framework.throttling import BaseThrottle
@@ -91,6 +93,19 @@ def generate_s3_authorization_headers(key):
return request return request
def conditional_refresh_oidc_token(func):
"""
Conditionally apply refresh_oidc_access_token decorator.
The decorator is only applied if OIDC_STORE_REFRESH_TOKEN is True, meaning
we can actually refresh something. Broader settings checks are done in settings.py.
"""
if settings.OIDC_STORE_REFRESH_TOKEN:
return method_decorator(refresh_oidc_access_token)(func)
return func
class AIBaseRateThrottle(BaseThrottle, ABC): class AIBaseRateThrottle(BaseThrottle, ABC):
"""Base throttle class for AI-related rate limiting with backoff.""" """Base throttle class for AI-related rate limiting with backoff."""

View File

@@ -25,7 +25,6 @@ from django.db.models.functions import Greatest, Left, Length
from django.http import Http404, StreamingHttpResponse from django.http import Http404, StreamingHttpResponse
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.decorators import method_decorator
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.http import content_disposition_header from django.utils.http import content_disposition_header
from django.utils.text import capfirst, slugify from django.utils.text import capfirst, slugify
@@ -33,11 +32,11 @@ from django.utils.translation import gettext_lazy as _
import requests import requests
import rest_framework as drf import rest_framework as drf
import waffle
from botocore.exceptions import ClientError from botocore.exceptions import ClientError
from csp.constants import NONE from csp.constants import NONE
from csp.decorators import csp_update from csp.decorators import csp_update
from lasuite.malware_detection import malware_detection from lasuite.malware_detection import malware_detection
from lasuite.oidc_login.decorators import refresh_oidc_access_token
from lasuite.tools.email import get_domain_from_email from lasuite.tools.email import get_domain_from_email
from pydantic import ValidationError as PydanticValidationError from pydantic import ValidationError as PydanticValidationError
from rest_framework import filters, status, viewsets from rest_framework import filters, status, viewsets
@@ -71,8 +70,13 @@ from core.utils import (
users_sharing_documents_with, users_sharing_documents_with,
) )
from ..enums import FeatureFlag, SearchType
from . import permissions, serializers, utils from . import permissions, serializers, utils
from .filters import DocumentFilter, ListDocumentFilter, UserSearchFilter from .filters import (
DocumentFilter,
ListDocumentFilter,
UserSearchFilter,
)
from .throttling import ( from .throttling import (
DocumentThrottle, DocumentThrottle,
UserListThrottleBurst, UserListThrottleBurst,
@@ -318,6 +322,25 @@ class UserViewSet(
self.serializer_class(request.user, context=context).data self.serializer_class(request.user, context=context).data
) )
@drf.decorators.action(
detail=False,
methods=["post"],
url_path="onboarding-done",
permission_classes=[permissions.IsAuthenticated],
)
def onboarding_done(self, request):
"""
Allows the frontend to mark the first connection as done for the current user,
e.g. after showing an onboarding message.
"""
if request.user.is_first_connection:
request.user.is_first_connection = False
request.user.save(update_fields=["is_first_connection", "updated_at"])
return drf.response.Response(
{"detail": "Onboarding marked as done."}, status=status.HTTP_200_OK
)
class ReconciliationConfirmView(APIView): class ReconciliationConfirmView(APIView):
"""API endpoint to confirm user reconciliation emails. """API endpoint to confirm user reconciliation emails.
@@ -432,36 +455,45 @@ class DocumentViewSet(
### Additional Actions: ### Additional Actions:
1. **Trashbin**: List soft deleted documents for a document owner 1. **Trashbin**: List soft deleted documents for a document owner
Example: GET /documents/{id}/trashbin/ Example: GET /documents/trashbin/
2. **Children**: List or create child documents. 2. **Restore**: Restore a soft deleted document.
Example: POST /documents/{id}/restore/
3. **Move**: Move a document to another parent document.
Example: POST /documents/{id}/move/
4. **Duplicate**: Duplicate a document.
Example: POST /documents/{id}/duplicate/
5. **Children**: List or create child documents.
Example: GET, POST /documents/{id}/children/ Example: GET, POST /documents/{id}/children/
3. **Versions List**: Retrieve version history of a document. 6. **Versions List**: Retrieve version history of a document.
Example: GET /documents/{id}/versions/ Example: GET /documents/{id}/versions/
4. **Version Detail**: Get or delete a specific document version. 7. **Version Detail**: Get or delete a specific document version.
Example: GET, DELETE /documents/{id}/versions/{version_id}/ Example: GET, DELETE /documents/{id}/versions/{version_id}/
5. **Favorite**: Get list of favorite documents for a user. Mark or unmark 8. **Favorite**: Get list of favorite documents for a user. Mark or unmark
a document as favorite. a document as favorite.
Examples: Examples:
- GET /documents/favorite/ - GET /documents/favorite_list/
- POST, DELETE /documents/{id}/favorite/ - POST, DELETE /documents/{id}/favorite/
6. **Create for Owner**: Create a document via server-to-server on behalf of a user. 9. **Create for Owner**: Create a document via server-to-server on behalf of a user.
Example: POST /documents/create-for-owner/ Example: POST /documents/create-for-owner/
7. **Link Configuration**: Update document link configuration. 10. **Link Configuration**: Update document link configuration.
Example: PUT /documents/{id}/link-configuration/ Example: PUT /documents/{id}/link-configuration/
8. **Attachment Upload**: Upload a file attachment for the document. 11. **Attachment Upload**: Upload a file attachment for the document.
Example: POST /documents/{id}/attachment-upload/ Example: POST /documents/{id}/attachment-upload/
9. **Media Auth**: Authorize access to document media. 12. **Media Auth**: Authorize access to document media.
Example: GET /documents/media-auth/ Example: GET /documents/media-auth/
10. **AI Transform**: Apply a transformation action on a piece of text with AI. 13. **AI Transform**: Apply a transformation action on a piece of text with AI.
Example: POST /documents/{id}/ai-transform/ Example: POST /documents/{id}/ai-transform/
Expected data: Expected data:
- text (str): The input text. - text (str): The input text.
@@ -469,7 +501,7 @@ class DocumentViewSet(
Returns: JSON response with the processed text. Returns: JSON response with the processed text.
Throttled by: AIDocumentRateThrottle, AIUserRateThrottle. Throttled by: AIDocumentRateThrottle, AIUserRateThrottle.
11. **AI Translate**: Translate a piece of text with AI. 14. **AI Translate**: Translate a piece of text with AI.
Example: POST /documents/{id}/ai-translate/ Example: POST /documents/{id}/ai-translate/
Expected data: Expected data:
- text (str): The input text. - text (str): The input text.
@@ -477,7 +509,7 @@ class DocumentViewSet(
Returns: JSON response with the translated text. Returns: JSON response with the translated text.
Throttled by: AIDocumentRateThrottle, AIUserRateThrottle. Throttled by: AIDocumentRateThrottle, AIUserRateThrottle.
12. **AI Proxy**: Proxy an AI request to an external AI service. 15. **AI Proxy**: Proxy an AI request to an external AI service.
Example: POST /api/v1.0/documents/<resource_id>/ai-proxy Example: POST /api/v1.0/documents/<resource_id>/ai-proxy
### Ordering: created_at, updated_at, is_favorite, title ### Ordering: created_at, updated_at, is_favorite, title
@@ -585,20 +617,18 @@ class DocumentViewSet(
It performs early filtering on model fields, annotates user roles, and removes It performs early filtering on model fields, annotates user roles, and removes
descendant documents to keep only the highest ancestors readable by the current user. descendant documents to keep only the highest ancestors readable by the current user.
""" """
user = self.request.user user = request.user
# Not calling filter_queryset. We do our own cooking. # Not calling filter_queryset. We do our own cooking.
queryset = self.get_queryset() queryset = self.get_queryset()
filterset = ListDocumentFilter( filterset = ListDocumentFilter(request.GET, queryset=queryset, request=request)
self.request.GET, queryset=queryset, request=self.request
)
if not filterset.is_valid(): if not filterset.is_valid():
raise drf.exceptions.ValidationError(filterset.errors) raise drf.exceptions.ValidationError(filterset.errors)
filter_data = filterset.form.cleaned_data filter_data = filterset.form.cleaned_data
# Filter as early as possible on fields that are available on the model # Filter as early as possible on fields that are available on the model
for field in ["is_creator_me", "title"]: for field in ["is_creator_me", "title", "q"]:
queryset = filterset.filters[field].filter(queryset, filter_data[field]) queryset = filterset.filters[field].filter(queryset, filter_data[field])
queryset = queryset.annotate_user_roles(user) queryset = queryset.annotate_user_roles(user)
@@ -1065,7 +1095,7 @@ class DocumentViewSet(
filter_data = filterset.form.cleaned_data filter_data = filterset.form.cleaned_data
# Filter as early as possible on fields that are available on the model # Filter as early as possible on fields that are available on the model
for field in ["is_creator_me", "title"]: for field in ["is_creator_me", "title", "q"]:
queryset = filterset.filters[field].filter(queryset, filter_data[field]) queryset = filterset.filters[field].filter(queryset, filter_data[field])
queryset = queryset.annotate_user_roles(user) queryset = queryset.annotate_user_roles(user)
@@ -1088,7 +1118,11 @@ class DocumentViewSet(
ordering=["path"], ordering=["path"],
) )
def descendants(self, request, *args, **kwargs): def descendants(self, request, *args, **kwargs):
"""Handle listing descendants of a document""" """Deprecated endpoint to list descendants of a document."""
logger.warning(
"The 'descendants' endpoint is deprecated and will be removed in a future release. "
"The search endpoint should be used for all document retrieval use cases."
)
document = self.get_object() document = self.get_object()
queryset = document.get_descendants().filter(ancestors_deleted_at__isnull=True) queryset = document.get_descendants().filter(ancestors_deleted_at__isnull=True)
@@ -1325,7 +1359,7 @@ class DocumentViewSet(
) )
else: else:
duplicated_document = document_to_duplicate.add_sibling( duplicated_document = document_to_duplicate.add_sibling(
"right", "last-sibling",
title=title, title=title,
content=base64_yjs_content, content=base64_yjs_content,
attachments=attachments, attachments=attachments,
@@ -1378,82 +1412,122 @@ class DocumentViewSet(
return duplicated_document return duplicated_document
def _search_simple(self, request, text):
"""
Returns a queryset filtered by the content of the document title
"""
# 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)
return self.get_response_for_queryset(
queryset.order_by("-updated_at"),
context={
"request": request,
},
)
def _search_fulltext(self, indexer, request, params):
"""
Returns a queryset from the results the fulltext search of Find
"""
access_token = request.session.get("oidc_access_token")
user = request.user
text = params.validated_data["q"]
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),
)
docs_by_uuid = {str(d.pk): d for d in queryset.filter(pk__in=results)}
ordered_docs = [docs_by_uuid[id] for id in results]
page = self.paginate_queryset(ordered_docs)
serializer = self.get_serializer(
page if page else ordered_docs,
many=True,
context={
"request": request,
},
)
return self.get_paginated_response(serializer.data)
@drf.decorators.action(detail=False, methods=["get"], url_path="search") @drf.decorators.action(detail=False, methods=["get"], url_path="search")
@method_decorator(refresh_oidc_access_token) @utils.conditional_refresh_oidc_token
def search(self, request, *args, **kwargs): def search(self, request, *args, **kwargs):
""" """
Returns a DRF response containing the filtered, annotated and ordered document list. Returns an ordered list of documents best matching the search query parameter 'q'.
Applies filtering based on request parameter 'q' from `SearchDocumentSerializer`. It depends on a search configurable Search Indexer. If no Search Indexer is configured
Depending of the configuration it can be: or if it is not reachable, the function falls back to a basic title search.
- A fulltext search through the opensearch indexation app "find" if the backend is
enabled (see SEARCH_INDEXER_CLASS)
- A filtering by the model field 'title'.
The ordering is always by the most recent first.
""" """
params = serializers.SearchDocumentSerializer(data=request.query_params) params = serializers.SearchDocumentSerializer(data=request.query_params)
params.is_valid(raise_exception=True) params.is_valid(raise_exception=True)
search_type = self._get_search_type()
if search_type == SearchType.TITLE:
return self._title_search(request, params.validated_data, *args, **kwargs)
indexer = get_document_indexer() indexer = get_document_indexer()
if indexer is None:
# fallback on title search if the indexer is not configured
return self._title_search(request, params.validated_data, *args, **kwargs)
if indexer: try:
return self._search_fulltext(indexer, request, params=params) return self._search_with_indexer(
indexer, request, params=params, search_type=search_type
)
except requests.exceptions.RequestException as e:
logger.error("Error while searching documents with indexer: %s", e)
# fallback on title search if the indexer is not reached
return self._title_search(request, params.validated_data, *args, **kwargs)
# The indexer is not configured, we fallback on a simple icontains filter by the def _get_search_type(self) -> SearchType:
# model field 'title'. """
return self._search_simple(request, text=params.validated_data["q"]) Returns the search type to use for the search endpoint based on feature flags.
If a user has both flags activated the most advanced search is used
(HYBRID > FULL_TEXT > TITLE).
A user with no flag will default to the basic title search.
"""
if waffle.flag_is_active(self.request, FeatureFlag.FLAG_FIND_HYBRID_SEARCH):
return SearchType.HYBRID
if waffle.flag_is_active(self.request, FeatureFlag.FLAG_FIND_FULL_TEXT_SEARCH):
return SearchType.FULL_TEXT
return SearchType.TITLE
@staticmethod
def _search_with_indexer(indexer, request, params, search_type):
"""
Returns a list of documents matching the query (q) according to the configured indexer.
"""
queryset = models.Document.objects.all()
results = indexer.search(
q=params.validated_data["q"],
search_type=search_type,
token=request.session.get("oidc_access_token"),
path=(
params.validated_data["path"]
if "path" in params.validated_data
else None
),
visited=get_visited_document_ids_of(queryset, request.user),
)
return drf_response.Response(
{
"count": len(results),
"next": None,
"previous": None,
"results": results,
}
)
def _title_search(self, request, validated_data, *args, **kwargs):
"""
Fallback search method when no indexer is configured.
Only searches in the title field of documents.
"""
if not validated_data.get("path"):
return self.list(request, *args, **kwargs)
return self._list_descendants(request, validated_data)
def _list_descendants(self, request, validated_data):
"""
List all documents whose path starts with the provided path parameter.
Includes the parent document itself.
Used internally by the search endpoint when path filtering is requested.
"""
# Get parent document without access filtering
parent_path = validated_data["path"]
try:
parent = models.Document.objects.annotate_user_roles(request.user).get(
path=parent_path
)
except models.Document.DoesNotExist as exc:
raise drf.exceptions.NotFound("Document not found from path.") from exc
abilities = parent.get_abilities(request.user)
if not abilities.get("search"):
raise drf.exceptions.PermissionDenied(
"You do not have permission to search within this document."
)
# Get descendants and include the parent, ordered by path
queryset = (
parent.get_descendants(include_self=True)
.filter(ancestors_deleted_at__isnull=True)
.order_by("path")
)
queryset = self.filter_queryset(queryset)
# filter by title
filterset = DocumentFilter(request.GET, queryset=queryset)
if not filterset.is_valid():
raise drf.exceptions.ValidationError(filterset.errors)
queryset = filterset.qs
return self.get_response_for_queryset(queryset)
@drf.decorators.action(detail=True, methods=["get"], url_path="versions") @drf.decorators.action(detail=True, methods=["get"], url_path="versions")
def versions_list(self, request, *args, **kwargs): def versions_list(self, request, *args, **kwargs):
@@ -2224,6 +2298,7 @@ class DocumentAccessViewSet(
"user__full_name", "user__full_name",
"user__email", "user__email",
"user__language", "user__language",
"user__is_first_connection",
"document__id", "document__id",
"document__path", "document__path",
"document__depth", "document__depth",

View File

@@ -3,7 +3,7 @@ Core application enums declaration
""" """
import re import re
from enum import StrEnum from enum import Enum, StrEnum
from django.conf import global_settings, settings from django.conf import global_settings, settings
from django.db import models from django.db import models
@@ -46,3 +46,24 @@ class DocumentAttachmentStatus(StrEnum):
PROCESSING = "processing" PROCESSING = "processing"
READY = "ready" READY = "ready"
class SearchType(str, Enum):
"""
Defines the possible search types for a document search query.
- TITLE: DRF based search in the title of the documents only.
- HYBRID and FULL_TEXT: more advanced search based on Find indexer.
"""
TITLE = "title"
HYBRID = "hybrid"
FULL_TEXT = "full-text"
class FeatureFlag(str, Enum):
"""
Defines the possible feature flags for the application.
"""
FLAG_FIND_HYBRID_SEARCH = "flag_find_hybrid_search"
FLAG_FIND_FULL_TEXT_SEARCH = "flag_find_full_text_search"

View File

@@ -0,0 +1,41 @@
"""Resource Server Permissions for the Docs app."""
from django.conf import settings
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from rest_framework import permissions
class ResourceServerClientPermission(permissions.BasePermission):
"""
Permission class for resource server views.
This provides a way to open the resource server views to a limited set of
Service Providers.
Note: we might add a more complex permission system in the future, based on
the Service Provider ID and the requested scopes.
"""
def has_permission(self, request, view):
"""
Check if the user is authenticated and the token introspection
provides an authorized Service Provider.
"""
if not isinstance(
request.successful_authenticator, ResourceServerAuthentication
):
# Not a resource server request
return False
# Check if the user is authenticated
if not request.user.is_authenticated:
return False
if (
hasattr(view, "resource_server_actions")
and view.action not in view.resource_server_actions
):
return False
# When used as a resource server, the request has a token audience
return (
request.resource_server_token_audience in settings.OIDC_RS_ALLOWED_AUDIENCES
)

View File

@@ -0,0 +1,91 @@
"""Resource Server Viewsets for the Docs app."""
from django.conf import settings
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from core.api.permissions import (
CanCreateInvitationPermission,
DocumentPermission,
IsSelf,
ResourceAccessPermission,
)
from core.api.viewsets import (
DocumentAccessViewSet,
DocumentViewSet,
InvitationViewset,
UserViewSet,
)
from core.external_api.permissions import ResourceServerClientPermission
# pylint: disable=too-many-ancestors
class ResourceServerRestrictionMixin:
"""
Mixin for Resource Server Viewsets to provide shortcut to get
configured actions for a given resource.
"""
def _get_resource_server_actions(self, resource_name):
"""Get resource_server_actions from settings."""
external_api_config = settings.EXTERNAL_API.get(resource_name, {})
return list(external_api_config.get("actions", []))
class ResourceServerDocumentViewSet(ResourceServerRestrictionMixin, DocumentViewSet):
"""Resource Server Viewset for Documents."""
authentication_classes = [ResourceServerAuthentication]
permission_classes = [ResourceServerClientPermission & DocumentPermission] # type: ignore
@property
def resource_server_actions(self):
"""Build resource_server_actions from settings."""
return self._get_resource_server_actions("documents")
class ResourceServerDocumentAccessViewSet(
ResourceServerRestrictionMixin, DocumentAccessViewSet
):
"""Resource Server Viewset for DocumentAccess."""
authentication_classes = [ResourceServerAuthentication]
permission_classes = [ResourceServerClientPermission & ResourceAccessPermission] # type: ignore
@property
def resource_server_actions(self):
"""Get resource_server_actions from settings."""
return self._get_resource_server_actions("document_access")
class ResourceServerInvitationViewSet(
ResourceServerRestrictionMixin, InvitationViewset
):
"""Resource Server Viewset for Invitations."""
authentication_classes = [ResourceServerAuthentication]
permission_classes = [
ResourceServerClientPermission & CanCreateInvitationPermission
]
@property
def resource_server_actions(self):
"""Get resource_server_actions from settings."""
return self._get_resource_server_actions("document_invitation")
class ResourceServerUserViewSet(ResourceServerRestrictionMixin, UserViewSet):
"""Resource Server Viewset for User."""
authentication_classes = [ResourceServerAuthentication]
permission_classes = [ResourceServerClientPermission & IsSelf] # type: ignore
@property
def resource_server_actions(self):
"""Get resource_server_actions from settings."""
return self._get_resource_server_actions("users")

View File

@@ -22,7 +22,7 @@ def set_path_on_existing_documents(apps, schema_editor):
# Iterate over all existing documents and make them root nodes # Iterate over all existing documents and make them root nodes
documents = Document.objects.order_by("created_at").values_list("id", flat=True) documents = Document.objects.order_by("created_at").values_list("id", flat=True)
numconv = NumConv(ALPHABET) numconv = NumConv(len(ALPHABET), ALPHABET)
updates = [] updates = []
for i, pk in enumerate(documents): for i, pk in enumerate(documents):

View File

@@ -0,0 +1,32 @@
# Generated by Django 5.2.11 on 2026-03-04 14:49
from django.db import migrations, models
def set_is_first_connection_false(apps, schema_editor):
"""Update all existing user.is_first_connection to False."""
user = apps.get_model("core", "User")
user.objects.update(is_first_connection=False)
class Migration(migrations.Migration):
dependencies = [
("core", "0029_userreconciliationcsvimport_userreconciliation"),
]
operations = [
migrations.AddField(
model_name="user",
name="is_first_connection",
field=models.BooleanField(
default=True,
help_text="Whether the user has completed the first connection process.",
verbose_name="first connection status",
),
),
migrations.RunPython(
set_is_first_connection_false,
reverse_code=migrations.RunPython.noop,
),
]

View File

@@ -0,0 +1,34 @@
# Generated by Django 5.2.12 on 2026-03-11 17:16
from django.conf import settings
from django.db import migrations
from core.models import PRIVILEGED_ROLES
def clean_onboarding_accesses(apps, schema_editor):
"""clean accesses on on-boarding documents."""
onboarding_document_ids = settings.USER_ONBOARDING_DOCUMENTS
if not onboarding_document_ids:
return
onboarding_document_ids = set(settings.USER_ONBOARDING_DOCUMENTS)
DocumentAccess = apps.get_model("core", "DocumentAccess")
DocumentAccess.objects.filter(document_id__in=onboarding_document_ids).exclude(
role__in=PRIVILEGED_ROLES
).delete()
class Migration(migrations.Migration):
dependencies = [
("core", "0030_user_is_first_connection"),
]
operations = [
migrations.RunPython(
clean_onboarding_accesses,
reverse_code=migrations.RunPython.noop,
),
]

View File

@@ -19,7 +19,7 @@ from django.core.cache import cache
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.files.storage import default_storage from django.core.files.storage import default_storage
from django.core.mail import send_mail from django.core.mail import send_mail
from django.db import models, transaction from django.db import connection, models, transaction
from django.db.models.functions import Left, Length from django.db.models.functions import Left, Length
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.utils import timezone from django.utils import timezone
@@ -193,6 +193,11 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"Unselect this instead of deleting accounts." "Unselect this instead of deleting accounts."
), ),
) )
is_first_connection = models.BooleanField(
_("first connection status"),
default=True,
help_text=_("Whether the user has completed the first connection process."),
)
objects = UserManager() objects = UserManager()
@@ -222,11 +227,11 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
def _handle_onboarding_documents_access(self): def _handle_onboarding_documents_access(self):
""" """
If the user is new and there are documents configured to be given to new users, If the user is new and there are documents configured to be given to new users,
give access to these documents and pin them as favorites for the user. create link traces to these documents and pin them as favorites for the user.
""" """
if settings.USER_ONBOARDING_DOCUMENTS: if settings.USER_ONBOARDING_DOCUMENTS:
onboarding_document_ids = set(settings.USER_ONBOARDING_DOCUMENTS) onboarding_document_ids = set(settings.USER_ONBOARDING_DOCUMENTS)
onboarding_accesses = [] onboarding_link_traces = []
favorite_documents = [] favorite_documents = []
for document_id in onboarding_document_ids: for document_id in onboarding_document_ids:
try: try:
@@ -238,16 +243,20 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
) )
continue continue
onboarding_accesses.append( if document.link_reach == LinkReachChoices.RESTRICTED:
DocumentAccess( logger.warning(
user=self, document=document, role=RoleChoices.READER "Onboarding on a restricted document is not allowed. Must be public or "
"connected. Restricted document: %s",
document_id,
) )
) continue
onboarding_link_traces.append(LinkTrace(user=self, document=document))
favorite_documents.append( favorite_documents.append(
DocumentFavorite(user=self, document_id=document_id) DocumentFavorite(user=self, document_id=document_id)
) )
DocumentAccess.objects.bulk_create(onboarding_accesses) LinkTrace.objects.bulk_create(onboarding_link_traces)
DocumentFavorite.objects.bulk_create(favorite_documents) DocumentFavorite.objects.bulk_create(favorite_documents)
def _duplicate_onboarding_sandbox_document(self): def _duplicate_onboarding_sandbox_document(self):
@@ -256,29 +265,37 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
duplicate the sandbox document for the user duplicate the sandbox document for the user
""" """
if settings.USER_ONBOARDING_SANDBOX_DOCUMENT: if settings.USER_ONBOARDING_SANDBOX_DOCUMENT:
sandbox_id = settings.USER_ONBOARDING_SANDBOX_DOCUMENT # transaction.atomic is used in a context manager to avoid a transaction if
try: # the settings USER_ONBOARDING_SANDBOX_DOCUMENT is unused
template_document = Document.objects.get(id=sandbox_id) with transaction.atomic():
# locks the table to ensure safe concurrent access
with connection.cursor() as cursor:
cursor.execute(
f'LOCK TABLE "{Document._meta.db_table}" ' # noqa: SLF001
"IN SHARE ROW EXCLUSIVE MODE;"
)
except Document.DoesNotExist: sandbox_id = settings.USER_ONBOARDING_SANDBOX_DOCUMENT
logger.warning( try:
"Onboarding sandbox document with id %s does not exist. Skipping.", template_document = Document.objects.get(id=sandbox_id)
sandbox_id, except Document.DoesNotExist:
logger.warning(
"Onboarding sandbox document with id %s does not exist. Skipping.",
sandbox_id,
)
return
sandbox_document = Document.add_root(
title=template_document.title,
content=template_document.content,
attachments=template_document.attachments,
duplicated_from=template_document,
creator=self,
) )
return
sandbox_document = template_document.add_sibling( DocumentAccess.objects.create(
"right", user=self, document=sandbox_document, role=RoleChoices.OWNER
title=template_document.title, )
content=template_document.content,
attachments=template_document.attachments,
duplicated_from=template_document,
creator=self,
)
DocumentAccess.objects.create(
user=self, document=sandbox_document, role=RoleChoices.OWNER
)
def _convert_valid_invitations(self): def _convert_valid_invitations(self):
""" """
@@ -1312,6 +1329,7 @@ class Document(MP_Node, BaseModel):
"versions_destroy": is_owner_or_admin, "versions_destroy": is_owner_or_admin,
"versions_list": has_access_role, "versions_list": has_access_role,
"versions_retrieve": has_access_role, "versions_retrieve": has_access_role,
"search": can_get,
} }
def send_email(self, subject, emails, context=None, language=None): def send_email(self, subject, emails, context=None, language=None):

View File

@@ -8,12 +8,12 @@ from functools import cache
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.db.models import Subquery
from django.utils.module_loading import import_string from django.utils.module_loading import import_string
import requests import requests
from core import models, utils from core import models, utils
from core.enums import SearchType
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -69,7 +69,7 @@ def get_batch_accesses_by_users_and_teams(paths):
return dict(access_by_document_path) return dict(access_by_document_path)
def get_visited_document_ids_of(queryset, user): def get_visited_document_ids_of(queryset, user) -> tuple[str, ...]:
""" """
Returns the ids of the documents that have a linktrace to the user and NOT owned. 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 It will be use to limit the opensearch responses to the public documents already
@@ -78,7 +78,9 @@ def get_visited_document_ids_of(queryset, user):
if isinstance(user, AnonymousUser): if isinstance(user, AnonymousUser):
return [] return []
qs = models.LinkTrace.objects.filter(user=user) visited_ids = models.LinkTrace.objects.filter(user=user).values_list(
"document_id", flat=True
)
docs = ( docs = (
queryset.exclude(accesses__user=user) queryset.exclude(accesses__user=user)
@@ -86,12 +88,12 @@ def get_visited_document_ids_of(queryset, user):
deleted_at__isnull=True, deleted_at__isnull=True,
ancestors_deleted_at__isnull=True, ancestors_deleted_at__isnull=True,
) )
.filter(pk__in=Subquery(qs.values("document_id"))) .filter(pk__in=visited_ids)
.order_by("pk") .order_by("pk")
.distinct("pk") .distinct("pk")
) )
return [str(id) for id in docs.values_list("pk", flat=True)] return tuple(str(id) for id in docs.values_list("pk", flat=True))
class BaseDocumentIndexer(ABC): class BaseDocumentIndexer(ABC):
@@ -107,15 +109,13 @@ class BaseDocumentIndexer(ABC):
Initialize the indexer. Initialize the indexer.
""" """
self.batch_size = settings.SEARCH_INDEXER_BATCH_SIZE self.batch_size = settings.SEARCH_INDEXER_BATCH_SIZE
self.indexer_url = settings.SEARCH_INDEXER_URL self.indexer_url = settings.INDEXING_URL
self.indexer_secret = settings.SEARCH_INDEXER_SECRET self.indexer_secret = settings.SEARCH_INDEXER_SECRET
self.search_url = settings.SEARCH_INDEXER_QUERY_URL self.search_url = settings.SEARCH_URL
self.search_limit = settings.SEARCH_INDEXER_QUERY_LIMIT self.search_limit = settings.SEARCH_INDEXER_QUERY_LIMIT
if not self.indexer_url: if not self.indexer_url:
raise ImproperlyConfigured( raise ImproperlyConfigured("INDEXING_URL must be set in Django settings.")
"SEARCH_INDEXER_URL must be set in Django settings."
)
if not self.indexer_secret: if not self.indexer_secret:
raise ImproperlyConfigured( raise ImproperlyConfigured(
@@ -123,9 +123,7 @@ class BaseDocumentIndexer(ABC):
) )
if not self.search_url: if not self.search_url:
raise ImproperlyConfigured( raise ImproperlyConfigured("SEARCH_URL must be set in Django settings.")
"SEARCH_INDEXER_QUERY_URL must be set in Django settings."
)
def index(self, queryset=None, batch_size=None): def index(self, queryset=None, batch_size=None):
""" """
@@ -184,8 +182,16 @@ class BaseDocumentIndexer(ABC):
Must be implemented by subclasses. Must be implemented by subclasses.
""" """
# pylint: disable-next=too-many-arguments,too-many-positional-arguments # pylint: disable=too-many-arguments, too-many-positional-arguments
def search(self, text, token, visited=(), nb_results=None): def search( # noqa : PLR0913
self,
q: str,
token: str,
visited: tuple[str, ...] = (),
nb_results: int = None,
path: str = None,
search_type: SearchType = None,
):
""" """
Search for documents in Find app. Search for documents in Find app.
Ensure the same default ordering as "Docs" list : -updated_at Ensure the same default ordering as "Docs" list : -updated_at
@@ -193,7 +199,7 @@ class BaseDocumentIndexer(ABC):
Returns ids of the documents Returns ids of the documents
Args: Args:
text (str): Text search content. q (str): user query.
token (str): OIDC Authentication token. token (str): OIDC Authentication token.
visited (list, optional): visited (list, optional):
List of ids of active public documents with LinkTrace List of ids of active public documents with LinkTrace
@@ -201,21 +207,28 @@ class BaseDocumentIndexer(ABC):
nb_results (int, optional): nb_results (int, optional):
The number of results to return. The number of results to return.
Defaults to 50 if not specified. Defaults to 50 if not specified.
path (str, optional):
The parent path to search descendants of.
search_type (SearchType, optional):
Type of search to perform. Can be SearchType.HYBRID or SearchType.FULL_TEXT.
If None, the backend search service will use its default search behavior.
""" """
nb_results = nb_results or self.search_limit nb_results = nb_results or self.search_limit
response = self.search_query( results = self.search_query(
data={ data={
"q": text, "q": q,
"visited": visited, "visited": visited,
"services": ["docs"], "services": ["docs"],
"nb_results": nb_results, "nb_results": nb_results,
"order_by": "updated_at", "order_by": "updated_at",
"order_direction": "desc", "order_direction": "desc",
"path": path,
"search_type": search_type,
}, },
token=token, token=token,
) )
return [d["_id"] for d in response] return results
@abstractmethod @abstractmethod
def search_query(self, data, token) -> dict: def search_query(self, data, token) -> dict:
@@ -226,11 +239,72 @@ class BaseDocumentIndexer(ABC):
""" """
class SearchIndexer(BaseDocumentIndexer): class FindDocumentIndexer(BaseDocumentIndexer):
""" """
Document indexer that pushes documents to La Suite Find app. Document indexer that indexes and searches documents with La Suite Find app.
""" """
# pylint: disable=too-many-arguments, too-many-positional-arguments
def search( # noqa : PLR0913
self,
q: str,
token: str,
visited: tuple[()] = (),
nb_results: int = None,
path: str = None,
search_type: SearchType = None,
):
"""format Find search results"""
search_results = super().search(
q=q,
token=token,
visited=visited,
nb_results=nb_results,
path=path,
search_type=search_type,
)
return [
{
**hit["_source"],
"id": hit["_id"],
"title": self.get_title(hit["_source"]),
}
for hit in search_results
]
@staticmethod
def get_title(source):
"""
Find returns the titles with an extension depending on the language.
This function extracts the title in a generic way.
Handles multiple cases:
- Localized title fields like "title.<some_extension>"
- Fallback to plain "title" field if localized version not found
- Returns empty string if no title field exists
Args:
source (dict): The _source dictionary from a search hit
Returns:
str: The extracted title or empty string if not found
Example:
>>> get_title({"title.fr": "Bonjour", "id": 1})
"Bonjour"
>>> get_title({"title": "Hello", "id": 1})
"Hello"
>>> get_title({"id": 1})
""
"""
titles = utils.get_value_by_pattern(source, r"^title\.")
for title in titles:
if title:
return title
if "title" in source:
return source["title"]
return ""
def serialize_document(self, document, accesses): def serialize_document(self, document, accesses):
""" """
Convert a Document to the JSON format expected by La Suite Find. Convert a Document to the JSON format expected by La Suite Find.

View File

@@ -63,7 +63,7 @@ def batch_document_indexer_task(timestamp):
logger.info("Indexed %d documents", count) logger.info("Indexed %d documents", count)
def trigger_batch_document_indexer(item): def trigger_batch_document_indexer(document):
""" """
Trigger indexation task with debounce a delay set by the SEARCH_INDEXER_COUNTDOWN setting. Trigger indexation task with debounce a delay set by the SEARCH_INDEXER_COUNTDOWN setting.
@@ -82,14 +82,14 @@ def trigger_batch_document_indexer(item):
if batch_indexer_throttle_acquire(timeout=countdown): if batch_indexer_throttle_acquire(timeout=countdown):
logger.info( logger.info(
"Add task for batch document indexation from updated_at=%s in %d seconds", "Add task for batch document indexation from updated_at=%s in %d seconds",
item.updated_at.isoformat(), document.updated_at.isoformat(),
countdown, countdown,
) )
batch_document_indexer_task.apply_async( batch_document_indexer_task.apply_async(
args=[item.updated_at], countdown=countdown args=[document.updated_at], countdown=countdown
) )
else: else:
logger.info("Skip task for batch document %s indexation", item.pk) logger.info("Skip task for batch document %s indexation", document.pk)
else: else:
document_indexer_task.apply(args=[item.pk]) document_indexer_task.apply(args=[document.pk])

View File

@@ -11,7 +11,7 @@ from django.db import transaction
import pytest import pytest
from core import factories from core import factories
from core.services.search_indexers import SearchIndexer from core.services.search_indexers import FindDocumentIndexer
@pytest.mark.django_db @pytest.mark.django_db
@@ -19,7 +19,7 @@ from core.services.search_indexers import SearchIndexer
def test_index(): def test_index():
"""Test the command `index` that run the Find app indexer for all the available documents.""" """Test the command `index` that run the Find app indexer for all the available documents."""
user = factories.UserFactory() user = factories.UserFactory()
indexer = SearchIndexer() indexer = FindDocumentIndexer()
with transaction.atomic(): with transaction.atomic():
doc = factories.DocumentFactory() doc = factories.DocumentFactory()
@@ -36,7 +36,7 @@ def test_index():
str(no_title_doc.path): {"users": [user.sub]}, str(no_title_doc.path): {"users": [user.sub]},
} }
with mock.patch.object(SearchIndexer, "push") as mock_push: with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
call_command("index") call_command("index")
push_call_args = [call.args[0] for call in mock_push.call_args_list] push_call_args = [call.args[0] for call in mock_push.call_args_list]

View File

@@ -1,10 +1,15 @@
"""Fixtures for tests in the impress core application""" """Fixtures for tests in the impress core application"""
import base64
from unittest import mock from unittest import mock
from django.core.cache import cache from django.core.cache import cache
import pytest import pytest
import responses
from core import factories
from core.tests.utils.urls import reload_urls
USER = "user" USER = "user"
TEAM = "team" TEAM = "team"
@@ -39,15 +44,102 @@ def indexer_settings_fixture(settings):
get_document_indexer.cache_clear() get_document_indexer.cache_clear()
settings.SEARCH_INDEXER_CLASS = "core.services.search_indexers.SearchIndexer" settings.SEARCH_INDEXER_CLASS = "core.services.search_indexers.FindDocumentIndexer"
settings.SEARCH_INDEXER_SECRET = "ThisIsAKeyForTest" settings.SEARCH_INDEXER_SECRET = "ThisIsAKeyForTest"
settings.SEARCH_INDEXER_URL = "http://localhost:8081/api/v1.0/documents/index/" settings.INDEXING_URL = "http://localhost:8081/api/v1.0/documents/index/"
settings.SEARCH_INDEXER_QUERY_URL = ( settings.SEARCH_URL = "http://localhost:8081/api/v1.0/documents/search/"
"http://localhost:8081/api/v1.0/documents/search/"
)
settings.SEARCH_INDEXER_COUNTDOWN = 1 settings.SEARCH_INDEXER_COUNTDOWN = 1
yield settings yield settings
# clear cache to prevent issues with other tests # clear cache to prevent issues with other tests
get_document_indexer.cache_clear() get_document_indexer.cache_clear()
def resource_server_backend_setup(settings):
"""
A fixture to create a user token for testing.
"""
assert (
settings.OIDC_RS_BACKEND_CLASS
== "lasuite.oidc_resource_server.backend.ResourceServerBackend"
)
settings.OIDC_RESOURCE_SERVER_ENABLED = True
settings.OIDC_RS_CLIENT_ID = "some_client_id"
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
settings.OIDC_OP_URL = "https://oidc.example.com"
settings.OIDC_VERIFY_SSL = False
settings.OIDC_TIMEOUT = 5
settings.OIDC_PROXY = None
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
settings.OIDC_RS_SCOPES = ["openid", "groups"]
settings.OIDC_RS_ALLOWED_AUDIENCES = ["some_service_provider"]
@pytest.fixture
def resource_server_backend_conf(settings):
"""
A fixture to create a user token for testing.
"""
resource_server_backend_setup(settings)
reload_urls()
@pytest.fixture
def resource_server_backend(settings):
"""
A fixture to create a user token for testing.
Including a mocked introspection endpoint.
"""
resource_server_backend_setup(settings)
reload_urls()
with responses.RequestsMock() as rsps:
rsps.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
"sub": "very-specific-sub",
"client_id": "some_service_provider",
"scope": "openid groups",
"active": True,
},
)
yield rsps
@pytest.fixture
def user_specific_sub():
"""
A fixture to create a user token for testing.
"""
user = factories.UserFactory(sub="very-specific-sub", full_name="External User")
yield user
def build_authorization_bearer(token):
"""
Build an Authorization Bearer header value from a token.
This can be used like this:
client.post(
...
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer('some_token')}",
)
"""
return base64.b64encode(token.encode("utf-8")).decode("utf-8")
@pytest.fixture
def user_token():
"""
A fixture to create a user token for testing.
"""
return build_authorization_bearer("some_token")

View File

@@ -245,15 +245,18 @@ def test_api_document_accesses_list_authenticated_related_privileged(
"path": access.document.path, "path": access.document.path,
"depth": access.document.depth, "depth": access.document.depth,
}, },
"user": { "user": (
"id": str(access.user.id), {
"email": access.user.email, "id": str(access.user.id),
"language": access.user.language, "email": access.user.email,
"full_name": access.user.full_name, "language": access.user.language,
"short_name": access.user.short_name, "full_name": access.user.full_name,
} "short_name": access.user.short_name,
if access.user "is_first_connection": access.user.is_first_connection,
else None, }
if access.user
else None
),
"max_ancestors_role": None, "max_ancestors_role": None,
"max_role": access.role, "max_role": access.role,
"team": access.team, "team": access.team,

View File

@@ -123,7 +123,7 @@ def test_api_documents_duplicate_success(index):
image_refs[0][0] image_refs[0][0]
] # Only the first image key ] # Only the first image key
assert duplicated_document.get_parent() == document.get_parent() assert duplicated_document.get_parent() == document.get_parent()
assert duplicated_document.path == document.get_next_sibling().path assert duplicated_document.path == document.get_last_sibling().path
# Check that accesses were not duplicated. # Check that accesses were not duplicated.
# The user who did the duplicate is forced as owner # The user who did the duplicate is forced as owner
@@ -180,6 +180,7 @@ def test_api_documents_duplicate_with_accesses_admin(role):
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
documents_before = factories.DocumentFactory.create_batch(20)
document = factories.DocumentFactory( document = factories.DocumentFactory(
users=[(user, role)], users=[(user, role)],
title="document with accesses", title="document with accesses",
@@ -187,6 +188,12 @@ def test_api_documents_duplicate_with_accesses_admin(role):
user_access = factories.UserDocumentAccessFactory(document=document) user_access = factories.UserDocumentAccessFactory(document=document)
team_access = factories.TeamDocumentAccessFactory(document=document) team_access = factories.TeamDocumentAccessFactory(document=document)
documents_after = factories.DocumentFactory.create_batch(20)
all_documents = documents_before + [document] + documents_after
paths = {document.pk: document.path for document in all_documents}
# Duplicate the document via the API endpoint requesting to duplicate accesses # Duplicate the document via the API endpoint requesting to duplicate accesses
response = client.post( response = client.post(
f"/api/v1.0/documents/{document.id!s}/duplicate/", f"/api/v1.0/documents/{document.id!s}/duplicate/",
@@ -212,6 +219,10 @@ def test_api_documents_duplicate_with_accesses_admin(role):
assert duplicated_accesses.get(user=user_access.user).role == user_access.role assert duplicated_accesses.get(user=user_access.user).role == user_access.role
assert duplicated_accesses.get(team=team_access.team).role == team_access.role assert duplicated_accesses.get(team=team_access.team).role == team_access.role
for document in all_documents:
document.refresh_from_db()
assert document.path == paths[document.id]
@pytest.mark.parametrize("role", ["editor", "reader"]) @pytest.mark.parametrize("role", ["editor", "reader"])
def test_api_documents_duplicate_with_accesses_non_admin(role): def test_api_documents_duplicate_with_accesses_non_admin(role):

View File

@@ -16,7 +16,16 @@ fake = Faker()
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
def test_api_documents_list_filter_and_access_rights(): @pytest.mark.parametrize(
"title_search_field",
# for integration with indexer search we must have
# the same filtering behaviour with "q" and "title" parameters
[
("title"),
("q"),
],
)
def test_api_documents_list_filter_and_access_rights(title_search_field):
"""Filtering on querystring parameters should respect access rights.""" """Filtering on querystring parameters should respect access rights."""
user = factories.UserFactory() user = factories.UserFactory()
client = APIClient() client = APIClient()
@@ -76,7 +85,7 @@ def test_api_documents_list_filter_and_access_rights():
filters = { filters = {
"link_reach": random.choice([None, *models.LinkReachChoices.values]), "link_reach": random.choice([None, *models.LinkReachChoices.values]),
"title": random.choice([None, *word_list]), title_search_field: random.choice([None, *word_list]),
"favorite": random.choice([None, True, False]), "favorite": random.choice([None, True, False]),
"creator": random.choice([None, user, other_user]), "creator": random.choice([None, user, other_user]),
"ordering": random.choice( "ordering": random.choice(

View File

@@ -59,6 +59,7 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"partial_update": document.link_role == "editor", "partial_update": document.link_role == "editor",
"restore": False, "restore": False,
"retrieve": True, "retrieve": True,
"search": True,
"tree": True, "tree": True,
"update": document.link_role == "editor", "update": document.link_role == "editor",
"versions_destroy": False, "versions_destroy": False,
@@ -136,6 +137,7 @@ def test_api_documents_retrieve_anonymous_public_parent():
"partial_update": grand_parent.link_role == "editor", "partial_update": grand_parent.link_role == "editor",
"restore": False, "restore": False,
"retrieve": True, "retrieve": True,
"search": True,
"tree": True, "tree": True,
"update": grand_parent.link_role == "editor", "update": grand_parent.link_role == "editor",
"versions_destroy": False, "versions_destroy": False,
@@ -246,6 +248,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"partial_update": document.link_role == "editor", "partial_update": document.link_role == "editor",
"restore": False, "restore": False,
"retrieve": True, "retrieve": True,
"search": True,
"tree": True, "tree": True,
"update": document.link_role == "editor", "update": document.link_role == "editor",
"versions_destroy": False, "versions_destroy": False,
@@ -330,6 +333,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
"partial_update": grand_parent.link_role == "editor", "partial_update": grand_parent.link_role == "editor",
"restore": False, "restore": False,
"retrieve": True, "retrieve": True,
"search": True,
"tree": True, "tree": True,
"update": grand_parent.link_role == "editor", "update": grand_parent.link_role == "editor",
"versions_destroy": False, "versions_destroy": False,
@@ -529,6 +533,7 @@ def test_api_documents_retrieve_authenticated_related_parent():
"partial_update": access.role not in ["reader", "commenter"], "partial_update": access.role not in ["reader", "commenter"],
"restore": access.role == "owner", "restore": access.role == "owner",
"retrieve": True, "retrieve": True,
"search": True,
"tree": True, "tree": True,
"update": access.role not in ["reader", "commenter"], "update": access.role not in ["reader", "commenter"],
"versions_destroy": access.role in ["administrator", "owner"], "versions_destroy": access.role in ["administrator", "owner"],

View File

@@ -1,46 +1,40 @@
""" """
Tests for Documents API endpoint in impress's core app: list Tests for Documents API endpoint in impress's core app: search
""" """
import random from unittest import mock
from json import loads as json_loads
from django.test import RequestFactory
import pytest import pytest
import responses import responses
from faker import Faker from faker import Faker
from rest_framework import response as drf_response
from rest_framework.test import APIClient from rest_framework.test import APIClient
from waffle.testutils import override_flag
from core import factories, models from core import factories
from core.enums import FeatureFlag, SearchType
from core.services.search_indexers import get_document_indexer from core.services.search_indexers import get_document_indexer
fake = Faker() fake = Faker()
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
def build_search_url(**kwargs): @pytest.fixture(autouse=True)
"""Build absolute uri for search endpoint with ORDERED query arguments""" def enable_flag_find_hybrid_search():
return ( """Enable flag_find_hybrid_search for all tests in this module."""
RequestFactory() with override_flag(FeatureFlag.FLAG_FIND_HYBRID_SEARCH, active=True):
.get("/api/v1.0/documents/search/", dict(sorted(kwargs.items()))) yield
.build_absolute_uri()
)
@pytest.mark.parametrize("role", models.LinkRoleChoices.values) @mock.patch("core.services.search_indexers.FindDocumentIndexer.search_query")
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
@responses.activate @responses.activate
def test_api_documents_search_anonymous(reach, role, indexer_settings): def test_api_documents_search_anonymous(search_query, indexer_settings):
""" """
Anonymous users should not be allowed to search documents whatever the Anonymous users should be allowed to search documents with Find.
link reach and link role
""" """
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search" indexer_settings.SEARCH_URL = "http://find/api/v1.0/search"
factories.DocumentFactory(link_reach=reach, link_role=role) # mock Find response
# Find response
responses.add( responses.add(
responses.POST, responses.POST,
"http://find/api/v1.0/search", "http://find/api/v1.0/search",
@@ -48,7 +42,23 @@ def test_api_documents_search_anonymous(reach, role, indexer_settings):
status=200, status=200,
) )
response = APIClient().get("/api/v1.0/documents/search/", data={"q": "alpha"}) q = "alpha"
response = APIClient().get("/api/v1.0/documents/search/", data={"q": q})
assert search_query.call_count == 1
assert search_query.call_args[1] == {
"data": {
"q": q,
"visited": [],
"services": ["docs"],
"nb_results": 50,
"order_by": "updated_at",
"order_direction": "desc",
"path": None,
"search_type": SearchType.HYBRID,
},
"token": None,
}
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == { assert response.json() == {
@@ -59,115 +69,163 @@ def test_api_documents_search_anonymous(reach, role, indexer_settings):
} }
def test_api_documents_search_endpoint_is_none(indexer_settings): @mock.patch("core.api.viewsets.DocumentViewSet.list")
def test_api_documents_search_fall_back_on_search_list(mock_list, settings):
""" """
Missing SEARCH_INDEXER_QUERY_URL, so the indexer is not properly configured. When indexer is not configured and no path is provided,
Should fallback on title filter should fall back on list method
""" """
indexer_settings.SEARCH_INDEXER_QUERY_URL = None
assert get_document_indexer() is None assert get_document_indexer() is None
assert settings.OIDC_STORE_REFRESH_TOKEN is False
assert settings.OIDC_STORE_ACCESS_TOKEN is False
user = factories.UserFactory() user = factories.UserFactory()
document = factories.DocumentFactory(title="alpha")
access = factories.UserDocumentAccessFactory(document=document, user=user)
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(
user, backend="core.authentication.backends.OIDCAuthenticationBackend"
)
response = client.get("/api/v1.0/documents/search/", data={"q": "alpha"}) mocked_response = {
"count": 0,
assert response.status_code == 200
content = response.json()
results = content.pop("results")
assert content == {
"count": 1,
"next": None, "next": None,
"previous": None, "previous": None,
"results": [{"title": "mocked list result"}],
} }
assert len(results) == 1 mock_list.return_value = drf_response.Response(mocked_response)
assert results[0] == {
"id": str(document.id), q = "alpha"
"abilities": document.get_abilities(user), response = client.get("/api/v1.0/documents/search/", data={"q": q})
"ancestors_link_reach": None,
"ancestors_link_role": None, assert response.status_code == 200
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role, assert mock_list.call_count == 1
"created_at": document.created_at.isoformat().replace("+00:00", "Z"), assert mock_list.call_args[0][0].GET.get("q") == q
"creator": str(document.creator.id), assert response.json() == mocked_response
"depth": 1,
"excerpt": document.excerpt,
"link_reach": document.link_reach, @mock.patch("core.api.viewsets.DocumentViewSet._list_descendants")
"link_role": document.link_role, def test_api_documents_search_fallback_on_search_list_sub_docs(
"nb_accesses_ancestors": 1, mock_list_descendants, settings
"nb_accesses_direct": 1, ):
"numchild": 0, """
"path": document.path, When indexer is not configured and path parameter is provided,
"title": document.title, should call _list_descendants() method
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), """
"deleted_at": None, assert get_document_indexer() is None
"user_role": access.role, assert settings.OIDC_STORE_REFRESH_TOKEN is False
assert settings.OIDC_STORE_ACCESS_TOKEN is False
user = factories.UserFactory()
client = APIClient()
client.force_login(
user, backend="core.authentication.backends.OIDCAuthenticationBackend"
)
parent = factories.DocumentFactory(title="parent", users=[user])
mocked_response = {
"count": 0,
"next": None,
"previous": None,
"results": [{"title": "mocked _list_descendants result"}],
} }
mock_list_descendants.return_value = drf_response.Response(mocked_response)
q = "alpha"
response = client.get(
"/api/v1.0/documents/search/", data={"q": q, "path": parent.path}
)
mock_list_descendants.assert_called_with(
mock.ANY, {"q": "alpha", "path": parent.path}
)
assert response.json() == mocked_response
@mock.patch("core.api.viewsets.DocumentViewSet._title_search")
def test_api_documents_search_indexer_crashes(mock_title_search, indexer_settings):
"""
When indexer is configured but crashes -> falls back on title_search
"""
# indexer is properly configured
indexer_settings.SEARCH_URL = None
assert get_document_indexer() is None
# but returns an error when the query is sent
responses.add(
responses.POST,
"http://find/api/v1.0/search",
json=[{"error": "Some indexer error"}],
status=404,
)
user = factories.UserFactory()
client = APIClient()
client.force_login(
user, backend="core.authentication.backends.OIDCAuthenticationBackend"
)
mocked_response = {
"count": 0,
"next": None,
"previous": None,
"results": [{"title": "mocked title_search result"}],
}
mock_title_search.return_value = drf_response.Response(mocked_response)
parent = factories.DocumentFactory(title="parent", users=[user])
q = "alpha"
response = client.get(
"/api/v1.0/documents/search/", data={"q": "alpha", "path": parent.path}
)
# the search endpoint did not crash
assert response.status_code == 200
# fallback on title_search
assert mock_title_search.call_count == 1
assert mock_title_search.call_args[0][0].GET.get("q") == q
assert mock_title_search.call_args[0][0].GET.get("path") == parent.path
assert response.json() == mocked_response
@responses.activate @responses.activate
def test_api_documents_search_invalid_params(indexer_settings): def test_api_documents_search_invalid_params(indexer_settings):
"""Validate the format of documents as returned by the search view.""" """Validate the format of documents as returned by the search view."""
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search" indexer_settings.SEARCH_URL = "http://find/api/v1.0/search"
assert get_document_indexer() is not None
user = factories.UserFactory() user = factories.UserFactory()
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(
user, backend="core.authentication.backends.OIDCAuthenticationBackend"
)
response = client.get("/api/v1.0/documents/search/") response = client.get("/api/v1.0/documents/search/")
assert response.status_code == 400 assert response.status_code == 400
assert response.json() == {"q": ["This field is required."]} 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 @responses.activate
def test_api_documents_search_format(indexer_settings): def test_api_documents_search_success(indexer_settings):
"""Validate the format of documents as returned by the search view.""" """Validate the format of documents as returned by the search view."""
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search" indexer_settings.SEARCH_URL = "http://find/api/v1.0/search"
assert get_document_indexer() is not None assert get_document_indexer() is not None
user = factories.UserFactory() document = {"id": "doc-123", "title": "alpha", "path": "path/to/alpha.pdf"}
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 # Find response
responses.add( responses.add(
responses.POST, responses.POST,
"http://find/api/v1.0/search", "http://find/api/v1.0/search",
json=[ json=[
{"_id": str(document.pk)}, {
"_id": str(document["id"]),
"_source": {"title": document["title"], "path": document["path"]},
},
], ],
status=200, status=200,
) )
response = client.get("/api/v1.0/documents/search/", data={"q": "alpha"}) response = APIClient().get("/api/v1.0/documents/search/", data={"q": "alpha"})
assert response.status_code == 200 assert response.status_code == 200
content = response.json() content = response.json()
@@ -177,249 +235,6 @@ def test_api_documents_search_format(indexer_settings):
"next": None, "next": None,
"previous": None, "previous": None,
} }
assert len(results) == 1 assert results == [
assert results[0] == { {"id": document["id"], "title": document["title"], "path": document["path"]}
"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"),
"deleted_at": None,
"user_role": access.role,
}
@responses.activate
@pytest.mark.parametrize(
"pagination, status, expected",
(
(
{"page": 1, "page_size": 10},
200,
{
"count": 10,
"previous": None,
"next": None,
"range": (0, None),
},
),
(
{},
200,
{
"count": 10,
"previous": None,
"next": None,
"range": (0, None),
"api_page_size": 21, # default page_size is 20
},
),
(
{"page": 2, "page_size": 10},
404,
{},
),
(
{"page": 1, "page_size": 5},
200,
{
"count": 10,
"previous": None,
"next": {"page": 2, "page_size": 5},
"range": (0, 5),
},
),
(
{"page": 2, "page_size": 5},
200,
{
"count": 10,
"previous": {"page_size": 5},
"next": None,
"range": (5, None),
},
),
({"page": 3, "page_size": 5}, 404, {}),
),
)
def test_api_documents_search_pagination(
indexer_settings, pagination, status, expected
):
"""Documents should be ordered by descending "score" 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, title="alpha", users=[user])
docs_by_uuid = {str(doc.pk): doc for doc in docs}
api_results = [{"_id": id} for id in docs_by_uuid.keys()]
# reorder randomly to simulate score ordering
random.shuffle(api_results)
# Find response
# pylint: disable-next=assignment-from-none
api_search = responses.add(
responses.POST,
"http://find/api/v1.0/search",
json=api_results,
status=200,
)
response = client.get(
"/api/v1.0/documents/search/",
data={
"q": "alpha",
**pagination,
},
)
assert response.status_code == status
if response.status_code < 300:
previous_url = (
build_search_url(q="alpha", **expected["previous"])
if expected["previous"]
else None
)
next_url = (
build_search_url(q="alpha", **expected["next"])
if expected["next"]
else None
)
start, end = expected["range"]
content = response.json()
assert content["count"] == expected["count"]
assert content["previous"] == previous_url
assert content["next"] == next_url
results = content.pop("results")
# The find api results ordering by score is kept
assert [r["id"] for r in results] == [r["_id"] for r in api_results[start:end]]
# 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"],
"nb_results": 50,
"order_by": "updated_at",
"order_direction": "desc",
}
@responses.activate
@pytest.mark.parametrize(
"pagination, status, expected",
(
(
{"page": 1, "page_size": 10},
200,
{"count": 10, "previous": None, "next": None, "range": (0, None)},
),
(
{},
200,
{"count": 10, "previous": None, "next": None, "range": (0, None)},
),
(
{"page": 2, "page_size": 10},
404,
{},
),
(
{"page": 1, "page_size": 5},
200,
{
"count": 10,
"previous": None,
"next": {"page": 2, "page_size": 5},
"range": (0, 5),
},
),
(
{"page": 2, "page_size": 5},
200,
{
"count": 10,
"previous": {"page_size": 5},
"next": None,
"range": (5, None),
},
),
({"page": 3, "page_size": 5}, 404, {}),
),
)
def test_api_documents_search_pagination_endpoint_is_none(
indexer_settings, pagination, status, expected
):
"""Documents should be ordered by descending "-updated_at" by default"""
indexer_settings.SEARCH_INDEXER_QUERY_URL = None
assert get_document_indexer() is None
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(10, title="alpha", users=[user])
response = client.get(
"/api/v1.0/documents/search/",
data={
"q": "alpha",
**pagination,
},
)
assert response.status_code == status
if response.status_code < 300:
previous_url = (
build_search_url(q="alpha", **expected["previous"])
if expected["previous"]
else None
)
next_url = (
build_search_url(q="alpha", **expected["next"])
if expected["next"]
else None
)
queryset = models.Document.objects.order_by("-updated_at")
start, end = expected["range"]
expected_results = [str(d.pk) for d in queryset[start:end]]
content = response.json()
assert content["count"] == expected["count"]
assert content["previous"] == previous_url
assert content["next"] == next_url
results = content.pop("results")
assert [r["id"] for r in results] == expected_results

View File

@@ -0,0 +1,956 @@
"""
Tests for search API endpoint in impress's core app when indexer is not
available and a path param is given.
"""
import random
from django.contrib.auth.models import AnonymousUser
import pytest
from rest_framework.test import APIClient
from core import factories
from core.api.filters import remove_accents
pytestmark = pytest.mark.django_db
@pytest.fixture(autouse=True)
def disable_indexer(indexer_settings):
"""Disable search indexer for all tests in this file."""
indexer_settings.SEARCH_INDEXER_CLASS = None
def test_api_documents_search_descendants_list_anonymous_public_standalone():
"""Anonymous users should be allowed to retrieve the descendants of a public document."""
document = factories.DocumentFactory(link_reach="public", title="doc parent")
child1, child2 = factories.DocumentFactory.create_batch(
2, parent=document, title="doc child"
)
grand_child = factories.DocumentFactory(parent=child1, title="doc grand child")
factories.UserDocumentAccessFactory(document=child1)
response = APIClient().get(
"/api/v1.0/documents/search/", data={"q": "doc", "path": document.path}
)
assert response.status_code == 200
assert response.json() == {
"count": 4,
"next": None,
"previous": None,
"results": [
{
# the search should include the parent document itself
"abilities": document.get_abilities(AnonymousUser()),
"ancestors_link_role": None,
"ancestors_link_reach": None,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"deleted_at": None,
"depth": 1,
"excerpt": document.excerpt,
"id": str(document.id),
"is_favorite": False,
"link_reach": document.link_reach,
"link_role": document.link_role,
"numchild": 2,
"nb_accesses_ancestors": 0,
"nb_accesses_direct": 0,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None,
},
{
"abilities": child1.get_abilities(AnonymousUser()),
"ancestors_link_reach": document.link_reach,
"ancestors_link_role": document.link_role,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 1,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 1,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None,
},
{
"abilities": grand_child.get_abilities(AnonymousUser()),
"ancestors_link_reach": document.link_reach,
"ancestors_link_role": document.link_role
if (child1.link_reach == "public" and child1.link_role == "editor")
else document.link_role,
"computed_link_reach": "public",
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id),
"deleted_at": None,
"depth": 3,
"excerpt": grand_child.excerpt,
"id": str(grand_child.id),
"is_favorite": False,
"link_reach": grand_child.link_reach,
"link_role": grand_child.link_role,
"numchild": 0,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 0,
"path": grand_child.path,
"title": grand_child.title,
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None,
},
{
"abilities": child2.get_abilities(AnonymousUser()),
"ancestors_link_reach": document.link_reach,
"ancestors_link_role": document.link_role,
"computed_link_reach": "public",
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses_ancestors": 0,
"nb_accesses_direct": 0,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None,
},
],
}
def test_api_documents_search_descendants_list_anonymous_public_parent():
"""
Anonymous users should be allowed to retrieve the descendants of a document who
has a public ancestor.
"""
grand_parent = factories.DocumentFactory(
link_reach="public", title="grand parent doc"
)
parent = factories.DocumentFactory(
parent=grand_parent,
link_reach=random.choice(["authenticated", "restricted"]),
title="parent doc",
)
document = factories.DocumentFactory(
link_reach=random.choice(["authenticated", "restricted"]),
parent=parent,
title="document",
)
child1, child2 = factories.DocumentFactory.create_batch(
2, parent=document, title="child doc"
)
grand_child = factories.DocumentFactory(parent=child1, title="grand child doc")
factories.UserDocumentAccessFactory(document=child1)
response = APIClient().get(
"/api/v1.0/documents/search/", data={"q": "doc", "path": document.path}
)
assert response.status_code == 200
assert response.json() == {
"count": 4,
"next": None,
"previous": None,
"results": [
{
# the search should include the parent document itself
"abilities": document.get_abilities(AnonymousUser()),
"ancestors_link_reach": "public",
"ancestors_link_role": grand_parent.link_role,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"deleted_at": None,
"depth": 3,
"excerpt": document.excerpt,
"id": str(document.id),
"is_favorite": False,
"link_reach": document.link_reach,
"link_role": document.link_role,
"numchild": 2,
"nb_accesses_ancestors": 0,
"nb_accesses_direct": 0,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None,
},
{
"abilities": child1.get_abilities(AnonymousUser()),
"ancestors_link_reach": "public",
"ancestors_link_role": grand_parent.link_role,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"deleted_at": None,
"depth": 4,
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 1,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 1,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None,
},
{
"abilities": grand_child.get_abilities(AnonymousUser()),
"ancestors_link_reach": "public",
"ancestors_link_role": grand_child.ancestors_link_role,
"computed_link_reach": "public",
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id),
"deleted_at": None,
"depth": 5,
"excerpt": grand_child.excerpt,
"id": str(grand_child.id),
"is_favorite": False,
"link_reach": grand_child.link_reach,
"link_role": grand_child.link_role,
"numchild": 0,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 0,
"path": grand_child.path,
"title": grand_child.title,
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None,
},
{
"abilities": child2.get_abilities(AnonymousUser()),
"ancestors_link_reach": "public",
"ancestors_link_role": grand_parent.link_role,
"computed_link_reach": "public",
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"deleted_at": None,
"depth": 4,
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses_ancestors": 0,
"nb_accesses_direct": 0,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None,
},
],
}
@pytest.mark.parametrize("reach", ["restricted", "authenticated"])
def test_api_documents_search_descendants_list_anonymous_restricted_or_authenticated(
reach,
):
"""
Anonymous users should not be able to retrieve descendants of a document that is not public.
"""
document = factories.DocumentFactory(title="parent", link_reach=reach)
child = factories.DocumentFactory(title="child", parent=document)
_grand_child = factories.DocumentFactory(title="grand child", parent=child)
response = APIClient().get(
"/api/v1.0/documents/search/", data={"q": "child", "path": document.path}
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to search within this document."
}
@pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_search_descendants_list_authenticated_unrelated_public_or_authenticated(
reach,
):
"""
Authenticated users should be able to retrieve the descendants of a public/authenticated
document to which they are not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach, title="parent")
child1, child2 = factories.DocumentFactory.create_batch(
2, parent=document, link_reach="restricted", title="child"
)
grand_child = factories.DocumentFactory(parent=child1, title="grand child")
factories.UserDocumentAccessFactory(document=child1)
response = client.get(
"/api/v1.0/documents/search/", data={"q": "child", "path": document.path}
)
assert response.status_code == 200
assert response.json() == {
"count": 3,
"next": None,
"previous": None,
"results": [
{
"abilities": child1.get_abilities(user),
"ancestors_link_reach": reach,
"ancestors_link_role": document.link_role,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 1,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 1,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None,
},
{
"abilities": grand_child.get_abilities(user),
"ancestors_link_reach": reach,
"ancestors_link_role": document.link_role,
"computed_link_reach": grand_child.computed_link_reach,
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id),
"deleted_at": None,
"depth": 3,
"excerpt": grand_child.excerpt,
"id": str(grand_child.id),
"is_favorite": False,
"link_reach": grand_child.link_reach,
"link_role": grand_child.link_role,
"numchild": 0,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 0,
"path": grand_child.path,
"title": grand_child.title,
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None,
},
{
"abilities": child2.get_abilities(user),
"ancestors_link_reach": reach,
"ancestors_link_role": document.link_role,
"computed_link_reach": child2.computed_link_reach,
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses_ancestors": 0,
"nb_accesses_direct": 0,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None,
},
],
}
@pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_search_descendants_list_authenticated_public_or_authenticated_parent(
reach,
):
"""
Authenticated users should be allowed to retrieve the descendants of a document who
has a public or authenticated ancestor.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
grand_parent = factories.DocumentFactory(link_reach=reach, title="grand parent")
parent = factories.DocumentFactory(
parent=grand_parent, link_reach="restricted", title="parent"
)
document = factories.DocumentFactory(
link_reach="restricted", parent=parent, title="document"
)
child1, child2 = factories.DocumentFactory.create_batch(
2, parent=document, link_reach="restricted", title="child"
)
grand_child = factories.DocumentFactory(parent=child1, title="grand child")
factories.UserDocumentAccessFactory(document=child1)
response = client.get(
"/api/v1.0/documents/search/", data={"q": "child", "path": document.path}
)
assert response.status_code == 200
assert response.json() == {
"count": 3,
"next": None,
"previous": None,
"results": [
{
"abilities": child1.get_abilities(user),
"ancestors_link_reach": reach,
"ancestors_link_role": grand_parent.link_role,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"deleted_at": None,
"depth": 4,
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 1,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 1,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None,
},
{
"abilities": grand_child.get_abilities(user),
"ancestors_link_reach": reach,
"ancestors_link_role": grand_parent.link_role,
"computed_link_reach": grand_child.computed_link_reach,
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id),
"deleted_at": None,
"depth": 5,
"excerpt": grand_child.excerpt,
"id": str(grand_child.id),
"is_favorite": False,
"link_reach": grand_child.link_reach,
"link_role": grand_child.link_role,
"numchild": 0,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 0,
"path": grand_child.path,
"title": grand_child.title,
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None,
},
{
"abilities": child2.get_abilities(user),
"ancestors_link_reach": reach,
"ancestors_link_role": grand_parent.link_role,
"computed_link_reach": child2.computed_link_reach,
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"deleted_at": None,
"depth": 4,
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses_ancestors": 0,
"nb_accesses_direct": 0,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": None,
},
],
}
def test_api_documents_search_descendants_list_authenticated_unrelated_restricted():
"""
Authenticated users should not be allowed to retrieve the descendants of a document that is
restricted and to which they are not related.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted", title="parent")
child1, _child2 = factories.DocumentFactory.create_batch(
2, parent=document, title="child"
)
_grand_child = factories.DocumentFactory(parent=child1, title="grand child")
factories.UserDocumentAccessFactory(document=child1)
response = client.get(
"/api/v1.0/documents/search/", data={"q": "child", "path": document.path}
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to search within this document."
}
def test_api_documents_search_descendants_list_authenticated_related_direct():
"""
Authenticated users should be allowed to retrieve the descendants of a document
to which they are directly related whatever the role.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(title="parent")
access = factories.UserDocumentAccessFactory(document=document, user=user)
factories.UserDocumentAccessFactory(document=document)
child1, child2 = factories.DocumentFactory.create_batch(
2, parent=document, title="child"
)
factories.UserDocumentAccessFactory(document=child1)
grand_child = factories.DocumentFactory(parent=child1, title="grand child")
response = client.get(
"/api/v1.0/documents/search/", data={"q": "child", "path": document.path}
)
assert response.status_code == 200
assert response.json() == {
"count": 3,
"next": None,
"previous": None,
"results": [
{
"abilities": child1.get_abilities(user),
"ancestors_link_reach": child1.ancestors_link_reach,
"ancestors_link_role": child1.ancestors_link_role,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 1,
"nb_accesses_ancestors": 3,
"nb_accesses_direct": 1,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": access.role,
},
{
"abilities": grand_child.get_abilities(user),
"ancestors_link_reach": grand_child.ancestors_link_reach,
"ancestors_link_role": grand_child.ancestors_link_role,
"computed_link_reach": grand_child.computed_link_reach,
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id),
"deleted_at": None,
"depth": 3,
"excerpt": grand_child.excerpt,
"id": str(grand_child.id),
"is_favorite": False,
"link_reach": grand_child.link_reach,
"link_role": grand_child.link_role,
"numchild": 0,
"nb_accesses_ancestors": 3,
"nb_accesses_direct": 0,
"path": grand_child.path,
"title": grand_child.title,
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": access.role,
},
{
"abilities": child2.get_abilities(user),
"ancestors_link_reach": child2.ancestors_link_reach,
"ancestors_link_role": child2.ancestors_link_role,
"computed_link_reach": child2.computed_link_reach,
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses_ancestors": 2,
"nb_accesses_direct": 0,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": access.role,
},
],
}
def test_api_documents_search_descendants_list_authenticated_related_parent():
"""
Authenticated users should be allowed to retrieve the descendants of a document if they
are related to one of its ancestors whatever the role.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
grand_parent = factories.DocumentFactory(link_reach="restricted", title="parent")
grand_parent_access = factories.UserDocumentAccessFactory(
document=grand_parent, user=user
)
parent = factories.DocumentFactory(
parent=grand_parent, link_reach="restricted", title="parent"
)
document = factories.DocumentFactory(
parent=parent, link_reach="restricted", title="document"
)
child1, child2 = factories.DocumentFactory.create_batch(
2, parent=document, title="child"
)
factories.UserDocumentAccessFactory(document=child1)
grand_child = factories.DocumentFactory(parent=child1, title="grand child")
response = client.get(
"/api/v1.0/documents/search/", data={"q": "child", "path": document.path}
)
assert response.status_code == 200
assert response.json() == {
"count": 3,
"next": None,
"previous": None,
"results": [
{
"abilities": child1.get_abilities(user),
"ancestors_link_reach": child1.ancestors_link_reach,
"ancestors_link_role": child1.ancestors_link_role,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"deleted_at": None,
"depth": 4,
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 1,
"nb_accesses_ancestors": 2,
"nb_accesses_direct": 1,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": grand_parent_access.role,
},
{
"abilities": grand_child.get_abilities(user),
"ancestors_link_reach": grand_child.ancestors_link_reach,
"ancestors_link_role": grand_child.ancestors_link_role,
"computed_link_reach": grand_child.computed_link_reach,
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id),
"deleted_at": None,
"depth": 5,
"excerpt": grand_child.excerpt,
"id": str(grand_child.id),
"is_favorite": False,
"link_reach": grand_child.link_reach,
"link_role": grand_child.link_role,
"numchild": 0,
"nb_accesses_ancestors": 2,
"nb_accesses_direct": 0,
"path": grand_child.path,
"title": grand_child.title,
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": grand_parent_access.role,
},
{
"abilities": child2.get_abilities(user),
"ancestors_link_reach": child2.ancestors_link_reach,
"ancestors_link_role": child2.ancestors_link_role,
"computed_link_reach": child2.computed_link_reach,
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"deleted_at": None,
"depth": 4,
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 0,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": grand_parent_access.role,
},
],
}
def test_api_documents_search_descendants_list_authenticated_related_child():
"""
Authenticated users should not be allowed to retrieve all the descendants of a document
as a result of being related to one of its children.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted")
child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document)
_grand_child = factories.DocumentFactory(parent=child1)
factories.UserDocumentAccessFactory(document=child1, user=user)
factories.UserDocumentAccessFactory(document=document)
response = client.get(
"/api/v1.0/documents/search/", data={"q": "doc", "path": document.path}
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to search within this document."
}
def test_api_documents_search_descendants_list_authenticated_related_team_none(
mock_user_teams,
):
"""
Authenticated users should not be able to retrieve the descendants of a restricted document
related to teams in which the user is not.
"""
mock_user_teams.return_value = []
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted", title="document")
factories.DocumentFactory.create_batch(2, parent=document, title="child")
factories.TeamDocumentAccessFactory(document=document, team="myteam")
response = client.get(
"/api/v1.0/documents/search/", data={"q": "doc", "path": document.path}
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to search within this document."
}
def test_api_documents_search_descendants_list_authenticated_related_team_members(
mock_user_teams,
):
"""
Authenticated users should be allowed to retrieve the descendants of a document to which they
are related via a team whatever the role.
"""
mock_user_teams.return_value = ["myteam"]
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted", title="parent")
child1, child2 = factories.DocumentFactory.create_batch(
2, parent=document, title="child"
)
grand_child = factories.DocumentFactory(parent=child1, title="grand child")
access = factories.TeamDocumentAccessFactory(document=document, team="myteam")
response = client.get(
"/api/v1.0/documents/search/", data={"q": "child", "path": document.path}
)
# pylint: disable=R0801
assert response.status_code == 200
assert response.json() == {
"count": 3,
"next": None,
"previous": None,
"results": [
{
"abilities": child1.get_abilities(user),
"ancestors_link_reach": child1.ancestors_link_reach,
"ancestors_link_role": child1.ancestors_link_role,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child1.excerpt,
"id": str(child1.id),
"is_favorite": False,
"link_reach": child1.link_reach,
"link_role": child1.link_role,
"numchild": 1,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 0,
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": access.role,
},
{
"abilities": grand_child.get_abilities(user),
"ancestors_link_reach": grand_child.ancestors_link_reach,
"ancestors_link_role": grand_child.ancestors_link_role,
"computed_link_reach": grand_child.computed_link_reach,
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id),
"deleted_at": None,
"depth": 3,
"excerpt": grand_child.excerpt,
"id": str(grand_child.id),
"is_favorite": False,
"link_reach": grand_child.link_reach,
"link_role": grand_child.link_role,
"numchild": 0,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 0,
"path": grand_child.path,
"title": grand_child.title,
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": access.role,
},
{
"abilities": child2.get_abilities(user),
"ancestors_link_reach": child2.ancestors_link_reach,
"ancestors_link_role": child2.ancestors_link_role,
"computed_link_reach": child2.computed_link_reach,
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child2.excerpt,
"id": str(child2.id),
"is_favorite": False,
"link_reach": child2.link_reach,
"link_role": child2.link_role,
"numchild": 0,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 0,
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_role": access.role,
},
],
}
@pytest.mark.parametrize(
"query,nb_results",
[
("", 7), # Empty string
("Project Alpha", 1), # Exact match
("project", 2), # Partial match (case-insensitive)
("Guide", 2), # Word match within a title
("Special", 0), # No match (nonexistent keyword)
("2024", 2), # Match by numeric keyword
("velo", 1), # Accent-insensitive match (velo vs vélo)
("bêta", 1), # Accent-insensitive match (bêta vs beta)
],
)
def test_api_documents_search_descendants_search_on_title(query, nb_results):
"""Authenticated users should be able to search documents by their unaccented title."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
parent = factories.DocumentFactory(users=[user])
# Create documents with predefined titles
titles = [
"Project Alpha Documentation",
"Project Beta Overview",
"User Guide",
"Financial Report 2024",
"Annual Review 2024",
"Guide du vélo urbain", # <-- Title with accent for accent-insensitive test
]
for title in titles:
factories.DocumentFactory(title=title, parent=parent)
# Perform the search query
response = client.get(
"/api/v1.0/documents/search/", data={"q": query, "path": parent.path}
)
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == nb_results
# Ensure all results contain the query in their title
for result in results:
assert (
remove_accents(query).lower().strip()
in remove_accents(result["title"]).lower()
)

View File

@@ -0,0 +1,90 @@
"""
Tests for Find search feature flags
"""
from unittest import mock
from django.http import HttpResponse
import pytest
import responses
from rest_framework.test import APIClient
from waffle.testutils import override_flag
from core.enums import FeatureFlag, SearchType
from core.services.search_indexers import get_document_indexer
pytestmark = pytest.mark.django_db
@responses.activate
@mock.patch("core.api.viewsets.DocumentViewSet._title_search")
@mock.patch("core.api.viewsets.DocumentViewSet._search_with_indexer")
@pytest.mark.parametrize(
"activated_flags,"
"expected_search_type,"
"expected_search_with_indexer_called,"
"expected_title_search_called",
[
([], SearchType.TITLE, False, True),
([FeatureFlag.FLAG_FIND_HYBRID_SEARCH], SearchType.HYBRID, True, False),
(
[
FeatureFlag.FLAG_FIND_HYBRID_SEARCH,
FeatureFlag.FLAG_FIND_FULL_TEXT_SEARCH,
],
SearchType.HYBRID,
True,
False,
),
([FeatureFlag.FLAG_FIND_FULL_TEXT_SEARCH], SearchType.FULL_TEXT, True, False),
],
)
# pylint: disable=too-many-arguments, too-many-positional-arguments
def test_api_documents_search_success( # noqa : PLR0913
mock_search_with_indexer,
mock_title_search,
activated_flags,
expected_search_type,
expected_search_with_indexer_called,
expected_title_search_called,
indexer_settings,
):
"""
Test that the API endpoint for searching documents returns a successful response
with the expected search type according to the activated feature flags,
and that the appropriate search method is called.
"""
assert get_document_indexer() is not None
mock_search_with_indexer.return_value = HttpResponse()
mock_title_search.return_value = HttpResponse()
with override_flag(
FeatureFlag.FLAG_FIND_HYBRID_SEARCH,
active=FeatureFlag.FLAG_FIND_HYBRID_SEARCH in activated_flags,
):
with override_flag(
FeatureFlag.FLAG_FIND_FULL_TEXT_SEARCH,
active=FeatureFlag.FLAG_FIND_FULL_TEXT_SEARCH in activated_flags,
):
response = APIClient().get(
"/api/v1.0/documents/search/", data={"q": "alpha"}
)
assert response.status_code == 200
if expected_search_with_indexer_called:
mock_search_with_indexer.assert_called_once()
assert (
mock_search_with_indexer.call_args.kwargs["search_type"]
== expected_search_type
)
else:
assert not mock_search_with_indexer.called
if expected_title_search_called:
assert SearchType.TITLE == expected_search_type
mock_title_search.assert_called_once()
else:
assert not mock_title_search.called

View File

@@ -101,6 +101,7 @@ def test_api_documents_trashbin_format():
"partial_update": False, "partial_update": False,
"restore": True, "restore": True,
"retrieve": True, "retrieve": True,
"search": False,
"tree": True, "tree": True,
"update": False, "update": False,
"versions_destroy": False, "versions_destroy": False,

View File

@@ -1,8 +1,10 @@
""" """
Tests for Documents API endpoint in impress's core app: update Tests for Documents API endpoint in impress's core app: update
""" """
# pylint: disable=too-many-lines
import random import random
from unittest.mock import patch
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.core.cache import cache from django.core.cache import cache
@@ -17,6 +19,25 @@ from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
# A valid Yjs document derived from YDOC_HELLO_WORLD_BASE64 with "Hello" replaced by "World",
# used in PATCH tests to guarantee a real content change distinct from what DocumentFactory
# produces.
YDOC_UPDATED_CONTENT_BASE64 = (
"AR717vLVDgAHAQ5kb2N1bWVudC1zdG9yZQMKYmxvY2tHcm91cAcA9e7y1Q4AAw5ibG9ja0NvbnRh"
"aW5lcgcA9e7y1Q4BAwdoZWFkaW5nBwD17vLVDgIGBgD17vLVDgMGaXRhbGljAnt9hPXu8tUOBAVX"
"b3JsZIb17vLVDgkGaXRhbGljBG51bGwoAPXu8tUOAg10ZXh0QWxpZ25tZW50AXcEbGVmdCgA9e7y"
"1Q4CBWxldmVsAX0BKAD17vLVDgECaWQBdyQwNGQ2MjM0MS04MzI2LTQyMzYtYTA4My00ODdlMjZm"
"YWQyMzAoAPXu8tUOAQl0ZXh0Q29sb3IBdwdkZWZhdWx0KAD17vLVDgEPYmFja2dyb3VuZENvbG9y"
"AXcHZGVmYXVsdIf17vLVDgEDDmJsb2NrQ29udGFpbmVyBwD17vLVDhADDmJ1bGxldExpc3RJdGVt"
"BwD17vLVDhEGBAD17vLVDhIBd4b17vLVDhMEYm9sZAJ7fYT17vLVDhQCb3KG9e7y1Q4WBGJvbGQE"
"bnVsbIT17vLVDhcCbGQoAPXu8tUOEQ10ZXh0QWxpZ25tZW50AXcEbGVmdCgA9e7y1Q4QAmlkAXck"
"ZDM1MWUwNjgtM2U1NS00MjI2LThlYTUtYWJiMjYzMTk4ZTJhKAD17vLVDhAJdGV4dENvbG9yAXcH"
"ZGVmYXVsdCgA9e7y1Q4QD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHSH9e7y1Q4QAw5ibG9ja0Nv"
"bnRhaW5lcgcA9e7y1Q4eAwlwYXJhZ3JhcGgoAPXu8tUOHw10ZXh0QWxpZ25tZW50AXcEbGVmdCgA"
"9e7y1Q4eAmlkAXckODk3MDBjMDctZTBlMS00ZmUwLWFjYTItODQ5MzIwOWE3ZTQyKAD17vLVDh4J"
"dGV4dENvbG9yAXcHZGVmYXVsdCgA9e7y1Q4eD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQA"
)
@pytest.mark.parametrize("via_parent", [True, False]) @pytest.mark.parametrize("via_parent", [True, False])
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -330,6 +351,7 @@ def test_api_documents_update_authenticated_no_websocket(settings):
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False}) ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
assert cache.get(f"docs:no-websocket:{document.id}") is None assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
response = client.put( response = client.put(
f"/api/v1.0/documents/{document.id!s}/", f"/api/v1.0/documents/{document.id!s}/",
@@ -338,6 +360,8 @@ def test_api_documents_update_authenticated_no_websocket(settings):
) )
assert response.status_code == 200 assert response.status_code == 200
document.refresh_from_db()
assert document.path == old_path
assert cache.get(f"docs:no-websocket:{document.id}") == session_key assert cache.get(f"docs:no-websocket:{document.id}") == session_key
assert ws_resp.call_count == 1 assert ws_resp.call_count == 1
@@ -446,6 +470,7 @@ def test_api_documents_update_user_connected_to_websocket(settings):
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": True}) ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": True})
assert cache.get(f"docs:no-websocket:{document.id}") is None assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
response = client.put( response = client.put(
f"/api/v1.0/documents/{document.id!s}/", f"/api/v1.0/documents/{document.id!s}/",
@@ -453,6 +478,9 @@ def test_api_documents_update_user_connected_to_websocket(settings):
format="json", format="json",
) )
assert response.status_code == 200 assert response.status_code == 200
document.refresh_from_db()
assert document.path == old_path
assert cache.get(f"docs:no-websocket:{document.id}") is None assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 1 assert ws_resp.call_count == 1
@@ -486,6 +514,7 @@ def test_api_documents_update_websocket_server_unreachable_fallback_to_no_websoc
ws_resp = responses.get(endpoint_url, status=500) ws_resp = responses.get(endpoint_url, status=500)
assert cache.get(f"docs:no-websocket:{document.id}") is None assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
response = client.put( response = client.put(
f"/api/v1.0/documents/{document.id!s}/", f"/api/v1.0/documents/{document.id!s}/",
@@ -494,6 +523,8 @@ def test_api_documents_update_websocket_server_unreachable_fallback_to_no_websoc
) )
assert response.status_code == 200 assert response.status_code == 200
document.refresh_from_db()
assert document.path == old_path
assert cache.get(f"docs:no-websocket:{document.id}") == session_key assert cache.get(f"docs:no-websocket:{document.id}") == session_key
assert ws_resp.call_count == 1 assert ws_resp.call_count == 1
@@ -605,6 +636,7 @@ def test_api_documents_update_force_websocket_param_to_true(settings):
ws_resp = responses.get(endpoint_url, status=500) ws_resp = responses.get(endpoint_url, status=500)
assert cache.get(f"docs:no-websocket:{document.id}") is None assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
response = client.put( response = client.put(
f"/api/v1.0/documents/{document.id!s}/", f"/api/v1.0/documents/{document.id!s}/",
@@ -613,6 +645,8 @@ def test_api_documents_update_force_websocket_param_to_true(settings):
) )
assert response.status_code == 200 assert response.status_code == 200
document.refresh_from_db()
assert document.path == old_path
assert cache.get(f"docs:no-websocket:{document.id}") is None assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 0 assert ws_resp.call_count == 0
@@ -643,6 +677,7 @@ def test_api_documents_update_feature_flag_disabled(settings):
ws_resp = responses.get(endpoint_url, status=500) ws_resp = responses.get(endpoint_url, status=500)
assert cache.get(f"docs:no-websocket:{document.id}") is None assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
response = client.put( response = client.put(
f"/api/v1.0/documents/{document.id!s}/", f"/api/v1.0/documents/{document.id!s}/",
@@ -651,6 +686,8 @@ def test_api_documents_update_feature_flag_disabled(settings):
) )
assert response.status_code == 200 assert response.status_code == 200
document.refresh_from_db()
assert document.path == old_path
assert cache.get(f"docs:no-websocket:{document.id}") is None assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 0 assert ws_resp.call_count == 0
@@ -716,3 +753,724 @@ def test_api_documents_update_invalid_content():
) )
assert response.status_code == 400 assert response.status_code == 400
assert response.json() == {"content": ["Invalid base64 content."]} assert response.json() == {"content": ["Invalid base64 content."]}
# =============================================================================
# PATCH tests
# =============================================================================
@pytest.mark.parametrize("via_parent", [True, False])
@pytest.mark.parametrize(
"reach, role",
[
("restricted", "reader"),
("restricted", "editor"),
("authenticated", "reader"),
("authenticated", "editor"),
("public", "reader"),
],
)
def test_api_documents_patch_anonymous_forbidden(reach, role, via_parent):
"""
Anonymous users should not be allowed to patch a document when link
configuration does not allow it.
"""
if via_parent:
grand_parent = factories.DocumentFactory(link_reach=reach, link_role=role)
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
else:
document = factories.DocumentFactory(link_reach=reach, link_role=role)
old_document_values = serializers.DocumentSerializer(instance=document).data
new_content = YDOC_UPDATED_CONTENT_BASE64
response = APIClient().patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": new_content},
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
document.refresh_from_db()
assert serializers.DocumentSerializer(instance=document).data == old_document_values
@pytest.mark.parametrize("via_parent", [True, False])
@pytest.mark.parametrize(
"reach,role",
[
("public", "reader"),
("authenticated", "reader"),
("restricted", "reader"),
("restricted", "editor"),
],
)
def test_api_documents_patch_authenticated_unrelated_forbidden(reach, role, via_parent):
"""
Authenticated users should not be allowed to patch a document to which
they are not related if the link configuration does not allow it.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
if via_parent:
grand_parent = factories.DocumentFactory(link_reach=reach, link_role=role)
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
else:
document = factories.DocumentFactory(link_reach=reach, link_role=role)
old_document_values = serializers.DocumentSerializer(instance=document).data
new_content = YDOC_UPDATED_CONTENT_BASE64
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": new_content},
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
document.refresh_from_db()
assert serializers.DocumentSerializer(instance=document).data == old_document_values
@pytest.mark.parametrize("via_parent", [True, False])
@pytest.mark.parametrize(
"is_authenticated,reach,role",
[
(False, "public", "editor"),
(True, "public", "editor"),
(True, "authenticated", "editor"),
],
)
def test_api_documents_patch_anonymous_or_authenticated_unrelated(
is_authenticated, reach, role, via_parent
):
"""
Anonymous and authenticated users should be able to patch a document to which
they are not related if the link configuration allows it.
"""
client = APIClient()
if is_authenticated:
user = factories.UserFactory(with_owned_document=True)
client.force_login(user)
if via_parent:
grand_parent = factories.DocumentFactory(link_reach=reach, link_role=role)
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
else:
document = factories.DocumentFactory(link_reach=reach, link_role=role)
old_document_values = serializers.DocumentSerializer(instance=document).data
old_path = document.path
new_content = YDOC_UPDATED_CONTENT_BASE64
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": new_content, "websocket": True},
format="json",
)
assert response.status_code == 200
# Using document.refresh_from_db does not wirk because the content is in cache.
# Force reloading it by fetching the document in the database.
document = models.Document.objects.get(id=document.id)
assert document.path == old_path
assert document.content == new_content
document_values = serializers.DocumentSerializer(instance=document).data
for key in [
"id",
"title",
"link_reach",
"link_role",
"creator",
"depth",
"numchild",
"path",
]:
assert document_values[key] == old_document_values[key]
@pytest.mark.parametrize("via_parent", [True, False])
@pytest.mark.parametrize("via", VIA)
def test_api_documents_patch_authenticated_reader(via, via_parent, mock_user_teams):
"""Users who are reader of a document should not be allowed to patch it."""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
if via_parent:
grand_parent = factories.DocumentFactory(link_reach="restricted")
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
access_document = grand_parent
else:
document = factories.DocumentFactory(link_reach="restricted")
access_document = document
if via == USER:
factories.UserDocumentAccessFactory(
document=access_document, user=user, role="reader"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=access_document, team="lasuite", role="reader"
)
old_document_values = serializers.DocumentSerializer(instance=document).data
new_content = YDOC_UPDATED_CONTENT_BASE64
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": new_content},
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
document.refresh_from_db()
assert serializers.DocumentSerializer(instance=document).data == old_document_values
@pytest.mark.parametrize("via_parent", [True, False])
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
@pytest.mark.parametrize("via", VIA)
def test_api_documents_patch_authenticated_editor_administrator_or_owner(
via, role, via_parent, mock_user_teams
):
"""A user who is editor, administrator or owner of a document should be allowed to patch it."""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
if via_parent:
grand_parent = factories.DocumentFactory(link_reach="restricted")
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
access_document = grand_parent
else:
document = factories.DocumentFactory(link_reach="restricted")
access_document = document
if via == USER:
factories.UserDocumentAccessFactory(
document=access_document, user=user, role=role
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=access_document, team="lasuite", role=role
)
old_document_values = serializers.DocumentSerializer(instance=document).data
old_path = document.path
new_content = YDOC_UPDATED_CONTENT_BASE64
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": new_content, "websocket": True},
format="json",
)
assert response.status_code == 200
# Using document.refresh_from_db does not wirk because the content is in cache.
# Force reloading it by fetching the document in the database.
document = models.Document.objects.get(id=document.id)
assert document.path == old_path
assert document.content == new_content
document_values = serializers.DocumentSerializer(instance=document).data
for key in [
"id",
"title",
"link_reach",
"link_role",
"creator",
"depth",
"numchild",
"path",
"nb_accesses_ancestors",
"nb_accesses_direct",
]:
assert document_values[key] == old_document_values[key]
@responses.activate
def test_api_documents_patch_authenticated_no_websocket(settings):
"""
When a user patches the document, not connected to the websocket and is the first to update,
the document should be updated.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_content = YDOC_UPDATED_CONTENT_BASE64
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": new_content},
format="json",
)
assert response.status_code == 200
# Using document.refresh_from_db does not work because the content is cached.
# Force reloading it by fetching the document from the database.
document = models.Document.objects.get(id=document.id)
assert document.path == old_path
assert document.content == new_content
assert cache.get(f"docs:no-websocket:{document.id}") == session_key
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_patch_authenticated_no_websocket_user_already_editing(settings):
"""
When a user patches the document, not connected to the websocket and is not the first to
update, the document should not be updated.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_content = YDOC_UPDATED_CONTENT_BASE64
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": new_content},
format="json",
)
assert response.status_code == 403
assert response.json() == {"detail": "You are not allowed to edit this document."}
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_patch_no_websocket_other_user_connected_to_websocket(settings):
"""
When a user patches the document, not connected to the websocket and another user is connected
to the websocket, the document should not be updated.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_content = YDOC_UPDATED_CONTENT_BASE64
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": False})
assert cache.get(f"docs:no-websocket:{document.id}") is None
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": new_content},
format="json",
)
assert response.status_code == 403
assert response.json() == {"detail": "You are not allowed to edit this document."}
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_patch_user_connected_to_websocket(settings):
"""
When a user patches the document while connected to the websocket, the document should be
updated.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_content = YDOC_UPDATED_CONTENT_BASE64
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": True})
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": new_content},
format="json",
)
assert response.status_code == 200
# Using document.refresh_from_db does not wirk because the content is in cache.
# Force reloading it by fetching the document in the database.
document = models.Document.objects.get(id=document.id)
assert document.path == old_path
assert document.content == new_content
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_patch_websocket_server_unreachable_fallback_to_no_websocket(
settings,
):
"""
When the websocket server is unreachable, the patch should be applied like if the user was
not connected to the websocket.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_content = YDOC_UPDATED_CONTENT_BASE64
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": new_content},
format="json",
)
assert response.status_code == 200
# Using document.refresh_from_db does not work because the content is cached.
# Force reloading it by fetching the document from the database.
document = models.Document.objects.get(id=document.id)
assert document.path == old_path
assert document.content == new_content
assert cache.get(f"docs:no-websocket:{document.id}") == session_key
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_patch_websocket_server_unreachable_fallback_to_no_websocket_other_users(
settings,
):
"""
When the websocket server is unreachable, the behavior falls back to no-websocket.
If another user is already editing, the patch must be denied.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_content = YDOC_UPDATED_CONTENT_BASE64
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": new_content},
format="json",
)
assert response.status_code == 403
assert cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_patch_websocket_server_room_not_found_fallback_to_no_websocket_other_users(
settings,
):
"""
When the WebSocket server does not have the room created, the logic should fallback to
no-WebSocket. If another user is already editing, the patch must be denied.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_content = YDOC_UPDATED_CONTENT_BASE64
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=404)
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": new_content},
format="json",
)
assert response.status_code == 403
assert cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_patch_force_websocket_param_to_true(settings):
"""
When the websocket parameter is set to true, the patch should be applied without any check.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_content = YDOC_UPDATED_CONTENT_BASE64
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": new_content, "websocket": True},
format="json",
)
assert response.status_code == 200
# Using document.refresh_from_db does not work because the content is cached.
# Force reloading it by fetching the document from the database.
document = models.Document.objects.get(id=document.id)
assert document.path == old_path
assert document.content == new_content
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 0
@responses.activate
def test_api_documents_patch_feature_flag_disabled(settings):
"""
When the feature flag is disabled, the patch should be applied without any check.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_content = YDOC_UPDATED_CONTENT_BASE64
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = False
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": new_content},
format="json",
)
assert response.status_code == 200
# Using document.refresh_from_db does not work because the content is cached.
# Force reloading it by fetching the document from the database.
document = models.Document.objects.get(id=document.id)
assert document.path == old_path
assert document.content == new_content
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 0
@pytest.mark.parametrize("via", VIA)
def test_api_documents_patch_administrator_or_owner_of_another(via, mock_user_teams):
"""
Being administrator or owner of a document should not grant authorization to patch
another document.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(
document=document, user=user, role=random.choice(["administrator", "owner"])
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document,
team="lasuite",
role=random.choice(["administrator", "owner"]),
)
other_document = factories.DocumentFactory(title="Old title", link_role="reader")
old_document_values = serializers.DocumentSerializer(instance=other_document).data
new_content = YDOC_UPDATED_CONTENT_BASE64
response = client.patch(
f"/api/v1.0/documents/{other_document.id!s}/",
{"content": new_content},
format="json",
)
assert response.status_code == 403
other_document.refresh_from_db()
assert (
serializers.DocumentSerializer(instance=other_document).data
== old_document_values
)
def test_api_documents_patch_invalid_content():
"""
Patching a document with a non base64 encoded content should raise a validation error.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[[user, "owner"]])
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": "invalid content"},
format="json",
)
assert response.status_code == 400
assert response.json() == {"content": ["Invalid base64 content."]}
@responses.activate
def test_api_documents_patch_empty_body(settings):
"""
Test when data is empty the document should not be updated.
The `updated_at` property should not change asserting that no update in the database is made.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "owner")], creator=user)
document_updated_at = document.updated_at
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": True})
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_document_values = serializers.DocumentSerializer(instance=document).data
with patch("core.models.Document.save") as mock_document_save:
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
content_type="application/json",
)
mock_document_save.assert_not_called()
assert response.status_code == 200
document = models.Document.objects.get(id=document.id)
new_document_values = serializers.DocumentSerializer(instance=document).data
assert new_document_values == old_document_values
assert document_updated_at == document.updated_at
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 1

View File

@@ -0,0 +1,772 @@
"""
Tests for the Resource Server API for documents.
Not testing external API endpoints that are already tested in the /api
because the resource server viewsets inherit from the api viewsets.
"""
from datetime import timedelta
from io import BytesIO
from unittest.mock import patch
from django.test import override_settings
from django.utils import timezone
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.services import mime_types
pytestmark = pytest.mark.django_db
# pylint: disable=unused-argument
def test_external_api_documents_retrieve_anonymous_public_standalone():
"""
Anonymous users SHOULD NOT be allowed to retrieve a document from external
API if resource server is not enabled.
"""
document = factories.DocumentFactory(link_reach="public")
response = APIClient().get(f"/external_api/v1.0/documents/{document.id!s}/")
assert response.status_code == 404
def test_external_api_documents_list_connected_not_resource_server():
"""
Connected users SHOULD NOT be allowed to list documents if resource server is not enabled.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
factories.UserDocumentAccessFactory(document=document, user=user, role="reader")
response = client.get("/external_api/v1.0/documents/")
assert response.status_code == 404
def test_external_api_documents_list_connected_resource_server(
user_token, resource_server_backend, user_specific_sub
):
"""Connected users should be allowed to list documents from a resource server."""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role="reader"
)
response = client.get("/external_api/v1.0/documents/")
assert response.status_code == 200
def test_external_api_documents_list_connected_resource_server_with_invalid_token(
user_token, resource_server_backend
):
"""A user with an invalid sub SHOULD NOT be allowed to retrieve documents
from a resource server."""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
response = client.get("/external_api/v1.0/documents/")
assert response.status_code == 401
def test_external_api_documents_retrieve_connected_resource_server_with_wrong_abilities(
user_token, user_specific_sub, resource_server_backend
):
"""
A user with wrong abilities SHOULD NOT be allowed to retrieve a document from
a resource server.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
response = client.get(f"/external_api/v1.0/documents/{document.id!s}/")
assert response.status_code == 403
def test_external_api_documents_retrieve_connected_resource_server_using_access_token(
user_token, resource_server_backend, user_specific_sub
):
"""
A user with an access token SHOULD be allowed to retrieve a document from
a resource server.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.LinkRoleChoices.READER
)
response = client.get(f"/external_api/v1.0/documents/{document.id!s}/")
assert response.status_code == 200
def test_external_api_documents_create_root_success(
user_token, resource_server_backend, user_specific_sub
):
"""
Users with an access token should be able to create a root document through the resource
server and should automatically be declared as the owner of the newly created document.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
response = client.post(
"/external_api/v1.0/documents/",
{
"title": "Test Root Document",
},
)
assert response.status_code == 201
data = response.json()
document = models.Document.objects.get(id=data["id"])
assert document.title == "Test Root Document"
assert document.creator == user_specific_sub
assert document.accesses.filter(role="owner", user=user_specific_sub).exists()
def test_external_api_documents_create_subdocument_owner_success(
user_token, resource_server_backend, user_specific_sub
):
"""
Users with an access token SHOULD BE able to create a sub-document through the resource
server when they have OWNER permissions on the parent document.
The creator is set to the authenticated user, and permissions are inherited
from the parent document.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
# Create a parent document first
parent_document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
factories.UserDocumentAccessFactory(
document=parent_document,
user=user_specific_sub,
role=models.RoleChoices.OWNER,
)
response = client.post(
f"/external_api/v1.0/documents/{parent_document.id}/children/",
{
"title": "Test Sub Document",
},
)
assert response.status_code == 201
data = response.json()
document = models.Document.objects.get(id=data["id"])
assert document.title == "Test Sub Document"
assert document.creator == user_specific_sub
assert document.get_parent() == parent_document
# Child documents inherit permissions from parent, no direct access needed
assert not document.accesses.exists()
def test_external_api_documents_create_subdocument_editor_success(
user_token, resource_server_backend, user_specific_sub
):
"""
Users with an access token SHOULD BE able to create a sub-document through the resource
server when they have EDITOR permissions on the parent document.
Permissions are inherited from the parent document.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
# Create a parent document first
parent_document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
)
factories.UserDocumentAccessFactory(
document=parent_document,
user=user_specific_sub,
role=models.RoleChoices.EDITOR,
)
response = client.post(
f"/external_api/v1.0/documents/{parent_document.id}/children/",
{
"title": "Test Sub Document",
},
)
assert response.status_code == 201
data = response.json()
document = models.Document.objects.get(id=data["id"])
assert document.title == "Test Sub Document"
assert document.creator == user_specific_sub
assert document.get_parent() == parent_document
# Child documents inherit permissions from parent, no direct access needed
assert not document.accesses.exists()
def test_external_api_documents_create_subdocument_reader_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Users with an access token SHOULD NOT be able to create a sub-document through the resource
server when they have READER permissions on the parent document.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
# Create a parent document first
parent_document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
)
factories.UserDocumentAccessFactory(
document=parent_document,
user=user_specific_sub,
role=models.RoleChoices.READER,
)
response = client.post(
f"/external_api/v1.0/documents/{parent_document.id}/children/",
{
"title": "Test Sub Document",
},
)
assert response.status_code == 403
@patch("core.services.converter_services.Converter.convert")
def test_external_api_documents_create_with_markdown_file_success(
mock_convert, user_token, resource_server_backend, user_specific_sub
):
"""
Users with an access token should be able to create documents through the resource
server by uploading a Markdown file and should automatically be declared as the owner
of the newly created document.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
# Mock the conversion
converted_yjs = "base64encodedyjscontent"
mock_convert.return_value = converted_yjs
# Create a fake Markdown file
file_content = b"# Test Document\n\nThis is a test."
file = BytesIO(file_content)
file.name = "readme.md"
response = client.post(
"/external_api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 201
data = response.json()
document = models.Document.objects.get(id=data["id"])
assert document.title == "readme.md"
assert document.content == converted_yjs
assert document.accesses.filter(role="owner", user=user_specific_sub).exists()
# Verify the converter was called correctly
mock_convert.assert_called_once_with(
file_content,
content_type=mime_types.MARKDOWN,
accept=mime_types.YJS,
)
def test_external_api_documents_list_with_multiple_roles(
user_token, resource_server_backend, user_specific_sub
):
"""
List all documents accessible to a user with different roles and verify
that associated permissions are correctly returned in the response.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
# Create documents with different roles for the user
owner_document = factories.DocumentFactory(
title="Owner Document",
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
factories.UserDocumentAccessFactory(
document=owner_document,
user=user_specific_sub,
role=models.RoleChoices.OWNER,
)
editor_document = factories.DocumentFactory(
title="Editor Document",
link_reach=models.LinkReachChoices.RESTRICTED,
)
factories.UserDocumentAccessFactory(
document=editor_document,
user=user_specific_sub,
role=models.RoleChoices.EDITOR,
)
reader_document = factories.DocumentFactory(
title="Reader Document",
link_reach=models.LinkReachChoices.RESTRICTED,
)
factories.UserDocumentAccessFactory(
document=reader_document,
user=user_specific_sub,
role=models.RoleChoices.READER,
)
# Create a document the user should NOT have access to
other_document = factories.DocumentFactory(
title="Other Document",
link_reach=models.LinkReachChoices.RESTRICTED,
)
other_user = factories.UserFactory()
factories.UserDocumentAccessFactory(
document=other_document,
user=other_user,
role=models.RoleChoices.OWNER,
)
response = client.get("/external_api/v1.0/documents/")
assert response.status_code == 200
data = response.json()
# Verify the response contains results
assert "results" in data
results = data["results"]
# Verify user can see exactly 3 documents (owner, editor, reader)
result_ids = {result["id"] for result in results}
assert len(results) == 3
assert str(owner_document.id) in result_ids
assert str(editor_document.id) in result_ids
assert str(reader_document.id) in result_ids
assert str(other_document.id) not in result_ids
# Verify each document has correct user_role field indicating permission level
for result in results:
if result["id"] == str(owner_document.id):
assert result["title"] == "Owner Document"
assert result["user_role"] == models.RoleChoices.OWNER
elif result["id"] == str(editor_document.id):
assert result["title"] == "Editor Document"
assert result["user_role"] == models.RoleChoices.EDITOR
elif result["id"] == str(reader_document.id):
assert result["title"] == "Reader Document"
assert result["user_role"] == models.RoleChoices.READER
def test_external_api_documents_duplicate_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users CAN DUPLICATE a document from a resource server
when they have the required permissions on the document,
as this action bypasses the permission checks.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
factories.UserDocumentAccessFactory(
document=document,
user=user_specific_sub,
role=models.RoleChoices.OWNER,
)
response = client.post(
f"/external_api/v1.0/documents/{document.id!s}/duplicate/",
)
assert response.status_code == 201
# NOT allowed actions on resource server.
def test_external_api_documents_put_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to PUT a document from a resource server.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
factories.UserDocumentAccessFactory(
document=document,
user=user_specific_sub,
role=models.RoleChoices.OWNER,
)
response = client.put(
f"/external_api/v1.0/documents/{document.id!s}/", {"title": "new title"}
)
assert response.status_code == 403
def test_external_api_document_delete_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to delete a document from a resource server.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
factories.UserDocumentAccessFactory(
document=document,
user=user_specific_sub,
role=models.RoleChoices.OWNER,
)
response = client.delete(f"/external_api/v1.0/documents/{document.id!s}/")
assert response.status_code == 403
def test_external_api_documents_move_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to MOVE a document from a resource server.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
factories.UserDocumentAccessFactory(
document=document,
user=user_specific_sub,
role=models.RoleChoices.OWNER,
)
new_parent = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
factories.UserDocumentAccessFactory(
document=new_parent,
user=user_specific_sub,
role=models.RoleChoices.OWNER,
)
response = client.post(
f"/external_api/v1.0/documents/{document.id!s}/move/",
{"target_document_id": new_parent.id},
)
assert response.status_code == 403
def test_external_api_documents_restore_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to restore a document from a resource server.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
factories.UserDocumentAccessFactory(
document=document,
user=user_specific_sub,
role=models.RoleChoices.OWNER,
)
response = client.post(f"/external_api/v1.0/documents/{document.id!s}/restore/")
assert response.status_code == 403
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_external_api_documents_trashbin_not_allowed(
role, reach, user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to list documents from the trashbin,
regardless of the document link reach and user role, from a resource server.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(
link_reach=reach,
creator=user_specific_sub,
deleted_at=timezone.now(),
)
factories.UserDocumentAccessFactory(
document=document,
user=user_specific_sub,
role=role,
)
response = client.get("/external_api/v1.0/documents/trashbin/")
assert response.status_code == 403
def test_external_api_documents_create_for_owner_not_allowed():
"""
Authenticated users SHOULD NOT be allowed to call create documents
on behalf of other users.
This API endpoint is reserved for server-to-server calls.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
data = {
"title": "My Document",
"content": "Document content",
"sub": "123",
"email": "john.doe@example.com",
}
response = client.post(
"/external_api/v1.0/documents/create-for-owner/",
data,
format="json",
)
assert response.status_code == 401
assert not models.Document.objects.exists()
# Test overrides
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": ["list", "retrieve", "children", "trashbin"],
},
}
)
def test_external_api_documents_trashbin_can_be_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD be allowed to list soft deleted documents from a resource server
when the trashbin action is enabled in EXTERNAL_API settings.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
document.soft_delete()
response = client.get("/external_api/v1.0/documents/trashbin/")
assert response.status_code == 200
content = response.json()
results = content.pop("results")
assert content == {
"count": 1,
"next": None,
"previous": None,
}
assert len(results) == 1
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": ["list", "retrieve", "children", "destroy"],
},
}
)
def test_external_api_documents_delete_can_be_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD be allowed to delete a document from a resource server
when the delete action is enabled in EXTERNAL_API settings.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
response = client.delete(f"/external_api/v1.0/documents/{document.id!s}/")
assert response.status_code == 204
# Verify the document is soft deleted
document.refresh_from_db()
assert document.deleted_at is not None
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
"update",
],
},
}
)
def test_external_api_documents_update_can_be_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD be allowed to update a document from a resource server
when the update action is enabled in EXTERNAL_API settings.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
original_title = document.title
response = client.put(
f"/external_api/v1.0/documents/{document.id!s}/", {"title": "new title"}
)
assert response.status_code == 200
# Verify the document is updated
document.refresh_from_db()
assert document.title == "new title"
assert document.title != original_title
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": ["list", "retrieve", "children", "move"],
},
}
)
def test_external_api_documents_move_can_be_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD be allowed to move a document from a resource server
when the move action is enabled in EXTERNAL_API settings and they
have the required permissions on the document and the target location.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
parent = factories.DocumentFactory(
users=[(user_specific_sub, "owner")], teams=[("lasuite", "owner")]
)
# A document with no owner
document = factories.DocumentFactory(
parent=parent, users=[(user_specific_sub, "reader")]
)
target = factories.DocumentFactory()
response = client.post(
f"/external_api/v1.0/documents/{document.id!s}/move/",
data={"target_document_id": str(target.id), "position": "first-sibling"},
)
assert response.status_code == 200
assert response.json() == {"message": "Document moved successfully."}
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": ["list", "retrieve", "children", "restore"],
},
}
)
def test_external_api_documents_restore_can_be_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD be allowed to restore a recently soft-deleted document
from a resource server when the restore action is enabled in EXTERNAL_API
settings and they have the required permissions on the document.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
now = timezone.now() - timedelta(days=15)
document = factories.DocumentFactory(deleted_at=now)
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role="owner"
)
response = client.post(f"/external_api/v1.0/documents/{document.id!s}/restore/")
assert response.status_code == 200
assert response.json() == {"detail": "Document has been successfully restored."}
document.refresh_from_db()
assert document.deleted_at is None
assert document.ancestors_deleted_at is None

View File

@@ -0,0 +1,681 @@
"""
Tests for the Resource Server API for documents accesses.
Not testing external API endpoints that are already tested in the /api
because the resource server viewsets inherit from the api viewsets.
"""
from django.test import override_settings
import pytest
import responses
from rest_framework.test import APIClient
from core import factories, models
from core.api import serializers
from core.tests.utils.urls import reload_urls
pytestmark = pytest.mark.django_db
# pylint: disable=unused-argument
def test_external_api_document_accesses_anonymous_public_standalone():
"""
Anonymous users SHOULD NOT be allowed to list document accesses
from external API if resource server is not enabled.
"""
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
)
response = APIClient().get(
f"/external_api/v1.0/documents/{document.id!s}/accesses/"
)
assert response.status_code == 404
def test_external_api_document_accesses_list_connected_not_resource_server():
"""
Connected users SHOULD NOT be allowed to list document accesses
if resource server is not enabled.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
response = APIClient().get(
f"/external_api/v1.0/documents/{document.id!s}/accesses/"
)
assert response.status_code == 404
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
],
},
"document_access": {
"enabled": True,
"actions": [],
},
}
)
def test_external_api_document_accesses_list_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to list the accesses of
a document from a resource server.
"""
reload_urls()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
factories.UserDocumentAccessFactory(
document=document,
user=user_specific_sub,
role=models.RoleChoices.OWNER,
)
response = client.get(f"/external_api/v1.0/documents/{document.id!s}/accesses/")
assert response.status_code == 403
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
],
},
"document_access": {
"enabled": True,
"actions": [],
},
}
)
def test_external_api_document_accesses_retrieve_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to retrieve a specific access of
a document from a resource server.
"""
reload_urls()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
factories.UserDocumentAccessFactory(
document=document,
user=user_specific_sub,
role=models.RoleChoices.OWNER,
)
access = factories.UserDocumentAccessFactory(document=document)
response = client.get(
f"/external_api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/"
)
assert response.status_code == 403
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
],
},
"document_access": {
"enabled": True,
"actions": [],
},
}
)
def test_external_api_documents_accesses_create_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to create an access for a document
from a resource server.
"""
reload_urls()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
factories.UserDocumentAccessFactory(
document=document,
user=user_specific_sub,
role=models.RoleChoices.OWNER,
)
other_user = factories.UserFactory()
response = client.post(
f"/external_api/v1.0/documents/{document.id!s}/accesses/",
{"user_id": other_user.id, "role": models.RoleChoices.READER},
)
assert response.status_code == 403
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
],
},
"document_access": {
"enabled": True,
"actions": [],
},
}
)
def test_external_api_document_accesses_update_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to update an access for a
document from a resource server through PUT.
"""
reload_urls()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
factories.UserDocumentAccessFactory(
document=document,
user=user_specific_sub,
role=models.RoleChoices.OWNER,
)
other_user = factories.UserFactory()
access = factories.UserDocumentAccessFactory(
document=document, user=other_user, role=models.RoleChoices.READER
)
response = client.put(
f"/external_api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
{"role": models.RoleChoices.EDITOR},
)
assert response.status_code == 403
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
],
},
"document_access": {
"enabled": True,
"actions": [],
},
}
)
def test_external_api_document_accesses_partial_update_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to update an access
for a document from a resource server through PATCH.
"""
reload_urls()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
factories.UserDocumentAccessFactory(
document=document,
user=user_specific_sub,
role=models.RoleChoices.OWNER,
)
other_user = factories.UserFactory()
access = factories.UserDocumentAccessFactory(
document=document, user=other_user, role=models.RoleChoices.READER
)
response = client.patch(
f"/external_api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
{"role": models.RoleChoices.EDITOR},
)
assert response.status_code == 403
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
],
},
"document_access": {
"enabled": True,
"actions": [],
},
}
)
def test_external_api_documents_accesses_delete_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to delete an access for
a document from a resource server.
"""
reload_urls()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
access = factories.UserDocumentAccessFactory(
document=document,
user=user_specific_sub,
role=models.RoleChoices.OWNER,
)
response = client.delete(
f"/external_api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
# Overrides
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
],
},
"document_access": {
"enabled": True,
"actions": ["list", "retrieve"],
},
}
)
def test_external_api_document_accesses_list_can_be_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD be allowed to list the accesses of a document from a resource server
when the list action is enabled in EXTERNAL_API document_access settings.
"""
reload_urls()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED, creator=user_specific_sub
)
user_access = factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
# Create additional accesses
other_user = factories.UserFactory()
other_access = factories.UserDocumentAccessFactory(
document=document, user=other_user, role=models.RoleChoices.READER
)
response = client.get(f"/external_api/v1.0/documents/{document.id!s}/accesses/")
assert response.status_code == 200
data = response.json()
access_ids = [entry["id"] for entry in data]
assert str(user_access.id) in access_ids
assert str(other_access.id) in access_ids
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
],
},
"document_access": {
"enabled": True,
"actions": ["list", "retrieve"],
},
}
)
def test_external_api_document_accesses_retrieve_can_be_allowed(
user_token,
resource_server_backend,
user_specific_sub,
):
"""
A user who is related to a document SHOULD be allowed to retrieve the
associated document user accesses.
"""
reload_urls()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory()
access = factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
response = client.get(
f"/external_api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
data = response.json()
assert response.status_code == 200
assert data["id"] == str(access.id)
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
],
},
"document_access": {
"enabled": True,
"actions": ["list", "create"],
},
}
)
def test_external_api_document_accesses_create_can_be_allowed(
user_token,
resource_server_backend,
user_specific_sub,
):
"""
A user who is related to a document SHOULD be allowed to create
a user access for the document.
"""
reload_urls()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
other_user = factories.UserFactory()
response = client.post(
f"/external_api/v1.0/documents/{document.id!s}/accesses/",
data={"user_id": other_user.id, "role": models.RoleChoices.READER},
)
data = response.json()
assert response.status_code == 201
assert data["role"] == models.RoleChoices.READER
assert str(data["user"]["id"]) == str(other_user.id)
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
],
},
"document_access": {
"enabled": True,
"actions": ["list", "update"],
},
}
)
def test_external_api_document_accesses_update_can_be_allowed(
user_token,
resource_server_backend,
user_specific_sub,
settings,
):
"""
A user who is related to a document SHOULD be allowed to update
a user access for the document through PUT.
"""
reload_urls()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
other_user = factories.UserFactory()
access = factories.UserDocumentAccessFactory(
document=document, user=other_user, role=models.RoleChoices.READER
)
# Add the reset-connections endpoint to the existing mock
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
endpoint_url = (
f"{settings.COLLABORATION_API_URL}reset-connections/?room={document.id}"
)
resource_server_backend.add(
responses.POST,
endpoint_url,
json={},
status=200,
)
old_values = serializers.DocumentAccessSerializer(instance=access).data
# Update only the role field
response = client.put(
f"/external_api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
{**old_values, "role": models.RoleChoices.EDITOR}, #  type: ignore
format="json",
)
assert response.status_code == 200
data = response.json()
assert data["role"] == models.RoleChoices.EDITOR
assert str(data["user"]["id"]) == str(other_user.id)
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
],
},
"document_access": {
"enabled": True,
"actions": ["list", "partial_update"],
},
}
)
def test_external_api_document_accesses_partial_update_can_be_allowed(
user_token,
resource_server_backend,
user_specific_sub,
settings,
):
"""
A user who is related to a document SHOULD be allowed to update
a user access for the document through PATCH.
"""
reload_urls()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
other_user = factories.UserFactory()
access = factories.UserDocumentAccessFactory(
document=document, user=other_user, role=models.RoleChoices.READER
)
# Add the reset-connections endpoint to the existing mock
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
endpoint_url = (
f"{settings.COLLABORATION_API_URL}reset-connections/?room={document.id}"
)
resource_server_backend.add(
responses.POST,
endpoint_url,
json={},
status=200,
)
response = client.patch(
f"/external_api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data={"role": models.RoleChoices.EDITOR},
)
data = response.json()
assert response.status_code == 200
assert data["role"] == models.RoleChoices.EDITOR
assert str(data["user"]["id"]) == str(other_user.id)
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
],
},
"document_access": {
"enabled": True,
"actions": ["list", "destroy"],
},
}
)
def test_external_api_documents_accesses_delete_can_be_allowed(
user_token, resource_server_backend, user_specific_sub, settings
):
"""
Connected users SHOULD be allowed to delete an access for
a document from a resource server when the destroy action is
enabled in settings.
"""
reload_urls()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
factories.UserDocumentAccessFactory(
document=document,
user=user_specific_sub,
role=models.RoleChoices.OWNER,
)
other_user = factories.UserFactory()
other_access = factories.UserDocumentAccessFactory(
document=document, user=other_user, role=models.RoleChoices.READER
)
# Add the reset-connections endpoint to the existing mock
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
endpoint_url = (
f"{settings.COLLABORATION_API_URL}reset-connections/?room={document.id}"
)
resource_server_backend.add(
responses.POST,
endpoint_url,
json={},
status=200,
)
response = client.delete(
f"/external_api/v1.0/documents/{document.id!s}/accesses/{other_access.id!s}/",
)
assert response.status_code == 204

View File

@@ -0,0 +1,273 @@
"""
Tests for the Resource Server API for document AI features.
Not testing external API endpoints that are already tested in the /api
because the resource server viewsets inherit from the api viewsets.
"""
from unittest.mock import MagicMock, patch
from django.test import override_settings
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.tests.documents.test_api_documents_ai_proxy import ( # pylint: disable=unused-import
ai_settings,
)
pytestmark = pytest.mark.django_db
# pylint: disable=unused-argument
def test_external_api_documents_ai_transform_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to access AI transform endpoints
from a resource server by default.
"""
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
response = client.post(
f"/external_api/v1.0/documents/{document.id!s}/ai-transform/",
{"text": "hello", "action": "prompt"},
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
def test_external_api_documents_ai_translate_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to access AI translate endpoints
from a resource server by default.
"""
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
response = client.post(
f"/external_api/v1.0/documents/{document.id!s}/ai-translate/",
{"text": "hello", "language": "es"},
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
def test_external_api_documents_ai_proxy_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to access AI proxy endpoints
from a resource server by default.
"""
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
response = client.post(
f"/external_api/v1.0/documents/{document.id!s}/ai-proxy/",
b"{}",
content_type="application/json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
# Overrides
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
"ai_transform",
],
},
}
)
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_external_api_documents_ai_transform_can_be_allowed(
mock_create, user_token, resource_server_backend, user_specific_sub
):
"""
Users SHOULD be allowed to transform a document using AI when the
corresponding action is enabled via EXTERNAL_API settings.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED, favorited_by=[user_specific_sub]
)
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content="Salut"))]
)
url = f"/external_api/v1.0/documents/{document.id!s}/ai-transform/"
response = client.post(url, {"text": "Hello", "action": "prompt"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
# pylint: disable=line-too-long
mock_create.assert_called_once_with(
model="llama",
messages=[
{
"role": "system",
"content": (
"Answer the prompt using markdown formatting for structure and emphasis. "
"Return the content directly without wrapping it in code blocks or markdown delimiters. "
"Preserve the language and markdown formatting. "
"Do not provide any other information. "
"Preserve the language."
),
},
{"role": "user", "content": "Hello"},
],
)
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
"ai_translate",
],
},
}
)
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_external_api_documents_ai_translate_can_be_allowed(
mock_create, user_token, resource_server_backend, user_specific_sub
):
"""
Users SHOULD be allowed to translate a document using AI when the
corresponding action is enabled via EXTERNAL_API settings.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED, favorited_by=[user_specific_sub]
)
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content="Salut"))]
)
url = f"/external_api/v1.0/documents/{document.id!s}/ai-translate/"
response = client.post(url, {"text": "Hello", "language": "es-co"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
mock_create.assert_called_once_with(
model="llama",
messages=[
{
"role": "system",
"content": (
"Keep the same html structure and formatting. "
"Translate the content in the html to the "
"specified language Colombian Spanish. "
"Check the translation for accuracy and make any necessary corrections. "
"Do not provide any other information."
),
},
{"role": "user", "content": "Hello"},
],
)
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
"ai_proxy",
],
},
}
)
@pytest.mark.usefixtures("ai_settings")
@patch("core.services.ai_services.AIService.stream")
def test_external_api_documents_ai_proxy_can_be_allowed(
mock_stream, user_token, resource_server_backend, user_specific_sub
):
"""
Users SHOULD be allowed to use the AI proxy endpoint when the
corresponding action is enabled via EXTERNAL_API settings.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED, creator=user_specific_sub
)
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
mock_stream.return_value = iter(["data: response\n"])
url = f"/external_api/v1.0/documents/{document.id!s}/ai-proxy/"
response = client.post(
url,
b"{}",
content_type="application/json",
)
assert response.status_code == 200
assert response["Content-Type"] == "text/event-stream" # type: ignore
mock_stream.assert_called_once()

View File

@@ -0,0 +1,121 @@
"""
Tests for the Resource Server API for document attachments.
Not testing external API endpoints that are already tested in the /api
because the resource server viewsets inherit from the api viewsets.
"""
import re
import uuid
from urllib.parse import parse_qs, urlparse
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import override_settings
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
# pylint: disable=unused-argument
def test_external_api_documents_attachment_upload_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to upload attachments to a document
from a resource server.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
pixel = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00"
b"\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\xf8\xff\xff?\x00\x05\xfe\x02\xfe"
b"\xa7V\xbd\xfa\x00\x00\x00\x00IEND\xaeB`\x82"
)
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
file = SimpleUploadedFile(name="test.png", content=pixel, content_type="image/png")
response = client.post(
f"/external_api/v1.0/documents/{document.id!s}/attachment-upload/",
{"file": file},
format="multipart",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
"attachment_upload",
],
},
}
)
def test_external_api_documents_attachment_upload_can_be_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD be allowed to upload attachments to a document
from a resource server when the attachment-upload action is enabled in EXTERNAL_API settings.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
pixel = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00"
b"\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\xf8\xff\xff?\x00\x05\xfe\x02\xfe"
b"\xa7V\xbd\xfa\x00\x00\x00\x00IEND\xaeB`\x82"
)
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
factories.UserDocumentAccessFactory(
document=document,
user=user_specific_sub,
role=models.RoleChoices.OWNER,
)
file = SimpleUploadedFile(name="test.png", content=pixel, content_type="image/png")
response = client.post(
f"/external_api/v1.0/documents/{document.id!s}/attachment-upload/",
{"file": file},
format="multipart",
)
assert response.status_code == 201
pattern = re.compile(rf"^{document.id!s}/attachments/(.*)\.png")
url_parsed = urlparse(response.json()["file"])
assert url_parsed.path == f"/api/v1.0/documents/{document.id!s}/media-check/"
query = parse_qs(url_parsed.query)
assert query["key"][0] is not None
file_path = query["key"][0]
match = pattern.search(file_path)
file_id = match.group(1) # type: ignore
# Validate that file_id is a valid UUID
uuid.UUID(file_id)

View File

@@ -0,0 +1,157 @@
"""
Tests for the Resource Server API for document favorites.
Not testing external API endpoints that are already tested in the /api
because the resource server viewsets inherit from the api viewsets.
"""
from django.test import override_settings
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
# pylint: disable=unused-argument
def test_external_api_documents_favorites_list_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD be allowed to list their favorites
from a resource server, as favorite_list() bypasses permissions.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.UserDocumentAccessFactory(
user=user_specific_sub,
role=models.RoleChoices.READER,
document__favorited_by=[user_specific_sub],
).document
response = client.get("/external_api/v1.0/documents/favorite_list/")
assert response.status_code == 200
data = response.json()
assert data["count"] == 1
assert data["results"][0]["id"] == str(document.id)
def test_external_api_documents_favorite_add_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
By default the "favorite" action is not permitted on the external API.
POST to the endpoint must return 403.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
response = client.post(f"/external_api/v1.0/documents/{document.id!s}/favorite/")
assert response.status_code == 403
def test_external_api_documents_favorite_delete_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
By default the "favorite" action is not permitted on the external API.
DELETE to the endpoint must return 403.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
response = client.delete(f"/external_api/v1.0/documents/{document.id!s}/favorite/")
assert response.status_code == 403
# Overrides
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
"favorite",
],
},
}
)
def test_external_api_documents_favorite_add_can_be_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Users SHOULD be allowed to POST to the favorite endpoint when the
corresponding action is enabled via EXTERNAL_API settings.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
response = client.post(f"/external_api/v1.0/documents/{document.id!s}/favorite/")
assert response.status_code == 201
assert models.DocumentFavorite.objects.filter(
document=document, user=user_specific_sub
).exists()
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
"favorite",
],
},
}
)
def test_external_api_documents_favorite_delete_can_be_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Users SHOULD be allowed to DELETE from the favorite endpoint when the
corresponding action is enabled via EXTERNAL_API settings.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED, favorited_by=[user_specific_sub]
)
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
response = client.delete(f"/external_api/v1.0/documents/{document.id!s}/favorite/")
assert response.status_code == 204
assert not models.DocumentFavorite.objects.filter(
document=document, user=user_specific_sub
).exists()

View File

@@ -0,0 +1,474 @@
"""
Tests for the Resource Server API for invitations.
Not testing external API endpoints that are already tested in the /api
because the resource server viewsets inherit from the api viewsets.
"""
from django.test import override_settings
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.tests.utils.urls import reload_urls
pytestmark = pytest.mark.django_db
# pylint: disable=unused-argument
def test_external_api_document_invitations_anonymous_public_standalone():
"""
Anonymous users SHOULD NOT be allowed to list invitations from external
API if resource server is not enabled.
"""
invitation = factories.InvitationFactory()
response = APIClient().get(
f"/external_api/v1.0/documents/{invitation.document.id!s}/invitations/"
)
assert response.status_code == 404
def test_external_api_document_invitations_list_connected_not_resource_server():
"""
Connected users SHOULD NOT be allowed to list document invitations
if resource server is not enabled.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
invitation = factories.InvitationFactory()
response = APIClient().get(
f"/external_api/v1.0/documents/{invitation.document.id!s}/invitations/"
)
assert response.status_code == 404
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
],
},
"document_invitation": {
"enabled": True,
"actions": [],
},
},
)
def test_external_api_document_invitations_list_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to list document invitations
by default.
"""
reload_urls()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
invitation = factories.InvitationFactory()
response = client.get(
f"/external_api/v1.0/documents/{invitation.document.id!s}/invitations/"
)
assert response.status_code == 403
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
],
},
"document_invitation": {
"enabled": True,
"actions": [],
},
},
)
def test_external_api_document_invitations_retrieve_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to retrieve a document invitation
by default.
"""
reload_urls()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
invitation = factories.InvitationFactory()
document = invitation.document
response = client.get(
f"/external_api/v1.0/documents/{document.id!s}/invitations/{invitation.id!s}/"
)
assert response.status_code == 403
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
],
},
"document_invitation": {
"enabled": True,
"actions": [],
},
},
)
def test_external_api_document_invitations_create_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to create a document invitation
by default.
"""
reload_urls()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
response = client.post(
f"/external_api/v1.0/documents/{document.id!s}/invitations/",
{"email": "invited@example.com", "role": models.RoleChoices.READER},
format="json",
)
assert response.status_code == 403
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
],
},
"document_invitation": {
"enabled": True,
"actions": ["list", "retrieve"],
},
},
)
def test_external_api_document_invitations_partial_update_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to partially update a document invitation
by default.
"""
reload_urls()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
invitation = factories.InvitationFactory(
document=document, role=models.RoleChoices.READER
)
response = client.patch(
f"/external_api/v1.0/documents/{document.id!s}/invitations/{invitation.id!s}/",
{"role": models.RoleChoices.EDITOR},
format="json",
)
assert response.status_code == 403
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
],
},
"document_invitation": {
"enabled": True,
"actions": ["list", "retrieve"],
},
},
)
def test_external_api_document_invitations_delete_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to delete a document invitation
by default.
"""
reload_urls()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
invitation = factories.InvitationFactory(document=document)
response = client.delete(
f"/external_api/v1.0/documents/{document.id!s}/invitations/{invitation.id!s}/",
)
assert response.status_code == 403
# Overrides
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
],
},
"document_invitation": {
"enabled": True,
"actions": ["list", "retrieve"],
},
},
)
def test_external_api_document_invitations_list_can_be_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD be allowed to list document invitations
when the action is explicitly enabled.
"""
reload_urls()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
invitation = factories.InvitationFactory(document=document)
response = client.get(f"/external_api/v1.0/documents/{document.id!s}/invitations/")
assert response.status_code == 200
data = response.json()
assert data["count"] == 1
assert data["results"][0]["id"] == str(invitation.id)
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
],
},
"document_invitation": {
"enabled": True,
"actions": ["list", "retrieve"],
},
},
)
def test_external_api_document_invitations_retrieve_can_be_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD be allowed to retrieve a document invitation
when the action is explicitly enabled.
"""
reload_urls()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
invitation = factories.InvitationFactory(document=document)
response = client.get(
f"/external_api/v1.0/documents/{document.id!s}/invitations/{invitation.id!s}/"
)
assert response.status_code == 200
data = response.json()
assert data["id"] == str(invitation.id)
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
],
},
"document_invitation": {
"enabled": True,
"actions": ["list", "retrieve", "create"],
},
},
)
def test_external_api_document_invitations_create_can_be_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD be allowed to create a document invitation
when the create action is explicitly enabled.
"""
reload_urls()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
response = client.post(
f"/external_api/v1.0/documents/{document.id!s}/invitations/",
{"email": "invited@example.com", "role": models.RoleChoices.READER},
format="json",
)
assert response.status_code == 201
data = response.json()
assert data["email"] == "invited@example.com"
assert data["role"] == models.RoleChoices.READER
assert str(data["document"]) == str(document.id)
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
],
},
"document_invitation": {
"enabled": True,
"actions": ["list", "retrieve", "partial_update"],
},
},
)
def test_external_api_document_invitations_partial_update_can_be_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD be allowed to partially update a document invitation
when the partial_update action is explicitly enabled.
"""
reload_urls()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
invitation = factories.InvitationFactory(
document=document, role=models.RoleChoices.READER
)
response = client.patch(
f"/external_api/v1.0/documents/{document.id!s}/invitations/{invitation.id!s}/",
{"role": models.RoleChoices.EDITOR},
format="json",
)
assert response.status_code == 200
data = response.json()
assert data["role"] == models.RoleChoices.EDITOR
assert data["email"] == invitation.email
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
],
},
"document_invitation": {
"enabled": True,
"actions": ["list", "retrieve", "destroy"],
},
},
)
def test_external_api_document_invitations_delete_can_be_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD be allowed to delete a document invitation
when the destroy action is explicitly enabled.
"""
reload_urls()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
invitation = factories.InvitationFactory(document=document)
response = client.delete(
f"/external_api/v1.0/documents/{document.id!s}/invitations/{invitation.id!s}/",
)
assert response.status_code == 204

View File

@@ -0,0 +1,105 @@
"""
Tests for the Resource Server API for document link configurations.
Not testing external API endpoints that are already tested in the /api
because the resource server viewsets inherit from the api viewsets.
"""
from unittest.mock import patch
from django.test import override_settings
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
# pylint: disable=unused-argument
def test_external_api_documents_link_configuration_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to update the link configuration of a document
from a resource server.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
factories.UserDocumentAccessFactory(
document=document,
user=user_specific_sub,
role=models.RoleChoices.OWNER,
)
response = client.put(
f"/external_api/v1.0/documents/{document.id!s}/link-configuration/"
)
assert response.status_code == 403
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
"link_configuration",
],
},
},
COLLABORATION_API_URL="http://example.com/",
COLLABORATION_SERVER_SECRET="secret-token",
)
@patch("core.services.collaboration_services.CollaborationService.reset_connections")
def test_external_api_documents_link_configuration_can_be_allowed(
mock_reset, user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD be allowed to update the link configuration of a document
from a resource server when the corresponding action is enabled in EXTERNAL_API settings.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
factories.UserDocumentAccessFactory(
document=document,
user=user_specific_sub,
role=models.RoleChoices.OWNER,
)
# attempt to change reach/role to a valid combination
new_data = {
"link_reach": models.LinkReachChoices.PUBLIC,
"link_role": models.LinkRoleChoices.EDITOR,
}
response = client.put(
f"/external_api/v1.0/documents/{document.id!s}/link-configuration/",
new_data,
format="json",
)
assert response.status_code == 200
# verify the document was updated in the database
document.refresh_from_db()
assert document.link_reach == models.LinkReachChoices.PUBLIC
assert document.link_role == models.LinkRoleChoices.EDITOR

View File

@@ -0,0 +1,94 @@
"""
Tests for the Resource Server API for document media authentication.
Not testing external API endpoints that are already tested in the /api
because the resource server viewsets inherit from the api viewsets.
"""
from io import BytesIO
from uuid import uuid4
from django.core.files.storage import default_storage
from django.test import override_settings
from django.utils import timezone
import pytest
from freezegun import freeze_time
from rest_framework.test import APIClient
from core import factories, models
from core.enums import DocumentAttachmentStatus
pytestmark = pytest.mark.django_db
# pylint: disable=unused-argument
def test_external_api_documents_media_auth_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to access media auth endpoints
from a resource server by default.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
response = client.get("/external_api/v1.0/documents/media-auth/")
assert response.status_code == 403
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
"media_auth",
],
},
}
)
def test_external_api_documents_media_auth_can_be_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD be allowed to access media auth endpoints
from a resource server when the media-auth action is enabled in EXTERNAL_API settings.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document_id = uuid4()
filename = f"{uuid4()!s}.jpg"
key = f"{document_id!s}/attachments/{filename:s}"
media_url = f"http://localhost/media/{key:s}"
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=key,
Body=BytesIO(b"my prose"),
ContentType="text/plain",
Metadata={"status": DocumentAttachmentStatus.READY},
)
document = factories.DocumentFactory(
id=document_id, link_reach=models.LinkReachChoices.RESTRICTED, attachments=[key]
)
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.READER
)
now = timezone.now()
with freeze_time(now):
response = client.get(
"/external_api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
)
assert response.status_code == 200

View File

@@ -0,0 +1,163 @@
"""
Tests for the Resource Server API for document versions.
Not testing external API endpoints that are already tested in the /api
because the resource server viewsets inherit from the api viewsets.
"""
import time
from django.test import override_settings
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
# pylint: disable=unused-argument
def test_external_api_documents_versions_list_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to list the versions of a document
from a resource server by default.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
factories.UserDocumentAccessFactory(
document=document,
user=user_specific_sub,
role=models.RoleChoices.OWNER,
)
response = client.get(f"/external_api/v1.0/documents/{document.id!s}/versions/")
assert response.status_code == 403
def test_external_api_documents_versions_detail_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to retrieve a specific version of a document
from a resource server by default.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
response = client.get(
f"/external_api/v1.0/documents/{document.id!s}/versions/1234/"
)
assert response.status_code == 403
# Overrides
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": ["list", "retrieve", "children", "versions_list"],
},
}
)
def test_external_api_documents_versions_list_can_be_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD be allowed to list version of a document from a resource server
when the versions action is enabled in EXTERNAL_API settings.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
# Add new versions to the document
for i in range(3):
document.content = f"new content {i:d}"
document.save()
response = client.get(f"/external_api/v1.0/documents/{document.id!s}/versions/")
assert response.status_code == 200
content = response.json()
assert content["count"] == 2
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
"versions_list",
"versions_detail",
],
},
}
)
def test_external_api_documents_versions_detail_can_be_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD be allowed to retrieve a specific version of a document
from a resource server when the versions_detail action is enabled.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
# ensure access datetime is earlier than versions (minio precision is one second)
time.sleep(1)
# create several versions, spacing them out to get distinct LastModified values
for i in range(3):
document.content = f"new content {i:d}"
document.save()
time.sleep(1)
# call the list endpoint and verify basic structure
response = client.get(f"/external_api/v1.0/documents/{document.id!s}/versions/")
assert response.status_code == 200
content = response.json()
# count should reflect two saved versions beyond the original
assert content.get("count") == 2
# pick the first version returned by the list (should be accessible)
version_id = content.get("versions")[0]["version_id"]
detailed_response = client.get(
f"/external_api/v1.0/documents/{document.id!s}/versions/{version_id}/"
)
assert detailed_response.status_code == 200
assert detailed_response.json()["content"] == "new content 1"

View File

@@ -0,0 +1,158 @@
"""
Tests for the Resource Server API for users.
Not testing external API endpoints that are already tested in the /api
because the resource server viewsets inherit from the api viewsets.
"""
import pytest
from rest_framework.test import APIClient
from core import factories
from core.api import serializers
from core.tests.utils.urls import reload_urls
pytestmark = pytest.mark.django_db
# pylint: disable=unused-argument
def test_external_api_users_me_anonymous_public_standalone():
"""
Anonymous users SHOULD NOT be allowed to retrieve their own user information from external
API if resource server is not enabled.
"""
reload_urls()
response = APIClient().get("/external_api/v1.0/users/me/")
assert response.status_code == 404
def test_external_api_users_me_connected_not_allowed():
"""
Connected users SHOULD NOT be allowed to retrieve their own user information from external
API if resource server is not enabled.
"""
reload_urls()
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.get("/external_api/v1.0/users/me/")
assert response.status_code == 404
def test_external_api_users_me_connected_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD be allowed to retrieve their own user information from external API
if resource server is enabled.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
response = client.get("/external_api/v1.0/users/me/")
assert response.status_code == 200
data = response.json()
assert data["id"] == str(user_specific_sub.id)
assert data["email"] == user_specific_sub.email
def test_external_api_users_me_connected_with_invalid_token_not_allowed(
user_token, resource_server_backend
):
"""
Connected users SHOULD NOT be allowed to retrieve their own user information from external API
if resource server is enabled with an invalid token.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
response = client.get("/external_api/v1.0/users/me/")
assert response.status_code == 401
# Non allowed actions on resource server.
def test_external_api_users_list_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to list users from a resource server.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
response = client.get("/external_api/v1.0/users/")
assert response.status_code == 403
def test_external_api_users_retrieve_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to retrieve a specific user from a resource server.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
other_user = factories.UserFactory()
response = client.get(f"/external_api/v1.0/users/{other_user.id!s}/")
assert response.status_code == 403
def test_external_api_users_put_patch_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to update or patch a user from a resource server.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
other_user = factories.UserFactory()
new_user_values = {
k: v
for k, v in serializers.UserSerializer(
instance=factories.UserFactory()
).data.items()
if v is not None
}
response = client.put(
f"/external_api/v1.0/users/{other_user.id!s}/", new_user_values
)
assert response.status_code == 403
response = client.patch(
f"/external_api/v1.0/users/{other_user.id!s}/",
{"email": "new_email@example.com"},
)
assert response.status_code == 403
def test_external_api_users_delete_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to delete a user from a resource server.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
other_user = factories.UserFactory()
response = client.delete(f"/external_api/v1.0/users/{other_user.id!s}/")
assert response.status_code == 403

View File

@@ -1,7 +1,5 @@
import pytest import pytest
from core import models
@pytest.mark.django_db @pytest.mark.django_db
def test_update_blank_title_migration(migrator): def test_update_blank_title_migration(migrator):

View File

@@ -7,8 +7,6 @@ from django.core.files.storage import default_storage
import pycrdt import pycrdt
import pytest import pytest
from core import models
@pytest.mark.django_db @pytest.mark.django_db
def test_populate_attachments_on_all_documents(migrator): def test_populate_attachments_on_all_documents(migrator):

View File

@@ -0,0 +1,52 @@
"""Module testing migration 0030 about adding is_first_connection to user model."""
from django.contrib.auth.hashers import make_password
import factory
import pytest
from core import models
@pytest.mark.django_db
def test_set_is_first_connection_false(migrator):
"""
Test that once the migration adding is_first_connection column to user model is applied
all existing user have the False value.
"""
old_state = migrator.apply_initial_migration(
("core", "0029_userreconciliationcsvimport_userreconciliation")
)
OldUser = old_state.apps.get_model("core", "User")
old_user1 = OldUser.objects.create(
email="email1@example.com", sub="user1", password=make_password("password")
)
old_user2 = OldUser.objects.create(
email="email2@example.com", sub="user2", password=make_password("password")
)
assert hasattr(old_user1, "is_first_connection") is False
assert hasattr(old_user2, "is_first_connection") is False
# # Apply the migration
new_state = migrator.apply_tested_migration(
("core", "0030_user_is_first_connection")
)
NewUser = new_state.apps.get_model("core", "User")
updated_user1 = NewUser.objects.get(id=old_user1.id)
assert updated_user1.is_first_connection is False
updated_user2 = NewUser.objects.get(id=old_user2.id)
assert updated_user2.is_first_connection is False
# create a new user after migration
new_user1 = NewUser.objects.create(
email="email3example.com", sub="user3", password=make_password("password")
)
assert new_user1.is_first_connection is True

View File

@@ -0,0 +1,193 @@
"""Module testing migration 0031_clean_onboarding_accesses."""
from django.contrib.auth.hashers import make_password
import pytest
def create_user(OldUser, n):
"""Create a user with a unique sub and email based on the given index."""
return OldUser.objects.create(
email=f"user-{n}@example.com",
sub=f"user-{n}",
password=make_password("password"),
)
@pytest.mark.django_db
def test_clean_onboarding_accesses(migrator, settings):
"""Test migration 0031_clean_onboarding_accesses."""
old_state = migrator.apply_initial_migration(
("core", "0030_user_is_first_connection")
)
OldUser = old_state.apps.get_model("core", "User")
OldDocument = old_state.apps.get_model("core", "Document")
OldDocumentAccess = old_state.apps.get_model("core", "DocumentAccess")
# Create onboarding documents
onboarding_doc_1 = OldDocument.objects.create(
title="Onboarding Doc 1", depth=1, path="0000001", link_reach="public"
)
onboarding_doc_2 = OldDocument.objects.create(
title="Onboarding Doc 2", depth=1, path="0000002", link_reach="public"
)
onboarding_documents = [onboarding_doc_1, onboarding_doc_2]
settings.USER_ONBOARDING_DOCUMENTS = [str(doc.id) for doc in onboarding_documents]
# Create other documents
non_onboarding_doc_1 = OldDocument.objects.create(
title="Non-Onboarding Doc 1", depth=1, path="0000003", link_reach="public"
)
non_onboarding_doc_2 = OldDocument.objects.create(
title="Non-Onboarding Doc 2", depth=1, path="0000004", link_reach="public"
)
non_onboarding_doc_3 = OldDocument.objects.create(
title="Non-Onboarding Doc 3", depth=1, path="0000005", link_reach="public"
)
non_onboarding_documents = [
non_onboarding_doc_1,
non_onboarding_doc_2,
non_onboarding_doc_3,
]
all_documents = onboarding_documents + non_onboarding_documents
user_counter = 0
# For every document create privileged roles: owner and admin
for document in all_documents:
OldDocumentAccess.objects.create(
document=document,
user=create_user(OldUser, user_counter),
role="owner",
)
user_counter += 1
OldDocumentAccess.objects.create(
document=document,
user=create_user(OldUser, user_counter),
role="administrator",
)
user_counter += 1
# For every document, create non-privileged roles
for document in all_documents:
for role in ["reader", "editor", "commenter"]:
for _ in range(10):
OldDocumentAccess.objects.create(
document=document,
user=create_user(OldUser, user_counter),
role=role,
)
user_counter += 1
onboarding_ids = [doc.id for doc in onboarding_documents]
non_onboarding_ids = [doc.id for doc in non_onboarding_documents]
# All documents should have 32 accesses each, so 160 accesses total
assert OldDocumentAccess.objects.count() == 160
assert (
OldDocumentAccess.objects.filter(document_id__in=onboarding_ids)
.exclude(role__in=["administrator", "owner"])
.count()
== 60
)
assert (
OldDocumentAccess.objects.filter(
document_id__in=onboarding_ids, role__in=["administrator", "owner"]
).count()
== 4
)
assert (
OldDocumentAccess.objects.filter(document_id__in=non_onboarding_ids)
.exclude(role__in=["administrator", "owner"])
.count()
== 90
)
assert (
OldDocumentAccess.objects.filter(
document_id__in=non_onboarding_ids, role__in=["administrator", "owner"]
).count()
== 6
)
# Apply the migration
new_state = migrator.apply_tested_migration(
("core", "0031_clean_onboarding_accesses")
)
NewDocumentAccess = new_state.apps.get_model("core", "DocumentAccess")
# 60 accesses should have been removed (30 non-privileged for each onboarding doc)
assert NewDocumentAccess.objects.count() == 100
# Non-privileged roles should have been deleted on the onboarding documents
assert (
NewDocumentAccess.objects.filter(document_id__in=onboarding_ids)
.exclude(role__in=["administrator", "owner"])
.count()
== 0
)
# Privileged roles should have been kept
assert (
NewDocumentAccess.objects.filter(
document_id__in=onboarding_ids, role__in=["administrator", "owner"]
).count()
== 4
)
# On other documents, all accesses should remain
assert (
NewDocumentAccess.objects.filter(document_id__in=non_onboarding_ids)
.exclude(role__in=["administrator", "owner"])
.count()
== 90
)
# Privileged roles should have been kept
assert (
NewDocumentAccess.objects.filter(
document_id__in=non_onboarding_ids, role__in=["administrator", "owner"]
).count()
== 6
)
@pytest.mark.django_db
def test_clean_onboarding_accesses_no_setting(migrator, settings):
"""Test migration 0031 does not delete any access when USER_ONBOARDING_DOCUMENTS is empty."""
old_state = migrator.apply_initial_migration(
("core", "0030_user_is_first_connection")
)
OldUser = old_state.apps.get_model("core", "User")
OldDocument = old_state.apps.get_model("core", "Document")
OldDocumentAccess = old_state.apps.get_model("core", "DocumentAccess")
settings.USER_ONBOARDING_DOCUMENTS = []
doc_1 = OldDocument.objects.create(title="Doc 1", depth=1, path="0000001")
doc_2 = OldDocument.objects.create(title="Doc 2", depth=1, path="0000002")
user_counter = 0
for document in [doc_1, doc_2]:
for role in ["owner", "administrator", "reader", "editor", "commenter"]:
OldDocumentAccess.objects.create(
document=document,
user=create_user(OldUser, user_counter),
role=role,
)
user_counter += 1
assert OldDocumentAccess.objects.count() == 10
new_state = migrator.apply_tested_migration(
("core", "0031_clean_onboarding_accesses")
)
NewDocumentAccess = new_state.apps.get_model("core", "DocumentAccess")
# No accesses should have been deleted
assert NewDocumentAccess.objects.count() == 10

View File

@@ -48,7 +48,7 @@ def test_api_users_list_query_email():
Only results with a Levenstein distance less than 3 with the query should be returned. Only results with a Levenstein distance less than 3 with the query should be returned.
We want to match by Levenstein distance because we want to prevent typing errors. We want to match by Levenstein distance because we want to prevent typing errors.
""" """
user = factories.UserFactory() user = factories.UserFactory(email="user@example.com", full_name="Example User")
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
@@ -83,7 +83,7 @@ def test_api_users_list_query_email_with_internationalized_domain_names():
Authenticated users should be able to list users and filter by email. Authenticated users should be able to list users and filter by email.
It should work even if the email address contains an internationalized domain name. It should work even if the email address contains an internationalized domain name.
""" """
user = factories.UserFactory() user = factories.UserFactory(email="user@example.com", full_name="Example User")
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
@@ -123,7 +123,7 @@ def test_api_users_list_query_full_name():
Authenticated users should be able to list users and filter by full name. Authenticated users should be able to list users and filter by full name.
Only results with a Trigram similarity greater than 0.2 with the query should be returned. Only results with a Trigram similarity greater than 0.2 with the query should be returned.
""" """
user = factories.UserFactory(email="user@example.com") user = factories.UserFactory(email="user@example.com", full_name="Example User")
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
@@ -168,7 +168,7 @@ def test_api_users_list_query_accented_full_name():
Authenticated users should be able to list users and filter by full name with accents. Authenticated users should be able to list users and filter by full name with accents.
Only results with a Trigram similarity greater than 0.2 with the query should be returned. Only results with a Trigram similarity greater than 0.2 with the query should be returned.
""" """
user = factories.UserFactory(email="user@example.com") user = factories.UserFactory(email="user@example.com", full_name="Example User")
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
@@ -416,7 +416,7 @@ def test_api_users_list_query_long_queries():
def test_api_users_list_query_inactive(): def test_api_users_list_query_inactive():
"""Inactive users should not be listed.""" """Inactive users should not be listed."""
user = factories.UserFactory(email="user@example.com") user = factories.UserFactory(email="user@example.com", full_name="Example User")
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
@@ -460,6 +460,7 @@ def test_api_users_retrieve_me_authenticated():
"full_name": user.full_name, "full_name": user.full_name,
"language": user.language, "language": user.language,
"short_name": user.short_name, "short_name": user.short_name,
"is_first_connection": True,
} }
@@ -489,9 +490,37 @@ def test_api_users_retrieve_me_authenticated_empty_name():
"full_name": "test_foo", "full_name": "test_foo",
"language": user.language, "language": user.language,
"short_name": "test_foo", "short_name": "test_foo",
"is_first_connection": True,
} }
def test_api_users_retrieve_me_onboarding():
"""
On first connection of a new user, the "is_first_connection" flag should be True.
The frontend can use this flag to trigger specific behavior for first time users,
e.g. showing an onboarding message, and update the flag to False after onboarding is done.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# First request: flag should be True
first_response = client.get("/api/v1.0/users/me/")
assert first_response.status_code == 200
assert first_response.json()["is_first_connection"] is True
update_response = client.post("/api/v1.0/users/onboarding-done/")
assert update_response.status_code == 200
# Second request: flag should be False
second_response = client.get("/api/v1.0/users/me/")
assert second_response.status_code == 200
assert second_response.json()["is_first_connection"] is False
def test_api_users_retrieve_anonymous(): def test_api_users_retrieve_anonymous():
"""Anonymous users should not be allowed to retrieve a user.""" """Anonymous users should not be allowed to retrieve a user."""
client = APIClient() client = APIClient()

View File

@@ -0,0 +1,32 @@
"""module testing the conditional_refresh_oidc_token utils."""
from unittest import mock
from core.api import utils
def test_refresh_oidc_access_token_storing_refresh_token_disabled(settings):
"""The method_decorator must not be called when OIDC_STORE_REFRESH_TOKEN is False."""
settings.OIDC_STORE_REFRESH_TOKEN = False
callback = mock.MagicMock()
with mock.patch.object(utils, "method_decorator") as mock_method_decorator:
result = utils.conditional_refresh_oidc_token(callback)
mock_method_decorator.assert_not_called()
assert result == callback
def test_refresh_oidc_access_token_storing_refresh_token_enabled(settings):
"""The method_decorator must not be called when OIDC_STORE_REFRESH_TOKEN is False."""
settings.OIDC_STORE_REFRESH_TOKEN = True
callback = mock.MagicMock()
with mock.patch.object(utils, "method_decorator") as mock_method_decorator:
utils.conditional_refresh_oidc_token(callback)
mock_method_decorator.assert_called_with(utils.refresh_oidc_access_token)

View File

@@ -189,6 +189,7 @@ def test_models_documents_get_abilities_forbidden(
"versions_destroy": False, "versions_destroy": False,
"versions_list": False, "versions_list": False,
"versions_retrieve": False, "versions_retrieve": False,
"search": False,
} }
nb_queries = 1 if is_authenticated else 0 nb_queries = 1 if is_authenticated else 0
with django_assert_num_queries(nb_queries): with django_assert_num_queries(nb_queries):
@@ -255,6 +256,7 @@ def test_models_documents_get_abilities_reader(
"versions_destroy": False, "versions_destroy": False,
"versions_list": False, "versions_list": False,
"versions_retrieve": False, "versions_retrieve": False,
"search": True,
} }
nb_queries = 1 if is_authenticated else 0 nb_queries = 1 if is_authenticated else 0
with django_assert_num_queries(nb_queries): with django_assert_num_queries(nb_queries):
@@ -326,6 +328,7 @@ def test_models_documents_get_abilities_commenter(
"versions_destroy": False, "versions_destroy": False,
"versions_list": False, "versions_list": False,
"versions_retrieve": False, "versions_retrieve": False,
"search": True,
} }
nb_queries = 1 if is_authenticated else 0 nb_queries = 1 if is_authenticated else 0
with django_assert_num_queries(nb_queries): with django_assert_num_queries(nb_queries):
@@ -394,6 +397,7 @@ def test_models_documents_get_abilities_editor(
"versions_destroy": False, "versions_destroy": False,
"versions_list": False, "versions_list": False,
"versions_retrieve": False, "versions_retrieve": False,
"search": True,
} }
nb_queries = 1 if is_authenticated else 0 nb_queries = 1 if is_authenticated else 0
with django_assert_num_queries(nb_queries): with django_assert_num_queries(nb_queries):
@@ -451,6 +455,7 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
"versions_destroy": True, "versions_destroy": True,
"versions_list": True, "versions_list": True,
"versions_retrieve": True, "versions_retrieve": True,
"search": True,
} }
with django_assert_num_queries(1): with django_assert_num_queries(1):
assert document.get_abilities(user) == expected_abilities assert document.get_abilities(user) == expected_abilities
@@ -494,6 +499,7 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
"versions_destroy": False, "versions_destroy": False,
"versions_list": False, "versions_list": False,
"versions_retrieve": False, "versions_retrieve": False,
"search": False,
} }
@@ -541,6 +547,7 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
"versions_destroy": True, "versions_destroy": True,
"versions_list": True, "versions_list": True,
"versions_retrieve": True, "versions_retrieve": True,
"search": True,
} }
with django_assert_num_queries(1): with django_assert_num_queries(1):
assert document.get_abilities(user) == expected_abilities assert document.get_abilities(user) == expected_abilities
@@ -598,6 +605,7 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
"versions_destroy": False, "versions_destroy": False,
"versions_list": True, "versions_list": True,
"versions_retrieve": True, "versions_retrieve": True,
"search": True,
} }
with django_assert_num_queries(1): with django_assert_num_queries(1):
assert document.get_abilities(user) == expected_abilities assert document.get_abilities(user) == expected_abilities
@@ -663,6 +671,7 @@ def test_models_documents_get_abilities_reader_user(
"versions_destroy": False, "versions_destroy": False,
"versions_list": True, "versions_list": True,
"versions_retrieve": True, "versions_retrieve": True,
"search": True,
} }
with override_settings(AI_ALLOW_REACH_FROM=ai_access_setting): with override_settings(AI_ALLOW_REACH_FROM=ai_access_setting):
@@ -729,6 +738,7 @@ def test_models_documents_get_abilities_commenter_user(
"versions_destroy": False, "versions_destroy": False,
"versions_list": True, "versions_list": True,
"versions_retrieve": True, "versions_retrieve": True,
"search": True,
} }
with override_settings(AI_ALLOW_REACH_FROM=ai_access_setting): with override_settings(AI_ALLOW_REACH_FROM=ai_access_setting):
@@ -791,6 +801,7 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
"versions_destroy": False, "versions_destroy": False,
"versions_list": True, "versions_list": True,
"versions_retrieve": True, "versions_retrieve": True,
"search": True,
} }

View File

@@ -79,7 +79,7 @@ def test_models_invitations_is_expired():
assert expired_invitation.is_expired is True assert expired_invitation.is_expired is True
def test_models_invitationd_new_userd_convert_invitations_to_accesses(): def test_models_invitations_new_user_convert_invitations_to_accesses():
""" """
Upon creating a new user, invitations linked to the email Upon creating a new user, invitations linked to the email
should be converted to accesses and then deleted. should be converted to accesses and then deleted.
@@ -114,7 +114,7 @@ def test_models_invitationd_new_userd_convert_invitations_to_accesses():
).exists() # the other invitation remains ).exists() # the other invitation remains
def test_models_invitationd_new_user_filter_expired_invitations(): def test_models_invitations_new_user_filter_expired_invitations():
""" """
Upon creating a new identity, valid invitations should be converted into accesses Upon creating a new identity, valid invitations should be converted into accesses
and expired invitations should remain unchanged. and expired invitations should remain unchanged.
@@ -145,7 +145,7 @@ def test_models_invitationd_new_user_filter_expired_invitations():
@pytest.mark.parametrize("num_invitations, num_queries", [(0, 3), (1, 7), (20, 7)]) @pytest.mark.parametrize("num_invitations, num_queries", [(0, 3), (1, 7), (20, 7)])
def test_models_invitationd_new_userd_user_creation_constant_num_queries( def test_models_invitations_new_userd_user_creation_constant_num_queries(
django_assert_num_queries, num_invitations, num_queries django_assert_num_queries, num_invitations, num_queries
): ):
""" """

View File

@@ -3,6 +3,7 @@ Unit tests for the User model
""" """
import uuid import uuid
from concurrent.futures import ThreadPoolExecutor
from unittest.mock import patch from unittest.mock import patch
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@@ -89,24 +90,19 @@ def test_models_users_handle_onboarding_documents_access_empty_setting():
assert models.DocumentAccess.objects.filter(user=user).count() == 0 assert models.DocumentAccess.objects.filter(user=user).count() == 0
def test_models_users_handle_onboarding_documents_access_with_single_document(): def test_models_users_handle_onboarding_document_link_trace_with_single_document():
""" """
When USER_ONBOARDING_DOCUMENTS has a valid document ID, When USER_ONBOARDING_DOCUMENTS has a valid document ID,
an access should be created for the new user with the READER role. a LinkTrace should be created for the new user.
The document should be pinned as a favorite for the user. The document should be pinned as a favorite for the user.
""" """
document = factories.DocumentFactory() document = factories.DocumentFactory(link_reach=models.LinkReachChoices.PUBLIC)
with override_settings(USER_ONBOARDING_DOCUMENTS=[str(document.id)]): with override_settings(USER_ONBOARDING_DOCUMENTS=[str(document.id)]):
user = factories.UserFactory() user = factories.UserFactory()
assert ( assert models.LinkTrace.objects.filter(user=user, document=document).count() == 1
models.DocumentAccess.objects.filter(user=user, document=document).count() == 1
)
access = models.DocumentAccess.objects.get(user=user, document=document)
assert access.role == models.RoleChoices.READER
user_favorites = models.DocumentFavorite.objects.filter(user=user) user_favorites = models.DocumentFavorite.objects.filter(user=user)
assert user_favorites.count() == 1 assert user_favorites.count() == 1
@@ -121,9 +117,15 @@ def test_models_users_handle_onboarding_documents_access_with_multiple_documents
All accesses should have the READER role. All accesses should have the READER role.
All documents should be pinned as favorites for the user. All documents should be pinned as favorites for the user.
""" """
document1 = factories.DocumentFactory(title="Document 1") document1 = factories.DocumentFactory(
document2 = factories.DocumentFactory(title="Document 2") title="Document 1", link_reach=models.LinkReachChoices.PUBLIC
document3 = factories.DocumentFactory(title="Document 3") )
document2 = factories.DocumentFactory(
title="Document 2", link_reach=models.LinkReachChoices.AUTHENTICATED
)
document3 = factories.DocumentFactory(
title="Document 3", link_reach=models.LinkReachChoices.PUBLIC
)
with override_settings( with override_settings(
USER_ONBOARDING_DOCUMENTS=[ USER_ONBOARDING_DOCUMENTS=[
@@ -134,15 +136,12 @@ def test_models_users_handle_onboarding_documents_access_with_multiple_documents
): ):
user = factories.UserFactory() user = factories.UserFactory()
user_accesses = models.DocumentAccess.objects.filter(user=user) link_traces = models.LinkTrace.objects.filter(user=user)
assert user_accesses.count() == 3 assert link_traces.count() == 3
assert models.DocumentAccess.objects.filter(user=user, document=document1).exists() assert models.LinkTrace.objects.filter(user=user, document=document1).exists()
assert models.DocumentAccess.objects.filter(user=user, document=document2).exists() assert models.LinkTrace.objects.filter(user=user, document=document2).exists()
assert models.DocumentAccess.objects.filter(user=user, document=document3).exists() assert models.LinkTrace.objects.filter(user=user, document=document3).exists()
for access in user_accesses:
assert access.role == models.RoleChoices.READER
user_favorites = models.DocumentFavorite.objects.filter(user=user) user_favorites = models.DocumentFavorite.objects.filter(user=user)
assert user_favorites.count() == 3 assert user_favorites.count() == 3
@@ -166,7 +165,7 @@ def test_models_users_handle_onboarding_documents_access_with_invalid_document_i
call_args = mock_logger.warning.call_args call_args = mock_logger.warning.call_args
assert "Onboarding document with id" in call_args[0][0] assert "Onboarding document with id" in call_args[0][0]
assert models.DocumentAccess.objects.filter(user=user).count() == 0 assert models.LinkTrace.objects.filter(user=user).count() == 0
def test_models_users_handle_onboarding_documents_access_duplicate_prevention(): def test_models_users_handle_onboarding_documents_access_duplicate_prevention():
@@ -174,16 +173,26 @@ def test_models_users_handle_onboarding_documents_access_duplicate_prevention():
If the same document is listed multiple times in USER_ONBOARDING_DOCUMENTS, If the same document is listed multiple times in USER_ONBOARDING_DOCUMENTS,
it should only create one access (or handle duplicates gracefully). it should only create one access (or handle duplicates gracefully).
""" """
document = factories.DocumentFactory() document = factories.DocumentFactory(link_reach=models.LinkReachChoices.PUBLIC)
with override_settings( with override_settings(
USER_ONBOARDING_DOCUMENTS=[str(document.id), str(document.id)] USER_ONBOARDING_DOCUMENTS=[str(document.id), str(document.id)]
): ):
user = factories.UserFactory() user = factories.UserFactory()
user_accesses = models.DocumentAccess.objects.filter(user=user, document=document) link_traces = models.LinkTrace.objects.filter(user=user, document=document)
assert user_accesses.count() >= 1 assert link_traces.count() == 1
def test_models_users_handle_onboarding_documents_on_restricted_document_is_not_allowed():
"""On-boarding document can be used when restricted"""
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
with override_settings(USER_ONBOARDING_DOCUMENTS=[str(document.id)]):
user = factories.UserFactory()
assert not models.LinkTrace.objects.filter(user=user, document=document).exists()
@override_settings(USER_ONBOARDING_SANDBOX_DOCUMENT=None) @override_settings(USER_ONBOARDING_SANDBOX_DOCUMENT=None)
@@ -207,7 +216,13 @@ def test_models_users_duplicate_onboarding_sandbox_document_creates_sandbox():
When USER_ONBOARDING_SANDBOX_DOCUMENT is set with a valid template document, When USER_ONBOARDING_SANDBOX_DOCUMENT is set with a valid template document,
a new sandbox document should be created for the user with OWNER access. a new sandbox document should be created for the user with OWNER access.
""" """
documents_before = factories.DocumentFactory.create_batch(20)
template_document = factories.DocumentFactory(title="Getting started with Docs") template_document = factories.DocumentFactory(title="Getting started with Docs")
documents_after = factories.DocumentFactory.create_batch(20)
all_documents = documents_before + [template_document] + documents_after
paths = {document.pk: document.path for document in all_documents}
with override_settings(USER_ONBOARDING_SANDBOX_DOCUMENT=str(template_document.id)): with override_settings(USER_ONBOARDING_SANDBOX_DOCUMENT=str(template_document.id)):
user = factories.UserFactory() user = factories.UserFactory()
@@ -224,6 +239,10 @@ def test_models_users_duplicate_onboarding_sandbox_document_creates_sandbox():
access = models.DocumentAccess.objects.get(user=user, document=sandbox_doc) access = models.DocumentAccess.objects.get(user=user, document=sandbox_doc)
assert access.role == models.RoleChoices.OWNER assert access.role == models.RoleChoices.OWNER
for document in all_documents:
document.refresh_from_db()
assert document.path == paths[document.id]
def test_models_users_duplicate_onboarding_sandbox_document_with_invalid_template_id(): def test_models_users_duplicate_onboarding_sandbox_document_with_invalid_template_id():
""" """
@@ -272,7 +291,9 @@ def test_models_users_duplicate_onboarding_sandbox_document_integration_with_oth
Verify that sandbox creation works alongside other onboarding methods. Verify that sandbox creation works alongside other onboarding methods.
""" """
template_document = factories.DocumentFactory(title="Getting started with Docs") template_document = factories.DocumentFactory(title="Getting started with Docs")
onboarding_doc = factories.DocumentFactory(title="Onboarding Document") onboarding_doc = factories.DocumentFactory(
title="Onboarding Document", link_reach=models.LinkReachChoices.AUTHENTICATED
)
with override_settings( with override_settings(
USER_ONBOARDING_SANDBOX_DOCUMENT=str(template_document.id), USER_ONBOARDING_SANDBOX_DOCUMENT=str(template_document.id),
@@ -284,11 +305,37 @@ def test_models_users_duplicate_onboarding_sandbox_document_integration_with_oth
creator=user, title="Getting started with Docs" creator=user, title="Getting started with Docs"
).first() ).first()
user_accesses = models.DocumentAccess.objects.filter(user=user) assert models.DocumentAccess.objects.filter(user=user).count() == 1
assert user_accesses.count() == 2 assert models.LinkTrace.objects.filter(user=user).count() == 1
sandbox_access = user_accesses.get(document=sandbox_doc) assert models.DocumentAccess.objects.filter(
onboarding_access = user_accesses.get(document=onboarding_doc) document=sandbox_doc, user=user, role=models.RoleChoices.OWNER
).exists()
assert models.LinkTrace.objects.filter(document=onboarding_doc, user=user).exists()
assert sandbox_access.role == models.RoleChoices.OWNER
assert onboarding_access.role == models.RoleChoices.READER @pytest.mark.django_db(transaction=True)
def test_models_users_duplicate_onboarding_sandbox_race_condition():
"""
It should be possible to create several documents at the same time
without causing any race conditions or data integrity issues.
"""
def create_user():
return factories.UserFactory()
template_document = factories.DocumentFactory(title="Getting started with Docs")
with (
override_settings(
USER_ONBOARDING_SANDBOX_DOCUMENT=str(template_document.id),
),
ThreadPoolExecutor(max_workers=2) as executor,
):
future1 = executor.submit(create_user)
future2 = executor.submit(create_user)
user1 = future1.result()
user2 = future2.result()
assert isinstance(user1, models.User)
assert isinstance(user2, models.User)

View File

@@ -1,5 +1,5 @@
""" """
Unit tests for the Document model Unit tests for FindDocumentIndexer
""" """
# pylint: disable=too-many-lines # pylint: disable=too-many-lines
@@ -12,7 +12,8 @@ from django.db import transaction
import pytest import pytest
from core import factories, models from core import factories, models
from core.services.search_indexers import SearchIndexer from core.enums import SearchType
from core.services.search_indexers import FindDocumentIndexer
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@@ -30,7 +31,7 @@ def reset_throttle():
reset_batch_indexer_throttle() reset_batch_indexer_throttle()
@mock.patch.object(SearchIndexer, "push") @mock.patch.object(FindDocumentIndexer, "push")
@pytest.mark.usefixtures("indexer_settings") @pytest.mark.usefixtures("indexer_settings")
@pytest.mark.django_db(transaction=True) @pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer(mock_push): def test_models_documents_post_save_indexer(mock_push):
@@ -41,7 +42,7 @@ def test_models_documents_post_save_indexer(mock_push):
accesses = {} accesses = {}
data = [call.args[0] for call in mock_push.call_args_list] data = [call.args[0] for call in mock_push.call_args_list]
indexer = SearchIndexer() indexer = FindDocumentIndexer()
assert len(data) == 1 assert len(data) == 1
@@ -64,14 +65,14 @@ def test_models_documents_post_save_indexer_no_batches(indexer_settings):
"""Test indexation task on doculment creation, no throttle""" """Test indexation task on doculment creation, no throttle"""
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0 indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
with mock.patch.object(SearchIndexer, "push") as mock_push: with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
with transaction.atomic(): with transaction.atomic():
doc1, doc2, doc3 = factories.DocumentFactory.create_batch(3) doc1, doc2, doc3 = factories.DocumentFactory.create_batch(3)
accesses = {} accesses = {}
data = [call.args[0] for call in mock_push.call_args_list] data = [call.args[0] for call in mock_push.call_args_list]
indexer = SearchIndexer() indexer = FindDocumentIndexer()
# 3 calls # 3 calls
assert len(data) == 3 assert len(data) == 3
@@ -91,7 +92,7 @@ def test_models_documents_post_save_indexer_no_batches(indexer_settings):
assert cache.get("file-batch-indexer-throttle") is None assert cache.get("file-batch-indexer-throttle") is None
@mock.patch.object(SearchIndexer, "push") @mock.patch.object(FindDocumentIndexer, "push")
@pytest.mark.django_db(transaction=True) @pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer_not_configured(mock_push, indexer_settings): def test_models_documents_post_save_indexer_not_configured(mock_push, indexer_settings):
"""Task should not start an indexation when disabled""" """Task should not start an indexation when disabled"""
@@ -106,13 +107,13 @@ def test_models_documents_post_save_indexer_not_configured(mock_push, indexer_se
assert mock_push.assert_not_called assert mock_push.assert_not_called
@mock.patch.object(SearchIndexer, "push") @mock.patch.object(FindDocumentIndexer, "push")
@pytest.mark.django_db(transaction=True) @pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer_wrongly_configured( def test_models_documents_post_save_indexer_wrongly_configured(
mock_push, indexer_settings mock_push, indexer_settings
): ):
"""Task should not start an indexation when disabled""" """Task should not start an indexation when disabled"""
indexer_settings.SEARCH_INDEXER_URL = None indexer_settings.INDEXING_URL = None
user = factories.UserFactory() user = factories.UserFactory()
@@ -123,7 +124,7 @@ def test_models_documents_post_save_indexer_wrongly_configured(
assert mock_push.assert_not_called assert mock_push.assert_not_called
@mock.patch.object(SearchIndexer, "push") @mock.patch.object(FindDocumentIndexer, "push")
@pytest.mark.usefixtures("indexer_settings") @pytest.mark.usefixtures("indexer_settings")
@pytest.mark.django_db(transaction=True) @pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer_with_accesses(mock_push): def test_models_documents_post_save_indexer_with_accesses(mock_push):
@@ -145,7 +146,7 @@ def test_models_documents_post_save_indexer_with_accesses(mock_push):
data = [call.args[0] for call in mock_push.call_args_list] data = [call.args[0] for call in mock_push.call_args_list]
indexer = SearchIndexer() indexer = FindDocumentIndexer()
assert len(data) == 1 assert len(data) == 1
assert sorted(data[0], key=itemgetter("id")) == sorted( assert sorted(data[0], key=itemgetter("id")) == sorted(
@@ -158,7 +159,7 @@ def test_models_documents_post_save_indexer_with_accesses(mock_push):
) )
@mock.patch.object(SearchIndexer, "push") @mock.patch.object(FindDocumentIndexer, "push")
@pytest.mark.usefixtures("indexer_settings") @pytest.mark.usefixtures("indexer_settings")
@pytest.mark.django_db(transaction=True) @pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer_deleted(mock_push): def test_models_documents_post_save_indexer_deleted(mock_push):
@@ -207,7 +208,7 @@ def test_models_documents_post_save_indexer_deleted(mock_push):
data = [call.args[0] for call in mock_push.call_args_list] data = [call.args[0] for call in mock_push.call_args_list]
indexer = SearchIndexer() indexer = FindDocumentIndexer()
assert len(data) == 2 assert len(data) == 2
@@ -244,14 +245,14 @@ def test_models_documents_indexer_hard_deleted():
factories.UserDocumentAccessFactory(document=doc, user=user) factories.UserDocumentAccessFactory(document=doc, user=user)
# Call task on deleted document. # Call task on deleted document.
with mock.patch.object(SearchIndexer, "push") as mock_push: with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
doc.delete() doc.delete()
# Hard delete document are not re-indexed. # Hard delete document are not re-indexed.
assert mock_push.assert_not_called assert mock_push.assert_not_called
@mock.patch.object(SearchIndexer, "push") @mock.patch.object(FindDocumentIndexer, "push")
@pytest.mark.usefixtures("indexer_settings") @pytest.mark.usefixtures("indexer_settings")
@pytest.mark.django_db(transaction=True) @pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer_restored(mock_push): def test_models_documents_post_save_indexer_restored(mock_push):
@@ -308,7 +309,7 @@ def test_models_documents_post_save_indexer_restored(mock_push):
data = [call.args[0] for call in mock_push.call_args_list] data = [call.args[0] for call in mock_push.call_args_list]
indexer = SearchIndexer() indexer = FindDocumentIndexer()
# All docs are re-indexed # All docs are re-indexed
assert len(data) == 2 assert len(data) == 2
@@ -337,16 +338,16 @@ def test_models_documents_post_save_indexer_restored(mock_push):
@pytest.mark.usefixtures("indexer_settings") @pytest.mark.usefixtures("indexer_settings")
def test_models_documents_post_save_indexer_throttle(): def test_models_documents_post_save_indexer_throttle():
"""Test indexation task skipping on document update""" """Test indexation task skipping on document update"""
indexer = SearchIndexer() indexer = FindDocumentIndexer()
user = factories.UserFactory() user = factories.UserFactory()
with mock.patch.object(SearchIndexer, "push"): with mock.patch.object(FindDocumentIndexer, "push"):
with transaction.atomic(): with transaction.atomic():
docs = factories.DocumentFactory.create_batch(5, users=(user,)) docs = factories.DocumentFactory.create_batch(5, users=(user,))
accesses = {str(item.path): {"users": [user.sub]} for item in docs} accesses = {str(item.path): {"users": [user.sub]} for item in docs}
with mock.patch.object(SearchIndexer, "push") as mock_push: with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
# Simulate 1 running task # Simulate 1 running task
cache.set("document-batch-indexer-throttle", 1) cache.set("document-batch-indexer-throttle", 1)
@@ -359,7 +360,7 @@ def test_models_documents_post_save_indexer_throttle():
assert [call.args[0] for call in mock_push.call_args_list] == [] assert [call.args[0] for call in mock_push.call_args_list] == []
with mock.patch.object(SearchIndexer, "push") as mock_push: with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
# No waiting task # No waiting task
cache.delete("document-batch-indexer-throttle") cache.delete("document-batch-indexer-throttle")
@@ -389,7 +390,7 @@ def test_models_documents_access_post_save_indexer():
"""Test indexation task on DocumentAccess update""" """Test indexation task on DocumentAccess update"""
users = factories.UserFactory.create_batch(3) users = factories.UserFactory.create_batch(3)
with mock.patch.object(SearchIndexer, "push"): with mock.patch.object(FindDocumentIndexer, "push"):
with transaction.atomic(): with transaction.atomic():
doc = factories.DocumentFactory(users=users) doc = factories.DocumentFactory(users=users)
doc_accesses = models.DocumentAccess.objects.filter(document=doc).order_by( doc_accesses = models.DocumentAccess.objects.filter(document=doc).order_by(
@@ -398,7 +399,7 @@ def test_models_documents_access_post_save_indexer():
reset_batch_indexer_throttle() reset_batch_indexer_throttle()
with mock.patch.object(SearchIndexer, "push") as mock_push: with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
with transaction.atomic(): with transaction.atomic():
for doc_access in doc_accesses: for doc_access in doc_accesses:
doc_access.save() doc_access.save()
@@ -426,7 +427,7 @@ def test_models_items_access_post_save_indexer_no_throttle(indexer_settings):
reset_batch_indexer_throttle() reset_batch_indexer_throttle()
with mock.patch.object(SearchIndexer, "push") as mock_push: with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
with transaction.atomic(): with transaction.atomic():
for doc_access in doc_accesses: for doc_access in doc_accesses:
doc_access.save() doc_access.save()
@@ -439,3 +440,77 @@ def test_models_items_access_post_save_indexer_no_throttle(indexer_settings):
assert [len(d) for d in data] == [1] * 3 assert [len(d) for d in data] == [1] * 3
# the same document is indexed 3 times # the same document is indexed 3 times
assert [d[0]["id"] for d in data] == [str(doc.pk)] * 3 assert [d[0]["id"] for d in data] == [str(doc.pk)] * 3
@mock.patch.object(FindDocumentIndexer, "search_query")
@pytest.mark.usefixtures("indexer_settings")
def test_find_document_indexer_search(mock_search_query):
"""Test search function of FindDocumentIndexer returns formatted results"""
# Mock API response from Find
hits = [
{
"_id": "doc-123",
"_source": {
"title": "Test Document",
"content": "This is test content",
"updated_at": "2024-01-01T00:00:00Z",
"path": "/some/path/doc-123",
},
},
{
"_id": "doc-456",
"_source": {
"title.fr": "Document de test",
"content": "Contenu de test",
"updated_at": "2024-01-02T00:00:00Z",
},
},
]
mock_search_query.return_value = hits
q = "test"
token = "fake-token"
nb_results = 10
path = "/some/path/"
visited = ["doc-123"]
search_type = SearchType.HYBRID
results = FindDocumentIndexer().search(
q=q,
token=token,
nb_results=nb_results,
path=path,
visited=visited,
search_type=search_type,
)
mock_search_query.assert_called_once()
call_args = mock_search_query.call_args
assert call_args[1]["data"] == {
"q": q,
"visited": visited,
"services": ["docs"],
"nb_results": nb_results,
"order_by": "updated_at",
"order_direction": "desc",
"path": path,
"search_type": search_type,
}
assert len(results) == 2
assert results == [
{
"id": hits[0]["_id"],
"title": hits[0]["_source"]["title"],
"content": hits[0]["_source"]["content"],
"updated_at": hits[0]["_source"]["updated_at"],
"path": hits[0]["_source"]["path"],
},
{
"id": hits[1]["_id"],
"title": hits[1]["_source"]["title.fr"],
"title.fr": hits[1]["_source"]["title.fr"], # <- Find response artefact
"content": hits[1]["_source"]["content"],
"updated_at": hits[1]["_source"]["updated_at"],
},
]

View File

@@ -15,7 +15,7 @@ from requests import HTTPError
from core import factories, models, utils from core import factories, models, utils
from core.services.search_indexers import ( from core.services.search_indexers import (
BaseDocumentIndexer, BaseDocumentIndexer,
SearchIndexer, FindDocumentIndexer,
get_document_indexer, get_document_indexer,
get_visited_document_ids_of, get_visited_document_ids_of,
) )
@@ -78,41 +78,41 @@ def test_services_search_indexer_is_configured(indexer_settings):
# Valid class # Valid class
indexer_settings.SEARCH_INDEXER_CLASS = ( indexer_settings.SEARCH_INDEXER_CLASS = (
"core.services.search_indexers.SearchIndexer" "core.services.search_indexers.FindDocumentIndexer"
) )
get_document_indexer.cache_clear() get_document_indexer.cache_clear()
assert get_document_indexer() is not None assert get_document_indexer() is not None
indexer_settings.SEARCH_INDEXER_URL = "" indexer_settings.INDEXING_URL = ""
# Invalid url # Invalid url
get_document_indexer.cache_clear() get_document_indexer.cache_clear()
assert not get_document_indexer() assert not get_document_indexer()
def test_services_search_indexer_url_is_none(indexer_settings): def test_services_indexing_url_is_none(indexer_settings):
""" """
Indexer should raise RuntimeError if SEARCH_INDEXER_URL is None or empty. Indexer should raise RuntimeError if INDEXING_URL is None or empty.
""" """
indexer_settings.SEARCH_INDEXER_URL = None indexer_settings.INDEXING_URL = None
with pytest.raises(ImproperlyConfigured) as exc_info: with pytest.raises(ImproperlyConfigured) as exc_info:
SearchIndexer() FindDocumentIndexer()
assert "SEARCH_INDEXER_URL must be set in Django settings." in str(exc_info.value) assert "INDEXING_URL must be set in Django settings." in str(exc_info.value)
def test_services_search_indexer_url_is_empty(indexer_settings): def test_services_indexing_url_is_empty(indexer_settings):
""" """
Indexer should raise RuntimeError if SEARCH_INDEXER_URL is empty string. Indexer should raise RuntimeError if INDEXING_URL is empty string.
""" """
indexer_settings.SEARCH_INDEXER_URL = "" indexer_settings.INDEXING_URL = ""
with pytest.raises(ImproperlyConfigured) as exc_info: with pytest.raises(ImproperlyConfigured) as exc_info:
SearchIndexer() FindDocumentIndexer()
assert "SEARCH_INDEXER_URL must be set in Django settings." in str(exc_info.value) assert "INDEXING_URL must be set in Django settings." in str(exc_info.value)
def test_services_search_indexer_secret_is_none(indexer_settings): def test_services_search_indexer_secret_is_none(indexer_settings):
@@ -122,7 +122,7 @@ def test_services_search_indexer_secret_is_none(indexer_settings):
indexer_settings.SEARCH_INDEXER_SECRET = None indexer_settings.SEARCH_INDEXER_SECRET = None
with pytest.raises(ImproperlyConfigured) as exc_info: with pytest.raises(ImproperlyConfigured) as exc_info:
SearchIndexer() FindDocumentIndexer()
assert "SEARCH_INDEXER_SECRET must be set in Django settings." in str( assert "SEARCH_INDEXER_SECRET must be set in Django settings." in str(
exc_info.value exc_info.value
@@ -136,39 +136,35 @@ def test_services_search_indexer_secret_is_empty(indexer_settings):
indexer_settings.SEARCH_INDEXER_SECRET = "" indexer_settings.SEARCH_INDEXER_SECRET = ""
with pytest.raises(ImproperlyConfigured) as exc_info: with pytest.raises(ImproperlyConfigured) as exc_info:
SearchIndexer() FindDocumentIndexer()
assert "SEARCH_INDEXER_SECRET must be set in Django settings." in str( assert "SEARCH_INDEXER_SECRET must be set in Django settings." in str(
exc_info.value exc_info.value
) )
def test_services_search_endpoint_is_none(indexer_settings): def test_services_search_url_is_none(indexer_settings):
""" """
Indexer should raise RuntimeError if SEARCH_INDEXER_QUERY_URL is None. Indexer should raise RuntimeError if SEARCH_URL is None.
""" """
indexer_settings.SEARCH_INDEXER_QUERY_URL = None indexer_settings.SEARCH_URL = None
with pytest.raises(ImproperlyConfigured) as exc_info: with pytest.raises(ImproperlyConfigured) as exc_info:
SearchIndexer() FindDocumentIndexer()
assert "SEARCH_INDEXER_QUERY_URL must be set in Django settings." in str( assert "SEARCH_URL must be set in Django settings." in str(exc_info.value)
exc_info.value
)
def test_services_search_endpoint_is_empty(indexer_settings): def test_services_search_url_is_empty(indexer_settings):
""" """
Indexer should raise RuntimeError if SEARCH_INDEXER_QUERY_URL is empty. Indexer should raise RuntimeError if SEARCH_URL is empty.
""" """
indexer_settings.SEARCH_INDEXER_QUERY_URL = "" indexer_settings.SEARCH_URL = ""
with pytest.raises(ImproperlyConfigured) as exc_info: with pytest.raises(ImproperlyConfigured) as exc_info:
SearchIndexer() FindDocumentIndexer()
assert "SEARCH_INDEXER_QUERY_URL must be set in Django settings." in str( assert "SEARCH_URL must be set in Django settings." in str(exc_info.value)
exc_info.value
)
@pytest.mark.usefixtures("indexer_settings") @pytest.mark.usefixtures("indexer_settings")
@@ -192,7 +188,7 @@ def test_services_search_indexers_serialize_document_returns_expected_json():
} }
} }
indexer = SearchIndexer() indexer = FindDocumentIndexer()
result = indexer.serialize_document(document, accesses) result = indexer.serialize_document(document, accesses)
assert set(result.pop("users")) == {str(user_a.sub), str(user_b.sub)} assert set(result.pop("users")) == {str(user_a.sub), str(user_b.sub)}
@@ -221,7 +217,7 @@ def test_services_search_indexers_serialize_document_deleted():
parent.soft_delete() parent.soft_delete()
document.refresh_from_db() document.refresh_from_db()
indexer = SearchIndexer() indexer = FindDocumentIndexer()
result = indexer.serialize_document(document, {}) result = indexer.serialize_document(document, {})
assert result["is_active"] is False assert result["is_active"] is False
@@ -232,7 +228,7 @@ def test_services_search_indexers_serialize_document_empty():
"""Empty documents returns empty content in the serialized json.""" """Empty documents returns empty content in the serialized json."""
document = factories.DocumentFactory(content="", title=None) document = factories.DocumentFactory(content="", title=None)
indexer = SearchIndexer() indexer = FindDocumentIndexer()
result = indexer.serialize_document(document, {}) result = indexer.serialize_document(document, {})
assert result["content"] == "" assert result["content"] == ""
@@ -246,7 +242,7 @@ def test_services_search_indexers_index_errors(indexer_settings):
""" """
factories.DocumentFactory() factories.DocumentFactory()
indexer_settings.SEARCH_INDEXER_URL = "http://app-find/api/v1.0/documents/index/" indexer_settings.INDEXING_URL = "http://app-find/api/v1.0/documents/index/"
responses.add( responses.add(
responses.POST, responses.POST,
@@ -256,10 +252,10 @@ def test_services_search_indexers_index_errors(indexer_settings):
) )
with pytest.raises(HTTPError): with pytest.raises(HTTPError):
SearchIndexer().index() FindDocumentIndexer().index()
@patch.object(SearchIndexer, "push") @patch.object(FindDocumentIndexer, "push")
def test_services_search_indexers_batches_pass_only_batch_accesses( def test_services_search_indexers_batches_pass_only_batch_accesses(
mock_push, indexer_settings mock_push, indexer_settings
): ):
@@ -276,7 +272,7 @@ def test_services_search_indexers_batches_pass_only_batch_accesses(
access = factories.UserDocumentAccessFactory(document=document) access = factories.UserDocumentAccessFactory(document=document)
expected_user_subs[str(document.id)] = str(access.user.sub) expected_user_subs[str(document.id)] = str(access.user.sub)
assert SearchIndexer().index() == 5 assert FindDocumentIndexer().index() == 5
# Should be 3 batches: 2 + 2 + 1 # Should be 3 batches: 2 + 2 + 1
assert mock_push.call_count == 3 assert mock_push.call_count == 3
@@ -299,7 +295,7 @@ def test_services_search_indexers_batches_pass_only_batch_accesses(
assert seen_doc_ids == {str(d.id) for d in documents} assert seen_doc_ids == {str(d.id) for d in documents}
@patch.object(SearchIndexer, "push") @patch.object(FindDocumentIndexer, "push")
@pytest.mark.usefixtures("indexer_settings") @pytest.mark.usefixtures("indexer_settings")
def test_services_search_indexers_batch_size_argument(mock_push): def test_services_search_indexers_batch_size_argument(mock_push):
""" """
@@ -314,7 +310,7 @@ def test_services_search_indexers_batch_size_argument(mock_push):
access = factories.UserDocumentAccessFactory(document=document) access = factories.UserDocumentAccessFactory(document=document)
expected_user_subs[str(document.id)] = str(access.user.sub) expected_user_subs[str(document.id)] = str(access.user.sub)
assert SearchIndexer().index(batch_size=2) == 5 assert FindDocumentIndexer().index(batch_size=2) == 5
# Should be 3 batches: 2 + 2 + 1 # Should be 3 batches: 2 + 2 + 1
assert mock_push.call_count == 3 assert mock_push.call_count == 3
@@ -337,7 +333,7 @@ def test_services_search_indexers_batch_size_argument(mock_push):
assert seen_doc_ids == {str(d.id) for d in documents} assert seen_doc_ids == {str(d.id) for d in documents}
@patch.object(SearchIndexer, "push") @patch.object(FindDocumentIndexer, "push")
@pytest.mark.usefixtures("indexer_settings") @pytest.mark.usefixtures("indexer_settings")
def test_services_search_indexers_ignore_empty_documents(mock_push): def test_services_search_indexers_ignore_empty_documents(mock_push):
""" """
@@ -349,7 +345,7 @@ def test_services_search_indexers_ignore_empty_documents(mock_push):
empty_title = factories.DocumentFactory(title="") empty_title = factories.DocumentFactory(title="")
empty_content = factories.DocumentFactory(content="") empty_content = factories.DocumentFactory(content="")
assert SearchIndexer().index() == 3 assert FindDocumentIndexer().index() == 3
assert mock_push.call_count == 1 assert mock_push.call_count == 1
@@ -365,7 +361,7 @@ def test_services_search_indexers_ignore_empty_documents(mock_push):
} }
@patch.object(SearchIndexer, "push") @patch.object(FindDocumentIndexer, "push")
def test_services_search_indexers_skip_empty_batches(mock_push, indexer_settings): def test_services_search_indexers_skip_empty_batches(mock_push, indexer_settings):
""" """
Documents indexing batch can be empty if all the docs are empty. Documents indexing batch can be empty if all the docs are empty.
@@ -377,14 +373,14 @@ def test_services_search_indexers_skip_empty_batches(mock_push, indexer_settings
# Only empty docs # Only empty docs
factories.DocumentFactory.create_batch(5, content="", title="") factories.DocumentFactory.create_batch(5, content="", title="")
assert SearchIndexer().index() == 1 assert FindDocumentIndexer().index() == 1
assert mock_push.call_count == 1 assert mock_push.call_count == 1
results = [doc["id"] for doc in mock_push.call_args[0][0]] results = [doc["id"] for doc in mock_push.call_args[0][0]]
assert results == [str(document.id)] assert results == [str(document.id)]
@patch.object(SearchIndexer, "push") @patch.object(FindDocumentIndexer, "push")
@pytest.mark.usefixtures("indexer_settings") @pytest.mark.usefixtures("indexer_settings")
def test_services_search_indexers_ancestors_link_reach(mock_push): def test_services_search_indexers_ancestors_link_reach(mock_push):
"""Document accesses and reach should take into account ancestors link reaches.""" """Document accesses and reach should take into account ancestors link reaches."""
@@ -395,7 +391,7 @@ def test_services_search_indexers_ancestors_link_reach(mock_push):
parent = factories.DocumentFactory(parent=grand_parent, link_reach="public") parent = factories.DocumentFactory(parent=grand_parent, link_reach="public")
document = factories.DocumentFactory(parent=parent, link_reach="restricted") document = factories.DocumentFactory(parent=parent, link_reach="restricted")
assert SearchIndexer().index() == 4 assert FindDocumentIndexer().index() == 4
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]} results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
assert len(results) == 4 assert len(results) == 4
@@ -405,7 +401,7 @@ def test_services_search_indexers_ancestors_link_reach(mock_push):
assert results[str(document.id)]["reach"] == "public" assert results[str(document.id)]["reach"] == "public"
@patch.object(SearchIndexer, "push") @patch.object(FindDocumentIndexer, "push")
@pytest.mark.usefixtures("indexer_settings") @pytest.mark.usefixtures("indexer_settings")
def test_services_search_indexers_ancestors_users(mock_push): def test_services_search_indexers_ancestors_users(mock_push):
"""Document accesses and reach should include users from ancestors.""" """Document accesses and reach should include users from ancestors."""
@@ -415,7 +411,7 @@ def test_services_search_indexers_ancestors_users(mock_push):
parent = factories.DocumentFactory(parent=grand_parent, users=[user_p]) parent = factories.DocumentFactory(parent=grand_parent, users=[user_p])
document = factories.DocumentFactory(parent=parent, users=[user_d]) document = factories.DocumentFactory(parent=parent, users=[user_d])
assert SearchIndexer().index() == 3 assert FindDocumentIndexer().index() == 3
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]} results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
assert len(results) == 3 assert len(results) == 3
@@ -428,7 +424,7 @@ def test_services_search_indexers_ancestors_users(mock_push):
} }
@patch.object(SearchIndexer, "push") @patch.object(FindDocumentIndexer, "push")
@pytest.mark.usefixtures("indexer_settings") @pytest.mark.usefixtures("indexer_settings")
def test_services_search_indexers_ancestors_teams(mock_push): def test_services_search_indexers_ancestors_teams(mock_push):
"""Document accesses and reach should include teams from ancestors.""" """Document accesses and reach should include teams from ancestors."""
@@ -436,7 +432,7 @@ def test_services_search_indexers_ancestors_teams(mock_push):
parent = factories.DocumentFactory(parent=grand_parent, teams=["team_p"]) parent = factories.DocumentFactory(parent=grand_parent, teams=["team_p"])
document = factories.DocumentFactory(parent=parent, teams=["team_d"]) document = factories.DocumentFactory(parent=parent, teams=["team_d"])
assert SearchIndexer().index() == 3 assert FindDocumentIndexer().index() == 3
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]} results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
assert len(results) == 3 assert len(results) == 3
@@ -451,9 +447,9 @@ def test_push_uses_correct_url_and_data(mock_post, indexer_settings):
push() should call requests.post with the correct URL from settings push() should call requests.post with the correct URL from settings
the timeout set to 10 seconds and the data as JSON. the timeout set to 10 seconds and the data as JSON.
""" """
indexer_settings.SEARCH_INDEXER_URL = "http://example.com/index" indexer_settings.INDEXING_URL = "http://example.com/index"
indexer = SearchIndexer() indexer = FindDocumentIndexer()
sample_data = [{"id": "123", "title": "Test"}] sample_data = [{"id": "123", "title": "Test"}]
mock_response = mock_post.return_value mock_response = mock_post.return_value
@@ -464,7 +460,7 @@ def test_push_uses_correct_url_and_data(mock_post, indexer_settings):
mock_post.assert_called_once() mock_post.assert_called_once()
args, kwargs = mock_post.call_args args, kwargs = mock_post.call_args
assert args[0] == indexer_settings.SEARCH_INDEXER_URL assert args[0] == indexer_settings.INDEXING_URL
assert kwargs.get("json") == sample_data assert kwargs.get("json") == sample_data
assert kwargs.get("timeout") == 10 assert kwargs.get("timeout") == 10
@@ -498,7 +494,7 @@ def test_get_visited_document_ids_of():
factories.UserDocumentAccessFactory(user=user, document=doc2) factories.UserDocumentAccessFactory(user=user, document=doc2)
# The second document have an access for the user # The second document have an access for the user
assert get_visited_document_ids_of(queryset, user) == [str(doc1.pk)] assert get_visited_document_ids_of(queryset, user) == (str(doc1.pk),)
@pytest.mark.usefixtures("indexer_settings") @pytest.mark.usefixtures("indexer_settings")
@@ -532,7 +528,7 @@ def test_get_visited_document_ids_of_deleted():
doc_deleted.soft_delete() doc_deleted.soft_delete()
# Only the first document is not deleted # Only the first document is not deleted
assert get_visited_document_ids_of(queryset, user) == [str(doc.pk)] assert get_visited_document_ids_of(queryset, user) == (str(doc.pk),)
@responses.activate @responses.activate
@@ -542,9 +538,7 @@ def test_services_search_indexers_search_errors(indexer_settings):
""" """
factories.DocumentFactory() factories.DocumentFactory()
indexer_settings.SEARCH_INDEXER_QUERY_URL = ( indexer_settings.SEARCH_URL = "http://app-find/api/v1.0/documents/search/"
"http://app-find/api/v1.0/documents/search/"
)
responses.add( responses.add(
responses.POST, responses.POST,
@@ -554,17 +548,17 @@ def test_services_search_indexers_search_errors(indexer_settings):
) )
with pytest.raises(HTTPError): with pytest.raises(HTTPError):
SearchIndexer().search("alpha", token="mytoken") FindDocumentIndexer().search(q="alpha", token="mytoken")
@patch("requests.post") @patch("requests.post")
def test_services_search_indexers_search(mock_post, indexer_settings): def test_services_search_indexers_search(mock_post, indexer_settings):
""" """
search() should call requests.post to SEARCH_INDEXER_QUERY_URL with the search() should call requests.post to SEARCH_URL with the
document ids from linktraces. document ids from linktraces.
""" """
user = factories.UserFactory() user = factories.UserFactory()
indexer = SearchIndexer() indexer = FindDocumentIndexer()
mock_response = mock_post.return_value mock_response = mock_post.return_value
mock_response.raise_for_status.return_value = None # No error mock_response.raise_for_status.return_value = None # No error
@@ -578,11 +572,11 @@ def test_services_search_indexers_search(mock_post, indexer_settings):
visited = get_visited_document_ids_of(models.Document.objects.all(), user) visited = get_visited_document_ids_of(models.Document.objects.all(), user)
indexer.search("alpha", visited=visited, token="mytoken") indexer.search(q="alpha", visited=visited, token="mytoken")
args, kwargs = mock_post.call_args args, kwargs = mock_post.call_args
assert args[0] == indexer_settings.SEARCH_INDEXER_QUERY_URL assert args[0] == indexer_settings.SEARCH_URL
query_data = kwargs.get("json") query_data = kwargs.get("json")
assert query_data["q"] == "alpha" assert query_data["q"] == "alpha"
@@ -605,7 +599,7 @@ def test_services_search_indexers_search_nb_results(mock_post, indexer_settings)
indexer_settings.SEARCH_INDEXER_QUERY_LIMIT = 25 indexer_settings.SEARCH_INDEXER_QUERY_LIMIT = 25
user = factories.UserFactory() user = factories.UserFactory()
indexer = SearchIndexer() indexer = FindDocumentIndexer()
mock_response = mock_post.return_value mock_response = mock_post.return_value
mock_response.raise_for_status.return_value = None # No error mock_response.raise_for_status.return_value = None # No error
@@ -619,17 +613,65 @@ def test_services_search_indexers_search_nb_results(mock_post, indexer_settings)
visited = get_visited_document_ids_of(models.Document.objects.all(), user) visited = get_visited_document_ids_of(models.Document.objects.all(), user)
indexer.search("alpha", visited=visited, token="mytoken") indexer.search(q="alpha", visited=visited, token="mytoken")
args, kwargs = mock_post.call_args args, kwargs = mock_post.call_args
assert args[0] == indexer_settings.SEARCH_INDEXER_QUERY_URL assert args[0] == indexer_settings.SEARCH_URL
assert kwargs.get("json")["nb_results"] == 25 assert kwargs.get("json")["nb_results"] == 25
# The argument overrides the setting value # The argument overrides the setting value
indexer.search("alpha", visited=visited, token="mytoken", nb_results=109) indexer.search(q="alpha", visited=visited, token="mytoken", nb_results=109)
args, kwargs = mock_post.call_args args, kwargs = mock_post.call_args
assert args[0] == indexer_settings.SEARCH_INDEXER_QUERY_URL assert args[0] == indexer_settings.SEARCH_URL
assert kwargs.get("json")["nb_results"] == 109 assert kwargs.get("json")["nb_results"] == 109
def test_search_indexer_get_title_with_localized_field():
"""Test extracting title from localized title field."""
source = {"title.extension": "Bonjour", "id": 1, "content": "test"}
result = FindDocumentIndexer.get_title(source)
assert result == "Bonjour"
def test_search_indexer_get_title_with_multiple_localized_fields():
"""Test that first matching localized title is returned."""
source = {"title.extension": "Bonjour", "title.en": "Hello", "id": 1}
result = FindDocumentIndexer.get_title(source)
assert result in ["Bonjour", "Hello"]
def test_search_indexer_get_title_fallback_to_plain_title():
"""Test fallback to plain 'title' field when no localized field exists."""
source = {"title": "Hello World", "id": 1}
result = FindDocumentIndexer.get_title(source)
assert result == "Hello World"
def test_search_indexer_get_title_no_title_field():
"""Test that empty string is returned when no title field exists."""
source = {"id": 1, "content": "test"}
result = FindDocumentIndexer.get_title(source)
assert result == ""
def test_search_indexer_get_title_with_empty_localized_title():
"""Test that fallback works when localized title is empty."""
source = {"title.extension": "", "title": "Fallback Title", "id": 1}
result = FindDocumentIndexer.get_title(source)
assert result == "Fallback Title"
def test_search_indexer_get_title_with_multiple_extension():
"""Test extracting title from title field with multiple extensions."""
source = {"title.extension_1.extension_2": "Bonjour", "id": 1, "content": "test"}
result = FindDocumentIndexer.get_title(source)
assert result == "Bonjour"

View File

@@ -28,3 +28,39 @@ def test_invalid_settings_oidc_email_configuration():
"Both OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION and " "Both OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION and "
"OIDC_ALLOW_DUPLICATE_EMAILS cannot be set to True simultaneously. " "OIDC_ALLOW_DUPLICATE_EMAILS cannot be set to True simultaneously. "
) )
def test_settings_psycopg_pool_not_enabled():
"""
Test that not changing DB_PSYCOPG_POOL_ENABLED should not configure psycopg in the DATABASES
settings.
"""
class TestSettings(Base):
"""Fake test settings without enabling psycopg"""
TestSettings.post_setup()
assert TestSettings.DATABASES["default"].get("OPTIONS") == {}
def test_settings_psycopg_pool_enabled(monkeypatch):
"""
Test when DB_PSYCOPG_POOL_ENABLED is set to True, the psycopg pool options should be present
in the DATABASES OPTIONS.
"""
monkeypatch.setenv("DB_PSYCOPG_POOL_ENABLED", "True")
class TestSettings(Base):
"""Fake test settings without enabling psycopg"""
TestSettings.post_setup()
assert TestSettings.DATABASES["default"].get("OPTIONS") == {
"pool": {
"min_size": 4,
"max_size": None,
"timeout": 3,
}
}

View File

@@ -205,3 +205,38 @@ def test_utils_users_sharing_documents_with_empty_result():
cached_data = cache.get(cache_key) cached_data = cache.get(cache_key)
assert cached_data == {} assert cached_data == {}
def test_utils_get_value_by_pattern_matching_key():
"""Test extracting value from a dictionary with a matching key pattern."""
data = {"title.extension": "Bonjour", "id": 1, "content": "test"}
result = utils.get_value_by_pattern(data, r"^title\.")
assert set(result) == {"Bonjour"}
def test_utils_get_value_by_pattern_multiple_matches():
"""Test that all matching keys are returned."""
data = {"title.extension_1": "Bonjour", "title.extension_2": "Hello", "id": 1}
result = utils.get_value_by_pattern(data, r"^title\.")
assert set(result) == {
"Bonjour",
"Hello",
}
def test_utils_get_value_by_pattern_multiple_extensions():
"""Test that all matching keys are returned."""
data = {"title.extension_1.extension_2": "Bonjour", "id": 1}
result = utils.get_value_by_pattern(data, r"^title\.")
assert set(result) == {"Bonjour"}
def test_utils_get_value_by_pattern_no_match():
"""Test that empty list is returned when no key matches the pattern."""
data = {"name": "Test", "id": 1}
result = utils.get_value_by_pattern(data, r"^title\.")
assert result == []

View File

@@ -0,0 +1,20 @@
"""Utils for testing URLs."""
import importlib
from django.urls import clear_url_caches
def reload_urls():
"""
Reload the URLs. Since the URLs are loaded based on a
settings value, we need to reload them to make the
URL settings based condition effective.
"""
import core.urls # pylint:disable=import-outside-toplevel # noqa: PLC0415
import impress.urls # pylint:disable=import-outside-toplevel # noqa: PLC0415
importlib.reload(core.urls)
importlib.reload(impress.urls)
clear_url_caches()

View File

@@ -7,6 +7,7 @@ from lasuite.oidc_login.urls import urlpatterns as oidc_urls
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from core.api import viewsets from core.api import viewsets
from core.external_api import viewsets as external_api_viewsets
# - Main endpoints # - Main endpoints
router = DefaultRouter() router = DefaultRouter()
@@ -43,6 +44,19 @@ thread_related_router.register(
basename="comments", basename="comments",
) )
# - Resource server routes
external_api_router = DefaultRouter()
external_api_router.register(
"documents",
external_api_viewsets.ResourceServerDocumentViewSet,
basename="resource_server_documents",
)
external_api_router.register(
"users",
external_api_viewsets.ResourceServerUserViewSet,
basename="resource_server_users",
)
urlpatterns = [ urlpatterns = [
path( path(
@@ -68,3 +82,38 @@ urlpatterns = [
), ),
path(f"api/{settings.API_VERSION}/config/", viewsets.ConfigView.as_view()), path(f"api/{settings.API_VERSION}/config/", viewsets.ConfigView.as_view()),
] ]
if settings.OIDC_RESOURCE_SERVER_ENABLED:
# - Routes nested under a document in external API
external_api_document_related_router = DefaultRouter()
document_access_config = settings.EXTERNAL_API.get("document_access", {})
if document_access_config.get("enabled", False):
external_api_document_related_router.register(
"accesses",
external_api_viewsets.ResourceServerDocumentAccessViewSet,
basename="resource_server_document_accesses",
)
document_invitation_config = settings.EXTERNAL_API.get("document_invitation", {})
if document_invitation_config.get("enabled", False):
external_api_document_related_router.register(
"invitations",
external_api_viewsets.ResourceServerInvitationViewSet,
basename="resource_server_document_invitations",
)
urlpatterns.append(
path(
f"external_api/{settings.API_VERSION}/",
include(
[
*external_api_router.urls,
re_path(
r"^documents/(?P<resource_id>[0-9a-z-]*)/",
include(external_api_document_related_router.urls),
),
]
),
)
)

View File

@@ -18,6 +18,27 @@ from core import enums, models
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def get_value_by_pattern(data, pattern):
"""
Get all values from keys matching a regex pattern in a dictionary.
Args:
data (dict): Source dictionary to search
pattern (str): Regex pattern to match against keys
Returns:
list: List of values for all matching keys, empty list if no matches
Example:
>>> get_value_by_pattern({"title.fr": "Bonjour", "id": 1}, r"^title\\.")
["Bonjour"]
>>> get_value_by_pattern({"title.fr": "Bonjour", "title.en": "Hello"}, r"^title\\.")
["Bonjour", "Hello"]
"""
regex = re.compile(pattern)
return [value for key, value in data.items() if regex.match(key)]
def get_ancestor_to_descendants_map(paths, steplen): def get_ancestor_to_descendants_map(paths, steplen):
""" """
Given a list of document paths, return a mapping of ancestor_path -> set of descendant_paths. Given a list of document paths, return a mapping of ancestor_path -> set of descendant_paths.

View File

@@ -138,6 +138,7 @@ def create_demo(stdout):
password="!", password="!",
is_superuser=False, is_superuser=False,
is_active=True, is_active=True,
is_first_connection=False,
is_staff=False, is_staff=False,
short_name=first_name, short_name=first_name,
full_name=f"{first_name:s} {random.choice(last_names):s}", full_name=f"{first_name:s} {random.choice(last_names):s}",
@@ -194,6 +195,7 @@ def create_demo(stdout):
password="!", password="!",
is_superuser=False, is_superuser=False,
is_active=True, is_active=True,
is_first_connection=False,
is_staff=False, is_staff=False,
language=dev_user["language"] or random.choice(languages), language=dev_user["language"] or random.choice(languages),
) )

View File

@@ -158,5 +158,9 @@
"href": "/assets/favicon-dark.png", "href": "/assets/favicon-dark.png",
"type": "image/png" "type": "image/png"
} }
},
"onboarding": {
"enabled": true,
"learn_more_url": ""
} }
} }

View File

@@ -99,6 +99,7 @@ class Base(Configuration):
"localhost", environ_name="DB_HOST", environ_prefix=None "localhost", environ_name="DB_HOST", environ_prefix=None
), ),
"PORT": values.Value(5432, environ_name="DB_PORT", environ_prefix=None), "PORT": values.Value(5432, environ_name="DB_PORT", environ_prefix=None),
# Psycopg pool can be configured in the post_setup method
} }
} }
DEFAULT_AUTO_FIELD = "django.db.models.AutoField" DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
@@ -112,8 +113,8 @@ class Base(Configuration):
SEARCH_INDEXER_BATCH_SIZE = values.IntegerValue( SEARCH_INDEXER_BATCH_SIZE = values.IntegerValue(
default=100_000, environ_name="SEARCH_INDEXER_BATCH_SIZE", environ_prefix=None default=100_000, environ_name="SEARCH_INDEXER_BATCH_SIZE", environ_prefix=None
) )
SEARCH_INDEXER_URL = values.Value( INDEXING_URL = values.Value(
default=None, environ_name="SEARCH_INDEXER_URL", environ_prefix=None default=None, environ_name="INDEXING_URL", environ_prefix=None
) )
SEARCH_INDEXER_COUNTDOWN = values.IntegerValue( SEARCH_INDEXER_COUNTDOWN = values.IntegerValue(
default=1, environ_name="SEARCH_INDEXER_COUNTDOWN", environ_prefix=None default=1, environ_name="SEARCH_INDEXER_COUNTDOWN", environ_prefix=None
@@ -121,8 +122,8 @@ class Base(Configuration):
SEARCH_INDEXER_SECRET = values.Value( SEARCH_INDEXER_SECRET = values.Value(
default=None, environ_name="SEARCH_INDEXER_SECRET", environ_prefix=None default=None, environ_name="SEARCH_INDEXER_SECRET", environ_prefix=None
) )
SEARCH_INDEXER_QUERY_URL = values.Value( SEARCH_URL = values.Value(
default=None, environ_name="SEARCH_INDEXER_QUERY_URL", environ_prefix=None default=None, environ_name="SEARCH_URL", environ_prefix=None
) )
SEARCH_INDEXER_QUERY_LIMIT = values.PositiveIntegerValue( SEARCH_INDEXER_QUERY_LIMIT = values.PositiveIntegerValue(
default=50, environ_name="SEARCH_INDEXER_QUERY_LIMIT", environ_prefix=None default=50, environ_name="SEARCH_INDEXER_QUERY_LIMIT", environ_prefix=None
@@ -330,6 +331,7 @@ class Base(Configuration):
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"dockerflow.django.middleware.DockerflowMiddleware", "dockerflow.django.middleware.DockerflowMiddleware",
"csp.middleware.CSPMiddleware", "csp.middleware.CSPMiddleware",
"waffle.middleware.WaffleMiddleware",
] ]
AUTHENTICATION_BACKENDS = [ AUTHENTICATION_BACKENDS = [
@@ -351,6 +353,7 @@ class Base(Configuration):
"parler", "parler",
"treebeard", "treebeard",
"easy_thumbnails", "easy_thumbnails",
"waffle",
# Django # Django
"django.contrib.admin", "django.contrib.admin",
"django.contrib.auth", "django.contrib.auth",
@@ -684,6 +687,109 @@ class Base(Configuration):
environ_prefix=None, environ_prefix=None,
) )
# OIDC Resource Server
OIDC_RESOURCE_SERVER_ENABLED = values.BooleanValue(
default=False, environ_name="OIDC_RESOURCE_SERVER_ENABLED", environ_prefix=None
)
OIDC_RS_BACKEND_CLASS = values.Value(
"lasuite.oidc_resource_server.backend.ResourceServerBackend",
environ_name="OIDC_RS_BACKEND_CLASS",
environ_prefix=None,
)
OIDC_OP_URL = values.Value(None, environ_name="OIDC_OP_URL", environ_prefix=None)
OIDC_VERIFY_SSL = values.BooleanValue(
default=True, environ_name="OIDC_VERIFY_SSL", environ_prefix=None
)
OIDC_TIMEOUT = values.PositiveIntegerValue(
3, environ_name="OIDC_TIMEOUT", environ_prefix=None
)
OIDC_PROXY = values.Value(None, environ_name="OIDC_PROXY", environ_prefix=None)
OIDC_OP_INTROSPECTION_ENDPOINT = values.Value(
None, environ_name="OIDC_OP_INTROSPECTION_ENDPOINT", environ_prefix=None
)
OIDC_RS_CLIENT_ID = values.Value(
None, environ_name="OIDC_RS_CLIENT_ID", environ_prefix=None
)
OIDC_RS_CLIENT_SECRET = values.Value(
None, environ_name="OIDC_RS_CLIENT_SECRET", environ_prefix=None
)
OIDC_RS_AUDIENCE_CLAIM = values.Value(
"client_id", environ_name="OIDC_RS_AUDIENCE_CLAIM", environ_prefix=None
)
OIDC_RS_ENCRYPTION_ENCODING = values.Value(
"A256GCM", environ_name="OIDC_RS_ENCRYPTION_ENCODING", environ_prefix=None
)
OIDC_RS_ENCRYPTION_ALGO = values.Value(
"RSA-OAEP", environ_name="OIDC_RS_ENCRYPTION_ALGO", environ_prefix=None
)
OIDC_RS_SIGNING_ALGO = values.Value(
"ES256", environ_name="OIDC_RS_SIGNING_ALGO", environ_prefix=None
)
OIDC_RS_SCOPES = values.ListValue(
["openid"], environ_name="OIDC_RS_SCOPES", environ_prefix=None
)
OIDC_RS_ALLOWED_AUDIENCES = values.ListValue(
default=[],
environ_name="OIDC_RS_ALLOWED_AUDIENCES",
environ_prefix=None,
)
OIDC_RS_PRIVATE_KEY_STR = values.Value(
default=None,
environ_name="OIDC_RS_PRIVATE_KEY_STR",
environ_prefix=None,
)
OIDC_RS_ENCRYPTION_KEY_TYPE = values.Value(
default="RSA",
environ_name="OIDC_RS_ENCRYPTION_KEY_TYPE",
environ_prefix=None,
)
# External API Configuration
# Configure available routes and actions for external_api endpoints
EXTERNAL_API = values.DictValue(
default={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"create",
"children",
],
},
"document_access": {
"enabled": False,
"actions": [],
},
"document_invitation": {
"enabled": False,
"actions": [],
},
"users": {
"enabled": True,
"actions": ["get_me"],
},
},
environ_name="EXTERNAL_API",
environ_prefix=None,
)
ALLOW_LOGOUT_GET_METHOD = values.BooleanValue( ALLOW_LOGOUT_GET_METHOD = values.BooleanValue(
default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None
) )
@@ -999,6 +1105,36 @@ class Base(Configuration):
"OIDC_ALLOW_DUPLICATE_EMAILS cannot be set to True simultaneously. " "OIDC_ALLOW_DUPLICATE_EMAILS cannot be set to True simultaneously. "
) )
psycopg_pool_enabled = values.BooleanValue(
False, environ_name="DB_PSYCOPG_POOL_ENABLED", environ_prefix=""
)
if psycopg_pool_enabled:
cls.DATABASES["default"].update(
{
"OPTIONS": {
# https://www.psycopg.org/psycopg3/docs/api/pool.html#psycopg_pool.ConnectionPool
"pool": {
"min_size": values.IntegerValue(
4,
environ_name="DB_PSYCOPG_POOL_MIN_SIZE",
environ_prefix=None,
),
"max_size": values.IntegerValue(
None,
environ_name="DB_PSYCOPG_POOL_MAX_SIZE",
environ_prefix=None,
),
"timeout": values.IntegerValue(
3,
environ_name="DB_PSYCOPG_POOL_TIMEOUT",
environ_prefix=None,
),
}
},
}
)
class Build(Base): class Build(Base):
"""Settings used when the application is built. """Settings used when the application is built.

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: lasuite-docs\n" "Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-06 09:37+0000\n" "POT-Creation-Date: 2026-03-12 13:31+0000\n"
"PO-Revision-Date: 2026-03-09 14:02\n" "PO-Revision-Date: 2026-03-13 16:53\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Breton\n" "Language-Team: Breton\n"
"Language: br_FR\n" "Language: br_FR\n"
@@ -58,24 +58,24 @@ msgstr "Kuzhet"
msgid "Favorite" msgid "Favorite"
msgstr "Sinedoù" msgstr "Sinedoù"
#: build/lib/core/api/serializers.py:513 core/api/serializers.py:513 #: build/lib/core/api/serializers.py:526 core/api/serializers.py:526
msgid "A new document was created on your behalf!" msgid "A new document was created on your behalf!"
msgstr "Ur restr nevez a zo bet krouet ganeoc'h!" msgstr "Ur restr nevez a zo bet krouet ganeoc'h!"
#: build/lib/core/api/serializers.py:517 core/api/serializers.py:517 #: build/lib/core/api/serializers.py:530 core/api/serializers.py:530
msgid "You have been granted ownership of a new document:" msgid "You have been granted ownership of a new document:"
msgstr "C'hwi zo bet disklaeriet perc'henn ur restr nevez:" msgstr "C'hwi zo bet disklaeriet perc'henn ur restr nevez:"
#: build/lib/core/api/serializers.py:553 core/api/serializers.py:553 #: build/lib/core/api/serializers.py:566 core/api/serializers.py:566
msgid "This field is required." msgid "This field is required."
msgstr "Ar vaezienn-mañ a zo rekis." msgstr "Ar vaezienn-mañ a zo rekis."
#: build/lib/core/api/serializers.py:564 core/api/serializers.py:564 #: build/lib/core/api/serializers.py:577 core/api/serializers.py:577
#, python-format #, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration." msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr "" msgstr ""
#: build/lib/core/api/viewsets.py:1279 core/api/viewsets.py:1279 #: build/lib/core/api/viewsets.py:1298 core/api/viewsets.py:1298
#, python-brace-format #, python-brace-format
msgid "copy of {title}" msgid "copy of {title}"
msgstr "eilenn {title}" msgstr "eilenn {title}"
@@ -231,106 +231,114 @@ msgstr "oberiant"
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts." msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Ma rank bezañ tretet an implijer-mañ evel oberiant. Diziuzit an dra-mañ e-plas dilemel kontoù." msgstr "Ma rank bezañ tretet an implijer-mañ evel oberiant. Diziuzit an dra-mañ e-plas dilemel kontoù."
#: build/lib/core/models.py:204 core/models.py:204 #: build/lib/core/models.py:197 core/models.py:197
msgid "first connection status"
msgstr ""
#: build/lib/core/models.py:199 core/models.py:199
msgid "Whether the user has completed the first connection process."
msgstr ""
#: build/lib/core/models.py:209 core/models.py:209
msgid "user" msgid "user"
msgstr "implijer" msgstr "implijer"
#: build/lib/core/models.py:205 core/models.py:205 #: build/lib/core/models.py:210 core/models.py:210
msgid "users" msgid "users"
msgstr "implijerien" msgstr "implijerien"
#: build/lib/core/models.py:360 core/models.py:360 #: build/lib/core/models.py:378 core/models.py:378
msgid "Active email address" msgid "Active email address"
msgstr "" msgstr ""
#: build/lib/core/models.py:361 core/models.py:361 #: build/lib/core/models.py:379 core/models.py:379
msgid "Email address to deactivate" msgid "Email address to deactivate"
msgstr "" msgstr ""
#: build/lib/core/models.py:388 core/models.py:388 #: build/lib/core/models.py:406 core/models.py:406
msgid "Unique ID in the source file" msgid "Unique ID in the source file"
msgstr "" msgstr ""
#: build/lib/core/models.py:394 build/lib/core/models.py:692 core/models.py:394 #: build/lib/core/models.py:412 build/lib/core/models.py:710 core/models.py:412
#: core/models.py:692 #: core/models.py:710
msgid "Pending" msgid "Pending"
msgstr "" msgstr ""
#: build/lib/core/models.py:395 core/models.py:395 #: build/lib/core/models.py:413 core/models.py:413
msgid "Ready" msgid "Ready"
msgstr "" msgstr ""
#: build/lib/core/models.py:396 build/lib/core/models.py:694 core/models.py:396 #: build/lib/core/models.py:414 build/lib/core/models.py:712 core/models.py:414
#: core/models.py:694 #: core/models.py:712
msgid "Done" msgid "Done"
msgstr "" msgstr ""
#: build/lib/core/models.py:397 build/lib/core/models.py:695 core/models.py:397 #: build/lib/core/models.py:415 build/lib/core/models.py:713 core/models.py:415
#: core/models.py:695 #: core/models.py:713
msgid "Error" msgid "Error"
msgstr "" msgstr ""
#: build/lib/core/models.py:405 core/models.py:405 #: build/lib/core/models.py:423 core/models.py:423
msgid "user reconciliation" msgid "user reconciliation"
msgstr "" msgstr ""
#: build/lib/core/models.py:406 core/models.py:406 #: build/lib/core/models.py:424 core/models.py:424
msgid "user reconciliations" msgid "user reconciliations"
msgstr "" msgstr ""
#: build/lib/core/models.py:644 core/models.py:644 #: build/lib/core/models.py:662 core/models.py:662
msgid "You have requested a reconciliation of your user accounts on Docs.\n" msgid "You have requested a reconciliation of your user accounts on Docs.\n"
" To confirm that you are the one who initiated the request\n" " To confirm that you are the one who initiated the request\n"
" and that this email belongs to you:" " and that this email belongs to you:"
msgstr "" msgstr ""
#: build/lib/core/models.py:650 core/models.py:650 #: build/lib/core/models.py:668 core/models.py:668
msgid "Confirm by clicking the link to start the reconciliation" msgid "Confirm by clicking the link to start the reconciliation"
msgstr "" msgstr ""
#: build/lib/core/models.py:655 build/lib/core/models.py:761 core/models.py:655 #: build/lib/core/models.py:673 build/lib/core/models.py:779 core/models.py:673
#: core/models.py:761 #: core/models.py:779
msgid "Click here" msgid "Click here"
msgstr "" msgstr ""
#: build/lib/core/models.py:656 core/models.py:656 #: build/lib/core/models.py:674 core/models.py:674
msgid "Confirm" msgid "Confirm"
msgstr "" msgstr ""
#: build/lib/core/models.py:667 core/models.py:667 #: build/lib/core/models.py:685 core/models.py:685
msgid "Your reconciliation request has been processed.\n" msgid "Your reconciliation request has been processed.\n"
" New documents are likely associated with your account:" " New documents are likely associated with your account:"
msgstr "" msgstr ""
#: build/lib/core/models.py:672 core/models.py:672 #: build/lib/core/models.py:690 core/models.py:690
msgid "Your accounts have been merged" msgid "Your accounts have been merged"
msgstr "" msgstr ""
#: build/lib/core/models.py:677 core/models.py:677 #: build/lib/core/models.py:695 core/models.py:695
msgid "Click here to see" msgid "Click here to see"
msgstr "" msgstr ""
#: build/lib/core/models.py:678 core/models.py:678 #: build/lib/core/models.py:696 core/models.py:696
msgid "See my documents" msgid "See my documents"
msgstr "" msgstr ""
#: build/lib/core/models.py:688 core/models.py:688 #: build/lib/core/models.py:706 core/models.py:706
msgid "CSV file" msgid "CSV file"
msgstr "" msgstr ""
#: build/lib/core/models.py:693 core/models.py:693 #: build/lib/core/models.py:711 core/models.py:711
msgid "Running" msgid "Running"
msgstr "" msgstr ""
#: build/lib/core/models.py:703 core/models.py:703 #: build/lib/core/models.py:721 core/models.py:721
msgid "user reconciliation CSV import" msgid "user reconciliation CSV import"
msgstr "" msgstr ""
#: build/lib/core/models.py:704 core/models.py:704 #: build/lib/core/models.py:722 core/models.py:722
msgid "user reconciliation CSV imports" msgid "user reconciliation CSV imports"
msgstr "" msgstr ""
#: build/lib/core/models.py:748 core/models.py:748 #: build/lib/core/models.py:766 core/models.py:766
#, python-brace-format #, python-brace-format
msgid "Your request for reconciliation was unsuccessful.\n" msgid "Your request for reconciliation was unsuccessful.\n"
" Reconciliation failed for the following email addresses:\n" " Reconciliation failed for the following email addresses:\n"
@@ -339,171 +347,171 @@ msgid "Your request for reconciliation was unsuccessful.\n"
" You can submit another request with the valid email addresses." " You can submit another request with the valid email addresses."
msgstr "" msgstr ""
#: build/lib/core/models.py:756 core/models.py:756 #: build/lib/core/models.py:774 core/models.py:774
msgid "Reconciliation of your Docs accounts not completed" msgid "Reconciliation of your Docs accounts not completed"
msgstr "" msgstr ""
#: build/lib/core/models.py:762 core/models.py:762 #: build/lib/core/models.py:780 core/models.py:780
msgid "Make a new request" msgid "Make a new request"
msgstr "" msgstr ""
#: build/lib/core/models.py:861 core/models.py:861 #: build/lib/core/models.py:879 core/models.py:879
msgid "title" msgid "title"
msgstr "titl" msgstr "titl"
#: build/lib/core/models.py:862 core/models.py:862 #: build/lib/core/models.py:880 core/models.py:880
msgid "excerpt" msgid "excerpt"
msgstr "bomm" msgstr "bomm"
#: build/lib/core/models.py:911 core/models.py:911 #: build/lib/core/models.py:929 core/models.py:929
msgid "Document" msgid "Document"
msgstr "Restr" msgstr "Restr"
#: build/lib/core/models.py:912 core/models.py:912 #: build/lib/core/models.py:930 core/models.py:930
msgid "Documents" msgid "Documents"
msgstr "Restroù" msgstr "Restroù"
#: build/lib/core/models.py:924 build/lib/core/models.py:1328 #: build/lib/core/models.py:942 build/lib/core/models.py:1346
#: core/models.py:924 core/models.py:1328 #: core/models.py:942 core/models.py:1346
msgid "Untitled Document" msgid "Untitled Document"
msgstr "Restr hep titl" msgstr "Restr hep titl"
#: build/lib/core/models.py:1329 core/models.py:1329 #: build/lib/core/models.py:1347 core/models.py:1347
msgid "Open" msgid "Open"
msgstr "Digeriñ" msgstr "Digeriñ"
#: build/lib/core/models.py:1364 core/models.py:1364 #: build/lib/core/models.py:1382 core/models.py:1382
#, python-brace-format #, python-brace-format
msgid "{name} shared a document with you!" msgid "{name} shared a document with you!"
msgstr "{name} en deus rannet ur restr ganeoc'h!" msgstr "{name} en deus rannet ur restr ganeoc'h!"
#: build/lib/core/models.py:1368 core/models.py:1368 #: build/lib/core/models.py:1386 core/models.py:1386
#, python-brace-format #, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:" 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:" msgstr "{name} en deus pedet ac'hanoc'h gant ar rol \"{role}\" war ar restr da-heul:"
#: build/lib/core/models.py:1374 core/models.py:1374 #: build/lib/core/models.py:1392 core/models.py:1392
#, python-brace-format #, python-brace-format
msgid "{name} shared a document with you: {title}" msgid "{name} shared a document with you: {title}"
msgstr "{name} en deus rannet ur restr ganeoc'h: {title}" msgstr "{name} en deus rannet ur restr ganeoc'h: {title}"
#: build/lib/core/models.py:1475 core/models.py:1475 #: build/lib/core/models.py:1493 core/models.py:1493
msgid "Document/user link trace" msgid "Document/user link trace"
msgstr "Roud liamm ar restr/an implijer" msgstr "Roud liamm ar restr/an implijer"
#: build/lib/core/models.py:1476 core/models.py:1476 #: build/lib/core/models.py:1494 core/models.py:1494
msgid "Document/user link traces" msgid "Document/user link traces"
msgstr "Roudoù liamm ar restr/an implijer" msgstr "Roudoù liamm ar restr/an implijer"
#: build/lib/core/models.py:1482 core/models.py:1482 #: build/lib/core/models.py:1500 core/models.py:1500
msgid "A link trace already exists for this document/user." msgid "A link trace already exists for this document/user."
msgstr "Ur roud liamm a zo dija evit an restr/an implijer." msgstr "Ur roud liamm a zo dija evit an restr/an implijer."
#: build/lib/core/models.py:1505 core/models.py:1505 #: build/lib/core/models.py:1523 core/models.py:1523
msgid "Document favorite" msgid "Document favorite"
msgstr "Restr muiañ-karet" msgstr "Restr muiañ-karet"
#: build/lib/core/models.py:1506 core/models.py:1506 #: build/lib/core/models.py:1524 core/models.py:1524
msgid "Document favorites" msgid "Document favorites"
msgstr "Restroù muiañ-karet" msgstr "Restroù muiañ-karet"
#: build/lib/core/models.py:1512 core/models.py:1512 #: build/lib/core/models.py:1530 core/models.py:1530
msgid "This document is already targeted by a favorite relation instance for the same user." 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ñ." msgstr "Ar restr-mañ a zo ur restr muiañ karet gant an implijer-mañ."
#: build/lib/core/models.py:1534 core/models.py:1534 #: build/lib/core/models.py:1552 core/models.py:1552
msgid "Document/user relation" msgid "Document/user relation"
msgstr "Liamm restr/implijer" msgstr "Liamm restr/implijer"
#: build/lib/core/models.py:1535 core/models.py:1535 #: build/lib/core/models.py:1553 core/models.py:1553
msgid "Document/user relations" msgid "Document/user relations"
msgstr "Liammoù restr/implijer" msgstr "Liammoù restr/implijer"
#: build/lib/core/models.py:1541 core/models.py:1541 #: build/lib/core/models.py:1559 core/models.py:1559
msgid "This user is already in this document." msgid "This user is already in this document."
msgstr "An implijer-mañ a zo dija er restr-mañ." msgstr "An implijer-mañ a zo dija er restr-mañ."
#: build/lib/core/models.py:1547 core/models.py:1547 #: build/lib/core/models.py:1565 core/models.py:1565
msgid "This team is already in this document." msgid "This team is already in this document."
msgstr "Ar skipailh-mañ a zo dija en restr-mañ." msgstr "Ar skipailh-mañ a zo dija en restr-mañ."
#: build/lib/core/models.py:1553 core/models.py:1553 #: build/lib/core/models.py:1571 core/models.py:1571
msgid "Either user or team must be set, not both." msgid "Either user or team must be set, not both."
msgstr "An implijer pe ar skipailh a rank bezañ termenet, ket an daou avat." msgstr "An implijer pe ar skipailh a rank bezañ termenet, ket an daou avat."
#: build/lib/core/models.py:1704 core/models.py:1704 #: build/lib/core/models.py:1722 core/models.py:1722
msgid "Document ask for access" msgid "Document ask for access"
msgstr "Goulenn tizhout ar restr" msgstr "Goulenn tizhout ar restr"
#: build/lib/core/models.py:1705 core/models.py:1705 #: build/lib/core/models.py:1723 core/models.py:1723
msgid "Document ask for accesses" msgid "Document ask for accesses"
msgstr "Goulennoù tizhout ar restr" msgstr "Goulennoù tizhout ar restr"
#: build/lib/core/models.py:1711 core/models.py:1711 #: build/lib/core/models.py:1729 core/models.py:1729
msgid "This user has already asked for access to this document." msgid "This user has already asked for access to this document."
msgstr "An implijer en deus goulennet tizhout ar restr-mañ." msgstr "An implijer en deus goulennet tizhout ar restr-mañ."
#: build/lib/core/models.py:1768 core/models.py:1768 #: build/lib/core/models.py:1786 core/models.py:1786
#, python-brace-format #, python-brace-format
msgid "{name} would like access to a document!" msgid "{name} would like access to a document!"
msgstr "{name} en defe c'hoant da dizhout ar restr-mañ!" msgstr "{name} en defe c'hoant da dizhout ar restr-mañ!"
#: build/lib/core/models.py:1772 core/models.py:1772 #: build/lib/core/models.py:1790 core/models.py:1790
#, python-brace-format #, python-brace-format
msgid "{name} would like access to the following document:" msgid "{name} would like access to the following document:"
msgstr "{name} en defe c'hoant da dizhout ar restr da-heul:" msgstr "{name} en defe c'hoant da dizhout ar restr da-heul:"
#: build/lib/core/models.py:1778 core/models.py:1778 #: build/lib/core/models.py:1796 core/models.py:1796
#, python-brace-format #, python-brace-format
msgid "{name} is asking for access to the document: {title}" msgid "{name} is asking for access to the document: {title}"
msgstr "{name} en defe c'hoant da dizhout ar restr: {title}" msgstr "{name} en defe c'hoant da dizhout ar restr: {title}"
#: build/lib/core/models.py:1820 core/models.py:1820 #: build/lib/core/models.py:1838 core/models.py:1838
msgid "Thread" msgid "Thread"
msgstr "" msgstr ""
#: build/lib/core/models.py:1821 core/models.py:1821 #: build/lib/core/models.py:1839 core/models.py:1839
msgid "Threads" msgid "Threads"
msgstr "" msgstr ""
#: build/lib/core/models.py:1824 build/lib/core/models.py:1876 #: build/lib/core/models.py:1842 build/lib/core/models.py:1894
#: core/models.py:1824 core/models.py:1876 #: core/models.py:1842 core/models.py:1894
msgid "Anonymous" msgid "Anonymous"
msgstr "" msgstr ""
#: build/lib/core/models.py:1871 core/models.py:1871 #: build/lib/core/models.py:1889 core/models.py:1889
msgid "Comment" msgid "Comment"
msgstr "" msgstr ""
#: build/lib/core/models.py:1872 core/models.py:1872 #: build/lib/core/models.py:1890 core/models.py:1890
msgid "Comments" msgid "Comments"
msgstr "" msgstr ""
#: build/lib/core/models.py:1921 core/models.py:1921 #: build/lib/core/models.py:1939 core/models.py:1939
msgid "This emoji has already been reacted to this comment." msgid "This emoji has already been reacted to this comment."
msgstr "" msgstr ""
#: build/lib/core/models.py:1925 core/models.py:1925 #: build/lib/core/models.py:1943 core/models.py:1943
msgid "Reaction" msgid "Reaction"
msgstr "" msgstr ""
#: build/lib/core/models.py:1926 core/models.py:1926 #: build/lib/core/models.py:1944 core/models.py:1944
msgid "Reactions" msgid "Reactions"
msgstr "" msgstr ""
#: build/lib/core/models.py:1936 core/models.py:1936 #: build/lib/core/models.py:1954 core/models.py:1954
msgid "email address" msgid "email address"
msgstr "postel" msgstr "postel"
#: build/lib/core/models.py:1955 core/models.py:1955 #: build/lib/core/models.py:1973 core/models.py:1973
msgid "Document invitation" msgid "Document invitation"
msgstr "Pedadenn d'ur restr" msgstr "Pedadenn d'ur restr"
#: build/lib/core/models.py:1956 core/models.py:1956 #: build/lib/core/models.py:1974 core/models.py:1974
msgid "Document invitations" msgid "Document invitations"
msgstr "Pedadennoù d'ur restr" msgstr "Pedadennoù d'ur restr"
#: build/lib/core/models.py:1976 core/models.py:1976 #: build/lib/core/models.py:1994 core/models.py:1994
msgid "This email is already associated to a registered user." msgid "This email is already associated to a registered user."
msgstr "Ar postel-mañ a zo liammet ouzh un implijer enskrivet." msgstr "Ar postel-mañ a zo liammet ouzh un implijer enskrivet."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: lasuite-docs\n" "Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-06 09:37+0000\n" "POT-Creation-Date: 2026-03-12 13:31+0000\n"
"PO-Revision-Date: 2026-03-09 14:02\n" "PO-Revision-Date: 2026-03-13 16:53\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: German\n" "Language-Team: German\n"
"Language: de_DE\n" "Language: de_DE\n"
@@ -58,24 +58,24 @@ msgstr ""
msgid "Favorite" msgid "Favorite"
msgstr "Favorit" msgstr "Favorit"
#: build/lib/core/api/serializers.py:513 core/api/serializers.py:513 #: build/lib/core/api/serializers.py:526 core/api/serializers.py:526
msgid "A new document was created on your behalf!" msgid "A new document was created on your behalf!"
msgstr "Ein neues Dokument wurde in Ihrem Namen erstellt!" msgstr "Ein neues Dokument wurde in Ihrem Namen erstellt!"
#: build/lib/core/api/serializers.py:517 core/api/serializers.py:517 #: build/lib/core/api/serializers.py:530 core/api/serializers.py:530
msgid "You have been granted ownership of a new document:" msgid "You have been granted ownership of a new document:"
msgstr "Sie sind Besitzer eines neuen Dokuments:" msgstr "Sie sind Besitzer eines neuen Dokuments:"
#: build/lib/core/api/serializers.py:553 core/api/serializers.py:553 #: build/lib/core/api/serializers.py:566 core/api/serializers.py:566
msgid "This field is required." msgid "This field is required."
msgstr "" msgstr ""
#: build/lib/core/api/serializers.py:564 core/api/serializers.py:564 #: build/lib/core/api/serializers.py:577 core/api/serializers.py:577
#, python-format #, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration." msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr "" msgstr ""
#: build/lib/core/api/viewsets.py:1279 core/api/viewsets.py:1279 #: build/lib/core/api/viewsets.py:1298 core/api/viewsets.py:1298
#, python-brace-format #, python-brace-format
msgid "copy of {title}" msgid "copy of {title}"
msgstr "Kopie von {title}" msgstr "Kopie von {title}"
@@ -231,106 +231,114 @@ msgstr "aktiviert"
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts." msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Ob dieser Benutzer als aktiviert behandelt werden soll. Deaktivieren Sie diese Option, anstatt Konten zu löschen." msgstr "Ob dieser Benutzer als aktiviert behandelt werden soll. Deaktivieren Sie diese Option, anstatt Konten zu löschen."
#: build/lib/core/models.py:204 core/models.py:204 #: build/lib/core/models.py:197 core/models.py:197
msgid "first connection status"
msgstr ""
#: build/lib/core/models.py:199 core/models.py:199
msgid "Whether the user has completed the first connection process."
msgstr ""
#: build/lib/core/models.py:209 core/models.py:209
msgid "user" msgid "user"
msgstr "Benutzer" msgstr "Benutzer"
#: build/lib/core/models.py:205 core/models.py:205 #: build/lib/core/models.py:210 core/models.py:210
msgid "users" msgid "users"
msgstr "Benutzer" msgstr "Benutzer"
#: build/lib/core/models.py:360 core/models.py:360 #: build/lib/core/models.py:378 core/models.py:378
msgid "Active email address" msgid "Active email address"
msgstr "" msgstr ""
#: build/lib/core/models.py:361 core/models.py:361 #: build/lib/core/models.py:379 core/models.py:379
msgid "Email address to deactivate" msgid "Email address to deactivate"
msgstr "" msgstr ""
#: build/lib/core/models.py:388 core/models.py:388 #: build/lib/core/models.py:406 core/models.py:406
msgid "Unique ID in the source file" msgid "Unique ID in the source file"
msgstr "" msgstr ""
#: build/lib/core/models.py:394 build/lib/core/models.py:692 core/models.py:394 #: build/lib/core/models.py:412 build/lib/core/models.py:710 core/models.py:412
#: core/models.py:692 #: core/models.py:710
msgid "Pending" msgid "Pending"
msgstr "" msgstr ""
#: build/lib/core/models.py:395 core/models.py:395 #: build/lib/core/models.py:413 core/models.py:413
msgid "Ready" msgid "Ready"
msgstr "" msgstr ""
#: build/lib/core/models.py:396 build/lib/core/models.py:694 core/models.py:396 #: build/lib/core/models.py:414 build/lib/core/models.py:712 core/models.py:414
#: core/models.py:694 #: core/models.py:712
msgid "Done" msgid "Done"
msgstr "" msgstr ""
#: build/lib/core/models.py:397 build/lib/core/models.py:695 core/models.py:397 #: build/lib/core/models.py:415 build/lib/core/models.py:713 core/models.py:415
#: core/models.py:695 #: core/models.py:713
msgid "Error" msgid "Error"
msgstr "" msgstr ""
#: build/lib/core/models.py:405 core/models.py:405 #: build/lib/core/models.py:423 core/models.py:423
msgid "user reconciliation" msgid "user reconciliation"
msgstr "" msgstr ""
#: build/lib/core/models.py:406 core/models.py:406 #: build/lib/core/models.py:424 core/models.py:424
msgid "user reconciliations" msgid "user reconciliations"
msgstr "" msgstr ""
#: build/lib/core/models.py:644 core/models.py:644 #: build/lib/core/models.py:662 core/models.py:662
msgid "You have requested a reconciliation of your user accounts on Docs.\n" msgid "You have requested a reconciliation of your user accounts on Docs.\n"
" To confirm that you are the one who initiated the request\n" " To confirm that you are the one who initiated the request\n"
" and that this email belongs to you:" " and that this email belongs to you:"
msgstr "" msgstr ""
#: build/lib/core/models.py:650 core/models.py:650 #: build/lib/core/models.py:668 core/models.py:668
msgid "Confirm by clicking the link to start the reconciliation" msgid "Confirm by clicking the link to start the reconciliation"
msgstr "" msgstr ""
#: build/lib/core/models.py:655 build/lib/core/models.py:761 core/models.py:655 #: build/lib/core/models.py:673 build/lib/core/models.py:779 core/models.py:673
#: core/models.py:761 #: core/models.py:779
msgid "Click here" msgid "Click here"
msgstr "" msgstr ""
#: build/lib/core/models.py:656 core/models.py:656 #: build/lib/core/models.py:674 core/models.py:674
msgid "Confirm" msgid "Confirm"
msgstr "" msgstr ""
#: build/lib/core/models.py:667 core/models.py:667 #: build/lib/core/models.py:685 core/models.py:685
msgid "Your reconciliation request has been processed.\n" msgid "Your reconciliation request has been processed.\n"
" New documents are likely associated with your account:" " New documents are likely associated with your account:"
msgstr "" msgstr ""
#: build/lib/core/models.py:672 core/models.py:672 #: build/lib/core/models.py:690 core/models.py:690
msgid "Your accounts have been merged" msgid "Your accounts have been merged"
msgstr "" msgstr ""
#: build/lib/core/models.py:677 core/models.py:677 #: build/lib/core/models.py:695 core/models.py:695
msgid "Click here to see" msgid "Click here to see"
msgstr "" msgstr ""
#: build/lib/core/models.py:678 core/models.py:678 #: build/lib/core/models.py:696 core/models.py:696
msgid "See my documents" msgid "See my documents"
msgstr "" msgstr ""
#: build/lib/core/models.py:688 core/models.py:688 #: build/lib/core/models.py:706 core/models.py:706
msgid "CSV file" msgid "CSV file"
msgstr "" msgstr ""
#: build/lib/core/models.py:693 core/models.py:693 #: build/lib/core/models.py:711 core/models.py:711
msgid "Running" msgid "Running"
msgstr "" msgstr ""
#: build/lib/core/models.py:703 core/models.py:703 #: build/lib/core/models.py:721 core/models.py:721
msgid "user reconciliation CSV import" msgid "user reconciliation CSV import"
msgstr "" msgstr ""
#: build/lib/core/models.py:704 core/models.py:704 #: build/lib/core/models.py:722 core/models.py:722
msgid "user reconciliation CSV imports" msgid "user reconciliation CSV imports"
msgstr "" msgstr ""
#: build/lib/core/models.py:748 core/models.py:748 #: build/lib/core/models.py:766 core/models.py:766
#, python-brace-format #, python-brace-format
msgid "Your request for reconciliation was unsuccessful.\n" msgid "Your request for reconciliation was unsuccessful.\n"
" Reconciliation failed for the following email addresses:\n" " Reconciliation failed for the following email addresses:\n"
@@ -339,171 +347,171 @@ msgid "Your request for reconciliation was unsuccessful.\n"
" You can submit another request with the valid email addresses." " You can submit another request with the valid email addresses."
msgstr "" msgstr ""
#: build/lib/core/models.py:756 core/models.py:756 #: build/lib/core/models.py:774 core/models.py:774
msgid "Reconciliation of your Docs accounts not completed" msgid "Reconciliation of your Docs accounts not completed"
msgstr "" msgstr ""
#: build/lib/core/models.py:762 core/models.py:762 #: build/lib/core/models.py:780 core/models.py:780
msgid "Make a new request" msgid "Make a new request"
msgstr "" msgstr ""
#: build/lib/core/models.py:861 core/models.py:861 #: build/lib/core/models.py:879 core/models.py:879
msgid "title" msgid "title"
msgstr "Titel" msgstr "Titel"
#: build/lib/core/models.py:862 core/models.py:862 #: build/lib/core/models.py:880 core/models.py:880
msgid "excerpt" msgid "excerpt"
msgstr "Auszug" msgstr "Auszug"
#: build/lib/core/models.py:911 core/models.py:911 #: build/lib/core/models.py:929 core/models.py:929
msgid "Document" msgid "Document"
msgstr "Dokument" msgstr "Dokument"
#: build/lib/core/models.py:912 core/models.py:912 #: build/lib/core/models.py:930 core/models.py:930
msgid "Documents" msgid "Documents"
msgstr "Dokumente" msgstr "Dokumente"
#: build/lib/core/models.py:924 build/lib/core/models.py:1328 #: build/lib/core/models.py:942 build/lib/core/models.py:1346
#: core/models.py:924 core/models.py:1328 #: core/models.py:942 core/models.py:1346
msgid "Untitled Document" msgid "Untitled Document"
msgstr "Unbenanntes Dokument" msgstr "Unbenanntes Dokument"
#: build/lib/core/models.py:1329 core/models.py:1329 #: build/lib/core/models.py:1347 core/models.py:1347
msgid "Open" msgid "Open"
msgstr "Öffnen" msgstr "Öffnen"
#: build/lib/core/models.py:1364 core/models.py:1364 #: build/lib/core/models.py:1382 core/models.py:1382
#, python-brace-format #, python-brace-format
msgid "{name} shared a document with you!" msgid "{name} shared a document with you!"
msgstr "{name} hat ein Dokument mit Ihnen geteilt!" msgstr "{name} hat ein Dokument mit Ihnen geteilt!"
#: build/lib/core/models.py:1368 core/models.py:1368 #: build/lib/core/models.py:1386 core/models.py:1386
#, python-brace-format #, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:" msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} hat Sie mit der Rolle \"{role}\" zu folgendem Dokument eingeladen:" msgstr "{name} hat Sie mit der Rolle \"{role}\" zu folgendem Dokument eingeladen:"
#: build/lib/core/models.py:1374 core/models.py:1374 #: build/lib/core/models.py:1392 core/models.py:1392
#, python-brace-format #, python-brace-format
msgid "{name} shared a document with you: {title}" msgid "{name} shared a document with you: {title}"
msgstr "{name} hat ein Dokument mit Ihnen geteilt: {title}" msgstr "{name} hat ein Dokument mit Ihnen geteilt: {title}"
#: build/lib/core/models.py:1475 core/models.py:1475 #: build/lib/core/models.py:1493 core/models.py:1493
msgid "Document/user link trace" msgid "Document/user link trace"
msgstr "Dokument/Benutzer Linkverfolgung" msgstr "Dokument/Benutzer Linkverfolgung"
#: build/lib/core/models.py:1476 core/models.py:1476 #: build/lib/core/models.py:1494 core/models.py:1494
msgid "Document/user link traces" msgid "Document/user link traces"
msgstr "Dokument/Benutzer Linkverfolgung" msgstr "Dokument/Benutzer Linkverfolgung"
#: build/lib/core/models.py:1482 core/models.py:1482 #: build/lib/core/models.py:1500 core/models.py:1500
msgid "A link trace already exists for this document/user." msgid "A link trace already exists for this document/user."
msgstr "Für dieses Dokument/ diesen Benutzer ist bereits eine Linkverfolgung vorhanden." msgstr "Für dieses Dokument/ diesen Benutzer ist bereits eine Linkverfolgung vorhanden."
#: build/lib/core/models.py:1505 core/models.py:1505 #: build/lib/core/models.py:1523 core/models.py:1523
msgid "Document favorite" msgid "Document favorite"
msgstr "Dokumentenfavorit" msgstr "Dokumentenfavorit"
#: build/lib/core/models.py:1506 core/models.py:1506 #: build/lib/core/models.py:1524 core/models.py:1524
msgid "Document favorites" msgid "Document favorites"
msgstr "Dokumentfavoriten" msgstr "Dokumentfavoriten"
#: build/lib/core/models.py:1512 core/models.py:1512 #: build/lib/core/models.py:1530 core/models.py:1530
msgid "This document is already targeted by a favorite relation instance for the same user." 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." msgstr "Dieses Dokument ist bereits durch den gleichen Benutzer favorisiert worden."
#: build/lib/core/models.py:1534 core/models.py:1534 #: build/lib/core/models.py:1552 core/models.py:1552
msgid "Document/user relation" msgid "Document/user relation"
msgstr "Dokument/Benutzerbeziehung" msgstr "Dokument/Benutzerbeziehung"
#: build/lib/core/models.py:1535 core/models.py:1535 #: build/lib/core/models.py:1553 core/models.py:1553
msgid "Document/user relations" msgid "Document/user relations"
msgstr "Dokument/Benutzerbeziehungen" msgstr "Dokument/Benutzerbeziehungen"
#: build/lib/core/models.py:1541 core/models.py:1541 #: build/lib/core/models.py:1559 core/models.py:1559
msgid "This user is already in this document." msgid "This user is already in this document."
msgstr "Dieser Benutzer befindet sich bereits in diesem Dokument." msgstr "Dieser Benutzer befindet sich bereits in diesem Dokument."
#: build/lib/core/models.py:1547 core/models.py:1547 #: build/lib/core/models.py:1565 core/models.py:1565
msgid "This team is already in this document." msgid "This team is already in this document."
msgstr "Dieses Team befindet sich bereits in diesem Dokument." msgstr "Dieses Team befindet sich bereits in diesem Dokument."
#: build/lib/core/models.py:1553 core/models.py:1553 #: build/lib/core/models.py:1571 core/models.py:1571
msgid "Either user or team must be set, not both." msgid "Either user or team must be set, not both."
msgstr "Benutzer oder Team müssen gesetzt werden, nicht beides." msgstr "Benutzer oder Team müssen gesetzt werden, nicht beides."
#: build/lib/core/models.py:1704 core/models.py:1704 #: build/lib/core/models.py:1722 core/models.py:1722
msgid "Document ask for access" msgid "Document ask for access"
msgstr "" msgstr ""
#: build/lib/core/models.py:1705 core/models.py:1705 #: build/lib/core/models.py:1723 core/models.py:1723
msgid "Document ask for accesses" msgid "Document ask for accesses"
msgstr "" msgstr ""
#: build/lib/core/models.py:1711 core/models.py:1711 #: build/lib/core/models.py:1729 core/models.py:1729
msgid "This user has already asked for access to this document." msgid "This user has already asked for access to this document."
msgstr "" msgstr ""
#: build/lib/core/models.py:1768 core/models.py:1768 #: build/lib/core/models.py:1786 core/models.py:1786
#, python-brace-format #, python-brace-format
msgid "{name} would like access to a document!" msgid "{name} would like access to a document!"
msgstr "" msgstr ""
#: build/lib/core/models.py:1772 core/models.py:1772 #: build/lib/core/models.py:1790 core/models.py:1790
#, python-brace-format #, python-brace-format
msgid "{name} would like access to the following document:" msgid "{name} would like access to the following document:"
msgstr "" msgstr ""
#: build/lib/core/models.py:1778 core/models.py:1778 #: build/lib/core/models.py:1796 core/models.py:1796
#, python-brace-format #, python-brace-format
msgid "{name} is asking for access to the document: {title}" msgid "{name} is asking for access to the document: {title}"
msgstr "" msgstr ""
#: build/lib/core/models.py:1820 core/models.py:1820 #: build/lib/core/models.py:1838 core/models.py:1838
msgid "Thread" msgid "Thread"
msgstr "" msgstr ""
#: build/lib/core/models.py:1821 core/models.py:1821 #: build/lib/core/models.py:1839 core/models.py:1839
msgid "Threads" msgid "Threads"
msgstr "" msgstr ""
#: build/lib/core/models.py:1824 build/lib/core/models.py:1876 #: build/lib/core/models.py:1842 build/lib/core/models.py:1894
#: core/models.py:1824 core/models.py:1876 #: core/models.py:1842 core/models.py:1894
msgid "Anonymous" msgid "Anonymous"
msgstr "" msgstr ""
#: build/lib/core/models.py:1871 core/models.py:1871 #: build/lib/core/models.py:1889 core/models.py:1889
msgid "Comment" msgid "Comment"
msgstr "" msgstr ""
#: build/lib/core/models.py:1872 core/models.py:1872 #: build/lib/core/models.py:1890 core/models.py:1890
msgid "Comments" msgid "Comments"
msgstr "" msgstr ""
#: build/lib/core/models.py:1921 core/models.py:1921 #: build/lib/core/models.py:1939 core/models.py:1939
msgid "This emoji has already been reacted to this comment." msgid "This emoji has already been reacted to this comment."
msgstr "" msgstr ""
#: build/lib/core/models.py:1925 core/models.py:1925 #: build/lib/core/models.py:1943 core/models.py:1943
msgid "Reaction" msgid "Reaction"
msgstr "" msgstr ""
#: build/lib/core/models.py:1926 core/models.py:1926 #: build/lib/core/models.py:1944 core/models.py:1944
msgid "Reactions" msgid "Reactions"
msgstr "" msgstr ""
#: build/lib/core/models.py:1936 core/models.py:1936 #: build/lib/core/models.py:1954 core/models.py:1954
msgid "email address" msgid "email address"
msgstr "E-Mail-Adresse" msgstr "E-Mail-Adresse"
#: build/lib/core/models.py:1955 core/models.py:1955 #: build/lib/core/models.py:1973 core/models.py:1973
msgid "Document invitation" msgid "Document invitation"
msgstr "Einladung zum Dokument" msgstr "Einladung zum Dokument"
#: build/lib/core/models.py:1956 core/models.py:1956 #: build/lib/core/models.py:1974 core/models.py:1974
msgid "Document invitations" msgid "Document invitations"
msgstr "Dokumenteinladungen" msgstr "Dokumenteinladungen"
#: build/lib/core/models.py:1976 core/models.py:1976 #: build/lib/core/models.py:1994 core/models.py:1994
msgid "This email is already associated to a registered user." msgid "This email is already associated to a registered user."
msgstr "Diese E-Mail ist bereits einem registrierten Benutzer zugeordnet." msgstr "Diese E-Mail ist bereits einem registrierten Benutzer zugeordnet."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: lasuite-docs\n" "Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-06 09:37+0000\n" "POT-Creation-Date: 2026-03-12 13:31+0000\n"
"PO-Revision-Date: 2026-03-09 14:02\n" "PO-Revision-Date: 2026-03-13 16:53\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: English\n" "Language-Team: English\n"
"Language: en_US\n" "Language: en_US\n"
@@ -58,24 +58,24 @@ msgstr ""
msgid "Favorite" msgid "Favorite"
msgstr "" msgstr ""
#: build/lib/core/api/serializers.py:513 core/api/serializers.py:513 #: build/lib/core/api/serializers.py:526 core/api/serializers.py:526
msgid "A new document was created on your behalf!" msgid "A new document was created on your behalf!"
msgstr "" msgstr ""
#: build/lib/core/api/serializers.py:517 core/api/serializers.py:517 #: build/lib/core/api/serializers.py:530 core/api/serializers.py:530
msgid "You have been granted ownership of a new document:" msgid "You have been granted ownership of a new document:"
msgstr "" msgstr ""
#: build/lib/core/api/serializers.py:553 core/api/serializers.py:553 #: build/lib/core/api/serializers.py:566 core/api/serializers.py:566
msgid "This field is required." msgid "This field is required."
msgstr "" msgstr ""
#: build/lib/core/api/serializers.py:564 core/api/serializers.py:564 #: build/lib/core/api/serializers.py:577 core/api/serializers.py:577
#, python-format #, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration." msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr "" msgstr ""
#: build/lib/core/api/viewsets.py:1279 core/api/viewsets.py:1279 #: build/lib/core/api/viewsets.py:1298 core/api/viewsets.py:1298
#, python-brace-format #, python-brace-format
msgid "copy of {title}" msgid "copy of {title}"
msgstr "" msgstr ""
@@ -231,106 +231,114 @@ msgstr ""
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts." msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "" msgstr ""
#: build/lib/core/models.py:204 core/models.py:204 #: build/lib/core/models.py:197 core/models.py:197
msgid "first connection status"
msgstr ""
#: build/lib/core/models.py:199 core/models.py:199
msgid "Whether the user has completed the first connection process."
msgstr ""
#: build/lib/core/models.py:209 core/models.py:209
msgid "user" msgid "user"
msgstr "" msgstr ""
#: build/lib/core/models.py:205 core/models.py:205 #: build/lib/core/models.py:210 core/models.py:210
msgid "users" msgid "users"
msgstr "" msgstr ""
#: build/lib/core/models.py:360 core/models.py:360 #: build/lib/core/models.py:378 core/models.py:378
msgid "Active email address" msgid "Active email address"
msgstr "" msgstr ""
#: build/lib/core/models.py:361 core/models.py:361 #: build/lib/core/models.py:379 core/models.py:379
msgid "Email address to deactivate" msgid "Email address to deactivate"
msgstr "" msgstr ""
#: build/lib/core/models.py:388 core/models.py:388 #: build/lib/core/models.py:406 core/models.py:406
msgid "Unique ID in the source file" msgid "Unique ID in the source file"
msgstr "" msgstr ""
#: build/lib/core/models.py:394 build/lib/core/models.py:692 core/models.py:394 #: build/lib/core/models.py:412 build/lib/core/models.py:710 core/models.py:412
#: core/models.py:692 #: core/models.py:710
msgid "Pending" msgid "Pending"
msgstr "" msgstr ""
#: build/lib/core/models.py:395 core/models.py:395 #: build/lib/core/models.py:413 core/models.py:413
msgid "Ready" msgid "Ready"
msgstr "" msgstr ""
#: build/lib/core/models.py:396 build/lib/core/models.py:694 core/models.py:396 #: build/lib/core/models.py:414 build/lib/core/models.py:712 core/models.py:414
#: core/models.py:694 #: core/models.py:712
msgid "Done" msgid "Done"
msgstr "" msgstr ""
#: build/lib/core/models.py:397 build/lib/core/models.py:695 core/models.py:397 #: build/lib/core/models.py:415 build/lib/core/models.py:713 core/models.py:415
#: core/models.py:695 #: core/models.py:713
msgid "Error" msgid "Error"
msgstr "" msgstr ""
#: build/lib/core/models.py:405 core/models.py:405 #: build/lib/core/models.py:423 core/models.py:423
msgid "user reconciliation" msgid "user reconciliation"
msgstr "" msgstr ""
#: build/lib/core/models.py:406 core/models.py:406 #: build/lib/core/models.py:424 core/models.py:424
msgid "user reconciliations" msgid "user reconciliations"
msgstr "" msgstr ""
#: build/lib/core/models.py:644 core/models.py:644 #: build/lib/core/models.py:662 core/models.py:662
msgid "You have requested a reconciliation of your user accounts on Docs.\n" msgid "You have requested a reconciliation of your user accounts on Docs.\n"
" To confirm that you are the one who initiated the request\n" " To confirm that you are the one who initiated the request\n"
" and that this email belongs to you:" " and that this email belongs to you:"
msgstr "" msgstr ""
#: build/lib/core/models.py:650 core/models.py:650 #: build/lib/core/models.py:668 core/models.py:668
msgid "Confirm by clicking the link to start the reconciliation" msgid "Confirm by clicking the link to start the reconciliation"
msgstr "" msgstr ""
#: build/lib/core/models.py:655 build/lib/core/models.py:761 core/models.py:655 #: build/lib/core/models.py:673 build/lib/core/models.py:779 core/models.py:673
#: core/models.py:761 #: core/models.py:779
msgid "Click here" msgid "Click here"
msgstr "" msgstr ""
#: build/lib/core/models.py:656 core/models.py:656 #: build/lib/core/models.py:674 core/models.py:674
msgid "Confirm" msgid "Confirm"
msgstr "" msgstr ""
#: build/lib/core/models.py:667 core/models.py:667 #: build/lib/core/models.py:685 core/models.py:685
msgid "Your reconciliation request has been processed.\n" msgid "Your reconciliation request has been processed.\n"
" New documents are likely associated with your account:" " New documents are likely associated with your account:"
msgstr "" msgstr ""
#: build/lib/core/models.py:672 core/models.py:672 #: build/lib/core/models.py:690 core/models.py:690
msgid "Your accounts have been merged" msgid "Your accounts have been merged"
msgstr "" msgstr ""
#: build/lib/core/models.py:677 core/models.py:677 #: build/lib/core/models.py:695 core/models.py:695
msgid "Click here to see" msgid "Click here to see"
msgstr "" msgstr ""
#: build/lib/core/models.py:678 core/models.py:678 #: build/lib/core/models.py:696 core/models.py:696
msgid "See my documents" msgid "See my documents"
msgstr "" msgstr ""
#: build/lib/core/models.py:688 core/models.py:688 #: build/lib/core/models.py:706 core/models.py:706
msgid "CSV file" msgid "CSV file"
msgstr "" msgstr ""
#: build/lib/core/models.py:693 core/models.py:693 #: build/lib/core/models.py:711 core/models.py:711
msgid "Running" msgid "Running"
msgstr "" msgstr ""
#: build/lib/core/models.py:703 core/models.py:703 #: build/lib/core/models.py:721 core/models.py:721
msgid "user reconciliation CSV import" msgid "user reconciliation CSV import"
msgstr "" msgstr ""
#: build/lib/core/models.py:704 core/models.py:704 #: build/lib/core/models.py:722 core/models.py:722
msgid "user reconciliation CSV imports" msgid "user reconciliation CSV imports"
msgstr "" msgstr ""
#: build/lib/core/models.py:748 core/models.py:748 #: build/lib/core/models.py:766 core/models.py:766
#, python-brace-format #, python-brace-format
msgid "Your request for reconciliation was unsuccessful.\n" msgid "Your request for reconciliation was unsuccessful.\n"
" Reconciliation failed for the following email addresses:\n" " Reconciliation failed for the following email addresses:\n"
@@ -339,171 +347,171 @@ msgid "Your request for reconciliation was unsuccessful.\n"
" You can submit another request with the valid email addresses." " You can submit another request with the valid email addresses."
msgstr "" msgstr ""
#: build/lib/core/models.py:756 core/models.py:756 #: build/lib/core/models.py:774 core/models.py:774
msgid "Reconciliation of your Docs accounts not completed" msgid "Reconciliation of your Docs accounts not completed"
msgstr "" msgstr ""
#: build/lib/core/models.py:762 core/models.py:762 #: build/lib/core/models.py:780 core/models.py:780
msgid "Make a new request" msgid "Make a new request"
msgstr "" msgstr ""
#: build/lib/core/models.py:861 core/models.py:861 #: build/lib/core/models.py:879 core/models.py:879
msgid "title" msgid "title"
msgstr "" msgstr ""
#: build/lib/core/models.py:862 core/models.py:862 #: build/lib/core/models.py:880 core/models.py:880
msgid "excerpt" msgid "excerpt"
msgstr "" msgstr ""
#: build/lib/core/models.py:911 core/models.py:911 #: build/lib/core/models.py:929 core/models.py:929
msgid "Document" msgid "Document"
msgstr "" msgstr ""
#: build/lib/core/models.py:912 core/models.py:912 #: build/lib/core/models.py:930 core/models.py:930
msgid "Documents" msgid "Documents"
msgstr "" msgstr ""
#: build/lib/core/models.py:924 build/lib/core/models.py:1328 #: build/lib/core/models.py:942 build/lib/core/models.py:1346
#: core/models.py:924 core/models.py:1328 #: core/models.py:942 core/models.py:1346
msgid "Untitled Document" msgid "Untitled Document"
msgstr "" msgstr ""
#: build/lib/core/models.py:1329 core/models.py:1329 #: build/lib/core/models.py:1347 core/models.py:1347
msgid "Open" msgid "Open"
msgstr "" msgstr ""
#: build/lib/core/models.py:1364 core/models.py:1364 #: build/lib/core/models.py:1382 core/models.py:1382
#, python-brace-format #, python-brace-format
msgid "{name} shared a document with you!" msgid "{name} shared a document with you!"
msgstr "" msgstr ""
#: build/lib/core/models.py:1368 core/models.py:1368 #: build/lib/core/models.py:1386 core/models.py:1386
#, python-brace-format #, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:" msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "" msgstr ""
#: build/lib/core/models.py:1374 core/models.py:1374 #: build/lib/core/models.py:1392 core/models.py:1392
#, python-brace-format #, python-brace-format
msgid "{name} shared a document with you: {title}" msgid "{name} shared a document with you: {title}"
msgstr "" msgstr ""
#: build/lib/core/models.py:1475 core/models.py:1475 #: build/lib/core/models.py:1493 core/models.py:1493
msgid "Document/user link trace" msgid "Document/user link trace"
msgstr "" msgstr ""
#: build/lib/core/models.py:1476 core/models.py:1476 #: build/lib/core/models.py:1494 core/models.py:1494
msgid "Document/user link traces" msgid "Document/user link traces"
msgstr "" msgstr ""
#: build/lib/core/models.py:1482 core/models.py:1482 #: build/lib/core/models.py:1500 core/models.py:1500
msgid "A link trace already exists for this document/user." msgid "A link trace already exists for this document/user."
msgstr "" msgstr ""
#: build/lib/core/models.py:1505 core/models.py:1505 #: build/lib/core/models.py:1523 core/models.py:1523
msgid "Document favorite" msgid "Document favorite"
msgstr "" msgstr ""
#: build/lib/core/models.py:1506 core/models.py:1506 #: build/lib/core/models.py:1524 core/models.py:1524
msgid "Document favorites" msgid "Document favorites"
msgstr "" msgstr ""
#: build/lib/core/models.py:1512 core/models.py:1512 #: build/lib/core/models.py:1530 core/models.py:1530
msgid "This document is already targeted by a favorite relation instance for the same user." msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "" msgstr ""
#: build/lib/core/models.py:1534 core/models.py:1534 #: build/lib/core/models.py:1552 core/models.py:1552
msgid "Document/user relation" msgid "Document/user relation"
msgstr "" msgstr ""
#: build/lib/core/models.py:1535 core/models.py:1535 #: build/lib/core/models.py:1553 core/models.py:1553
msgid "Document/user relations" msgid "Document/user relations"
msgstr "" msgstr ""
#: build/lib/core/models.py:1541 core/models.py:1541 #: build/lib/core/models.py:1559 core/models.py:1559
msgid "This user is already in this document." msgid "This user is already in this document."
msgstr "" msgstr ""
#: build/lib/core/models.py:1547 core/models.py:1547 #: build/lib/core/models.py:1565 core/models.py:1565
msgid "This team is already in this document." msgid "This team is already in this document."
msgstr "" msgstr ""
#: build/lib/core/models.py:1553 core/models.py:1553 #: build/lib/core/models.py:1571 core/models.py:1571
msgid "Either user or team must be set, not both." msgid "Either user or team must be set, not both."
msgstr "" msgstr ""
#: build/lib/core/models.py:1704 core/models.py:1704 #: build/lib/core/models.py:1722 core/models.py:1722
msgid "Document ask for access" msgid "Document ask for access"
msgstr "" msgstr ""
#: build/lib/core/models.py:1705 core/models.py:1705 #: build/lib/core/models.py:1723 core/models.py:1723
msgid "Document ask for accesses" msgid "Document ask for accesses"
msgstr "" msgstr ""
#: build/lib/core/models.py:1711 core/models.py:1711 #: build/lib/core/models.py:1729 core/models.py:1729
msgid "This user has already asked for access to this document." msgid "This user has already asked for access to this document."
msgstr "" msgstr ""
#: build/lib/core/models.py:1768 core/models.py:1768 #: build/lib/core/models.py:1786 core/models.py:1786
#, python-brace-format #, python-brace-format
msgid "{name} would like access to a document!" msgid "{name} would like access to a document!"
msgstr "" msgstr ""
#: build/lib/core/models.py:1772 core/models.py:1772 #: build/lib/core/models.py:1790 core/models.py:1790
#, python-brace-format #, python-brace-format
msgid "{name} would like access to the following document:" msgid "{name} would like access to the following document:"
msgstr "" msgstr ""
#: build/lib/core/models.py:1778 core/models.py:1778 #: build/lib/core/models.py:1796 core/models.py:1796
#, python-brace-format #, python-brace-format
msgid "{name} is asking for access to the document: {title}" msgid "{name} is asking for access to the document: {title}"
msgstr "" msgstr ""
#: build/lib/core/models.py:1820 core/models.py:1820 #: build/lib/core/models.py:1838 core/models.py:1838
msgid "Thread" msgid "Thread"
msgstr "" msgstr ""
#: build/lib/core/models.py:1821 core/models.py:1821 #: build/lib/core/models.py:1839 core/models.py:1839
msgid "Threads" msgid "Threads"
msgstr "" msgstr ""
#: build/lib/core/models.py:1824 build/lib/core/models.py:1876 #: build/lib/core/models.py:1842 build/lib/core/models.py:1894
#: core/models.py:1824 core/models.py:1876 #: core/models.py:1842 core/models.py:1894
msgid "Anonymous" msgid "Anonymous"
msgstr "" msgstr ""
#: build/lib/core/models.py:1871 core/models.py:1871 #: build/lib/core/models.py:1889 core/models.py:1889
msgid "Comment" msgid "Comment"
msgstr "" msgstr ""
#: build/lib/core/models.py:1872 core/models.py:1872 #: build/lib/core/models.py:1890 core/models.py:1890
msgid "Comments" msgid "Comments"
msgstr "" msgstr ""
#: build/lib/core/models.py:1921 core/models.py:1921 #: build/lib/core/models.py:1939 core/models.py:1939
msgid "This emoji has already been reacted to this comment." msgid "This emoji has already been reacted to this comment."
msgstr "" msgstr ""
#: build/lib/core/models.py:1925 core/models.py:1925 #: build/lib/core/models.py:1943 core/models.py:1943
msgid "Reaction" msgid "Reaction"
msgstr "" msgstr ""
#: build/lib/core/models.py:1926 core/models.py:1926 #: build/lib/core/models.py:1944 core/models.py:1944
msgid "Reactions" msgid "Reactions"
msgstr "" msgstr ""
#: build/lib/core/models.py:1936 core/models.py:1936 #: build/lib/core/models.py:1954 core/models.py:1954
msgid "email address" msgid "email address"
msgstr "" msgstr ""
#: build/lib/core/models.py:1955 core/models.py:1955 #: build/lib/core/models.py:1973 core/models.py:1973
msgid "Document invitation" msgid "Document invitation"
msgstr "" msgstr ""
#: build/lib/core/models.py:1956 core/models.py:1956 #: build/lib/core/models.py:1974 core/models.py:1974
msgid "Document invitations" msgid "Document invitations"
msgstr "" msgstr ""
#: build/lib/core/models.py:1976 core/models.py:1976 #: build/lib/core/models.py:1994 core/models.py:1994
msgid "This email is already associated to a registered user." msgid "This email is already associated to a registered user."
msgstr "" msgstr ""

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: lasuite-docs\n" "Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-06 09:37+0000\n" "POT-Creation-Date: 2026-03-12 13:31+0000\n"
"PO-Revision-Date: 2026-03-09 14:02\n" "PO-Revision-Date: 2026-03-13 16:53\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Spanish\n" "Language-Team: Spanish\n"
"Language: es_ES\n" "Language: es_ES\n"
@@ -58,24 +58,24 @@ msgstr ""
msgid "Favorite" msgid "Favorite"
msgstr "Favorito" msgstr "Favorito"
#: build/lib/core/api/serializers.py:513 core/api/serializers.py:513 #: build/lib/core/api/serializers.py:526 core/api/serializers.py:526
msgid "A new document was created on your behalf!" msgid "A new document was created on your behalf!"
msgstr "¡Un nuevo documento se ha creado por ti!" msgstr "¡Un nuevo documento se ha creado por ti!"
#: build/lib/core/api/serializers.py:517 core/api/serializers.py:517 #: build/lib/core/api/serializers.py:530 core/api/serializers.py:530
msgid "You have been granted ownership of a new document:" msgid "You have been granted ownership of a new document:"
msgstr "Se le ha concedido la propiedad de un nuevo documento :" msgstr "Se le ha concedido la propiedad de un nuevo documento :"
#: build/lib/core/api/serializers.py:553 core/api/serializers.py:553 #: build/lib/core/api/serializers.py:566 core/api/serializers.py:566
msgid "This field is required." msgid "This field is required."
msgstr "" msgstr ""
#: build/lib/core/api/serializers.py:564 core/api/serializers.py:564 #: build/lib/core/api/serializers.py:577 core/api/serializers.py:577
#, python-format #, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration." msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr "" msgstr ""
#: build/lib/core/api/viewsets.py:1279 core/api/viewsets.py:1279 #: build/lib/core/api/viewsets.py:1298 core/api/viewsets.py:1298
#, python-brace-format #, python-brace-format
msgid "copy of {title}" msgid "copy of {title}"
msgstr "copia de {title}" msgstr "copia de {title}"
@@ -231,106 +231,114 @@ msgstr "activo"
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts." msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Si este usuario debe ser considerado como activo. Deseleccionar en lugar de eliminar cuentas." msgstr "Si este usuario debe ser considerado como activo. Deseleccionar en lugar de eliminar cuentas."
#: build/lib/core/models.py:204 core/models.py:204 #: build/lib/core/models.py:197 core/models.py:197
msgid "first connection status"
msgstr ""
#: build/lib/core/models.py:199 core/models.py:199
msgid "Whether the user has completed the first connection process."
msgstr ""
#: build/lib/core/models.py:209 core/models.py:209
msgid "user" msgid "user"
msgstr "usuario" msgstr "usuario"
#: build/lib/core/models.py:205 core/models.py:205 #: build/lib/core/models.py:210 core/models.py:210
msgid "users" msgid "users"
msgstr "usuarios" msgstr "usuarios"
#: build/lib/core/models.py:360 core/models.py:360 #: build/lib/core/models.py:378 core/models.py:378
msgid "Active email address" msgid "Active email address"
msgstr "" msgstr ""
#: build/lib/core/models.py:361 core/models.py:361 #: build/lib/core/models.py:379 core/models.py:379
msgid "Email address to deactivate" msgid "Email address to deactivate"
msgstr "" msgstr ""
#: build/lib/core/models.py:388 core/models.py:388 #: build/lib/core/models.py:406 core/models.py:406
msgid "Unique ID in the source file" msgid "Unique ID in the source file"
msgstr "" msgstr ""
#: build/lib/core/models.py:394 build/lib/core/models.py:692 core/models.py:394 #: build/lib/core/models.py:412 build/lib/core/models.py:710 core/models.py:412
#: core/models.py:692 #: core/models.py:710
msgid "Pending" msgid "Pending"
msgstr "" msgstr ""
#: build/lib/core/models.py:395 core/models.py:395 #: build/lib/core/models.py:413 core/models.py:413
msgid "Ready" msgid "Ready"
msgstr "" msgstr ""
#: build/lib/core/models.py:396 build/lib/core/models.py:694 core/models.py:396 #: build/lib/core/models.py:414 build/lib/core/models.py:712 core/models.py:414
#: core/models.py:694 #: core/models.py:712
msgid "Done" msgid "Done"
msgstr "" msgstr ""
#: build/lib/core/models.py:397 build/lib/core/models.py:695 core/models.py:397 #: build/lib/core/models.py:415 build/lib/core/models.py:713 core/models.py:415
#: core/models.py:695 #: core/models.py:713
msgid "Error" msgid "Error"
msgstr "" msgstr ""
#: build/lib/core/models.py:405 core/models.py:405 #: build/lib/core/models.py:423 core/models.py:423
msgid "user reconciliation" msgid "user reconciliation"
msgstr "" msgstr ""
#: build/lib/core/models.py:406 core/models.py:406 #: build/lib/core/models.py:424 core/models.py:424
msgid "user reconciliations" msgid "user reconciliations"
msgstr "" msgstr ""
#: build/lib/core/models.py:644 core/models.py:644 #: build/lib/core/models.py:662 core/models.py:662
msgid "You have requested a reconciliation of your user accounts on Docs.\n" msgid "You have requested a reconciliation of your user accounts on Docs.\n"
" To confirm that you are the one who initiated the request\n" " To confirm that you are the one who initiated the request\n"
" and that this email belongs to you:" " and that this email belongs to you:"
msgstr "" msgstr ""
#: build/lib/core/models.py:650 core/models.py:650 #: build/lib/core/models.py:668 core/models.py:668
msgid "Confirm by clicking the link to start the reconciliation" msgid "Confirm by clicking the link to start the reconciliation"
msgstr "" msgstr ""
#: build/lib/core/models.py:655 build/lib/core/models.py:761 core/models.py:655 #: build/lib/core/models.py:673 build/lib/core/models.py:779 core/models.py:673
#: core/models.py:761 #: core/models.py:779
msgid "Click here" msgid "Click here"
msgstr "" msgstr ""
#: build/lib/core/models.py:656 core/models.py:656 #: build/lib/core/models.py:674 core/models.py:674
msgid "Confirm" msgid "Confirm"
msgstr "" msgstr ""
#: build/lib/core/models.py:667 core/models.py:667 #: build/lib/core/models.py:685 core/models.py:685
msgid "Your reconciliation request has been processed.\n" msgid "Your reconciliation request has been processed.\n"
" New documents are likely associated with your account:" " New documents are likely associated with your account:"
msgstr "" msgstr ""
#: build/lib/core/models.py:672 core/models.py:672 #: build/lib/core/models.py:690 core/models.py:690
msgid "Your accounts have been merged" msgid "Your accounts have been merged"
msgstr "" msgstr ""
#: build/lib/core/models.py:677 core/models.py:677 #: build/lib/core/models.py:695 core/models.py:695
msgid "Click here to see" msgid "Click here to see"
msgstr "" msgstr ""
#: build/lib/core/models.py:678 core/models.py:678 #: build/lib/core/models.py:696 core/models.py:696
msgid "See my documents" msgid "See my documents"
msgstr "" msgstr ""
#: build/lib/core/models.py:688 core/models.py:688 #: build/lib/core/models.py:706 core/models.py:706
msgid "CSV file" msgid "CSV file"
msgstr "" msgstr ""
#: build/lib/core/models.py:693 core/models.py:693 #: build/lib/core/models.py:711 core/models.py:711
msgid "Running" msgid "Running"
msgstr "" msgstr ""
#: build/lib/core/models.py:703 core/models.py:703 #: build/lib/core/models.py:721 core/models.py:721
msgid "user reconciliation CSV import" msgid "user reconciliation CSV import"
msgstr "" msgstr ""
#: build/lib/core/models.py:704 core/models.py:704 #: build/lib/core/models.py:722 core/models.py:722
msgid "user reconciliation CSV imports" msgid "user reconciliation CSV imports"
msgstr "" msgstr ""
#: build/lib/core/models.py:748 core/models.py:748 #: build/lib/core/models.py:766 core/models.py:766
#, python-brace-format #, python-brace-format
msgid "Your request for reconciliation was unsuccessful.\n" msgid "Your request for reconciliation was unsuccessful.\n"
" Reconciliation failed for the following email addresses:\n" " Reconciliation failed for the following email addresses:\n"
@@ -339,171 +347,171 @@ msgid "Your request for reconciliation was unsuccessful.\n"
" You can submit another request with the valid email addresses." " You can submit another request with the valid email addresses."
msgstr "" msgstr ""
#: build/lib/core/models.py:756 core/models.py:756 #: build/lib/core/models.py:774 core/models.py:774
msgid "Reconciliation of your Docs accounts not completed" msgid "Reconciliation of your Docs accounts not completed"
msgstr "" msgstr ""
#: build/lib/core/models.py:762 core/models.py:762 #: build/lib/core/models.py:780 core/models.py:780
msgid "Make a new request" msgid "Make a new request"
msgstr "" msgstr ""
#: build/lib/core/models.py:861 core/models.py:861 #: build/lib/core/models.py:879 core/models.py:879
msgid "title" msgid "title"
msgstr "título" msgstr "título"
#: build/lib/core/models.py:862 core/models.py:862 #: build/lib/core/models.py:880 core/models.py:880
msgid "excerpt" msgid "excerpt"
msgstr "resumen" msgstr "resumen"
#: build/lib/core/models.py:911 core/models.py:911 #: build/lib/core/models.py:929 core/models.py:929
msgid "Document" msgid "Document"
msgstr "Documento" msgstr "Documento"
#: build/lib/core/models.py:912 core/models.py:912 #: build/lib/core/models.py:930 core/models.py:930
msgid "Documents" msgid "Documents"
msgstr "Documentos" msgstr "Documentos"
#: build/lib/core/models.py:924 build/lib/core/models.py:1328 #: build/lib/core/models.py:942 build/lib/core/models.py:1346
#: core/models.py:924 core/models.py:1328 #: core/models.py:942 core/models.py:1346
msgid "Untitled Document" msgid "Untitled Document"
msgstr "Documento sin título" msgstr "Documento sin título"
#: build/lib/core/models.py:1329 core/models.py:1329 #: build/lib/core/models.py:1347 core/models.py:1347
msgid "Open" msgid "Open"
msgstr "Abrir" msgstr "Abrir"
#: build/lib/core/models.py:1364 core/models.py:1364 #: build/lib/core/models.py:1382 core/models.py:1382
#, python-brace-format #, python-brace-format
msgid "{name} shared a document with you!" msgid "{name} shared a document with you!"
msgstr "¡{name} ha compartido un documento contigo!" msgstr "¡{name} ha compartido un documento contigo!"
#: build/lib/core/models.py:1368 core/models.py:1368 #: build/lib/core/models.py:1386 core/models.py:1386
#, python-brace-format #, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:" msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "Te ha invitado {name} al siguiente documento con el rol \"{role}\" :" msgstr "Te ha invitado {name} al siguiente documento con el rol \"{role}\" :"
#: build/lib/core/models.py:1374 core/models.py:1374 #: build/lib/core/models.py:1392 core/models.py:1392
#, python-brace-format #, python-brace-format
msgid "{name} shared a document with you: {title}" msgid "{name} shared a document with you: {title}"
msgstr "{name} ha compartido un documento contigo: {title}" msgstr "{name} ha compartido un documento contigo: {title}"
#: build/lib/core/models.py:1475 core/models.py:1475 #: build/lib/core/models.py:1493 core/models.py:1493
msgid "Document/user link trace" msgid "Document/user link trace"
msgstr "Traza del enlace de documento/usuario" msgstr "Traza del enlace de documento/usuario"
#: build/lib/core/models.py:1476 core/models.py:1476 #: build/lib/core/models.py:1494 core/models.py:1494
msgid "Document/user link traces" msgid "Document/user link traces"
msgstr "Trazas del enlace de documento/usuario" msgstr "Trazas del enlace de documento/usuario"
#: build/lib/core/models.py:1482 core/models.py:1482 #: build/lib/core/models.py:1500 core/models.py:1500
msgid "A link trace already exists for this document/user." msgid "A link trace already exists for this document/user."
msgstr "Ya existe una traza de enlace para este documento/usuario." msgstr "Ya existe una traza de enlace para este documento/usuario."
#: build/lib/core/models.py:1505 core/models.py:1505 #: build/lib/core/models.py:1523 core/models.py:1523
msgid "Document favorite" msgid "Document favorite"
msgstr "Documento favorito" msgstr "Documento favorito"
#: build/lib/core/models.py:1506 core/models.py:1506 #: build/lib/core/models.py:1524 core/models.py:1524
msgid "Document favorites" msgid "Document favorites"
msgstr "Documentos favoritos" msgstr "Documentos favoritos"
#: build/lib/core/models.py:1512 core/models.py:1512 #: build/lib/core/models.py:1530 core/models.py:1530
msgid "This document is already targeted by a favorite relation instance for the same user." 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." msgstr "Este documento ya ha sido marcado como favorito por el usuario."
#: build/lib/core/models.py:1534 core/models.py:1534 #: build/lib/core/models.py:1552 core/models.py:1552
msgid "Document/user relation" msgid "Document/user relation"
msgstr "Relación documento/usuario" msgstr "Relación documento/usuario"
#: build/lib/core/models.py:1535 core/models.py:1535 #: build/lib/core/models.py:1553 core/models.py:1553
msgid "Document/user relations" msgid "Document/user relations"
msgstr "Relaciones documento/usuario" msgstr "Relaciones documento/usuario"
#: build/lib/core/models.py:1541 core/models.py:1541 #: build/lib/core/models.py:1559 core/models.py:1559
msgid "This user is already in this document." msgid "This user is already in this document."
msgstr "Este usuario ya forma parte del documento." msgstr "Este usuario ya forma parte del documento."
#: build/lib/core/models.py:1547 core/models.py:1547 #: build/lib/core/models.py:1565 core/models.py:1565
msgid "This team is already in this document." msgid "This team is already in this document."
msgstr "Este equipo ya forma parte del documento." msgstr "Este equipo ya forma parte del documento."
#: build/lib/core/models.py:1553 core/models.py:1553 #: build/lib/core/models.py:1571 core/models.py:1571
msgid "Either user or team must be set, not both." msgid "Either user or team must be set, not both."
msgstr "Debe establecerse un usuario o un equipo, no ambos." msgstr "Debe establecerse un usuario o un equipo, no ambos."
#: build/lib/core/models.py:1704 core/models.py:1704 #: build/lib/core/models.py:1722 core/models.py:1722
msgid "Document ask for access" msgid "Document ask for access"
msgstr "Solicitud de acceso" msgstr "Solicitud de acceso"
#: build/lib/core/models.py:1705 core/models.py:1705 #: build/lib/core/models.py:1723 core/models.py:1723
msgid "Document ask for accesses" msgid "Document ask for accesses"
msgstr "Solicitud de accesos" msgstr "Solicitud de accesos"
#: build/lib/core/models.py:1711 core/models.py:1711 #: build/lib/core/models.py:1729 core/models.py:1729
msgid "This user has already asked for access to this document." msgid "This user has already asked for access to this document."
msgstr "Este usuario ya ha solicitado acceso a este documento." msgstr "Este usuario ya ha solicitado acceso a este documento."
#: build/lib/core/models.py:1768 core/models.py:1768 #: build/lib/core/models.py:1786 core/models.py:1786
#, python-brace-format #, python-brace-format
msgid "{name} would like access to a document!" msgid "{name} would like access to a document!"
msgstr "¡{name} desea acceder a un documento!" msgstr "¡{name} desea acceder a un documento!"
#: build/lib/core/models.py:1772 core/models.py:1772 #: build/lib/core/models.py:1790 core/models.py:1790
#, python-brace-format #, python-brace-format
msgid "{name} would like access to the following document:" msgid "{name} would like access to the following document:"
msgstr "{name} desea acceso al siguiente documento:" msgstr "{name} desea acceso al siguiente documento:"
#: build/lib/core/models.py:1778 core/models.py:1778 #: build/lib/core/models.py:1796 core/models.py:1796
#, python-brace-format #, python-brace-format
msgid "{name} is asking for access to the document: {title}" msgid "{name} is asking for access to the document: {title}"
msgstr "{name} está pidiendo acceso al documento: {title}" msgstr "{name} está pidiendo acceso al documento: {title}"
#: build/lib/core/models.py:1820 core/models.py:1820 #: build/lib/core/models.py:1838 core/models.py:1838
msgid "Thread" msgid "Thread"
msgstr "" msgstr ""
#: build/lib/core/models.py:1821 core/models.py:1821 #: build/lib/core/models.py:1839 core/models.py:1839
msgid "Threads" msgid "Threads"
msgstr "" msgstr ""
#: build/lib/core/models.py:1824 build/lib/core/models.py:1876 #: build/lib/core/models.py:1842 build/lib/core/models.py:1894
#: core/models.py:1824 core/models.py:1876 #: core/models.py:1842 core/models.py:1894
msgid "Anonymous" msgid "Anonymous"
msgstr "" msgstr ""
#: build/lib/core/models.py:1871 core/models.py:1871 #: build/lib/core/models.py:1889 core/models.py:1889
msgid "Comment" msgid "Comment"
msgstr "" msgstr ""
#: build/lib/core/models.py:1872 core/models.py:1872 #: build/lib/core/models.py:1890 core/models.py:1890
msgid "Comments" msgid "Comments"
msgstr "" msgstr ""
#: build/lib/core/models.py:1921 core/models.py:1921 #: build/lib/core/models.py:1939 core/models.py:1939
msgid "This emoji has already been reacted to this comment." msgid "This emoji has already been reacted to this comment."
msgstr "" msgstr ""
#: build/lib/core/models.py:1925 core/models.py:1925 #: build/lib/core/models.py:1943 core/models.py:1943
msgid "Reaction" msgid "Reaction"
msgstr "" msgstr ""
#: build/lib/core/models.py:1926 core/models.py:1926 #: build/lib/core/models.py:1944 core/models.py:1944
msgid "Reactions" msgid "Reactions"
msgstr "" msgstr ""
#: build/lib/core/models.py:1936 core/models.py:1936 #: build/lib/core/models.py:1954 core/models.py:1954
msgid "email address" msgid "email address"
msgstr "dirección de correo electrónico" msgstr "dirección de correo electrónico"
#: build/lib/core/models.py:1955 core/models.py:1955 #: build/lib/core/models.py:1973 core/models.py:1973
msgid "Document invitation" msgid "Document invitation"
msgstr "Invitación al documento" msgstr "Invitación al documento"
#: build/lib/core/models.py:1956 core/models.py:1956 #: build/lib/core/models.py:1974 core/models.py:1974
msgid "Document invitations" msgid "Document invitations"
msgstr "Invitaciones a documentos" msgstr "Invitaciones a documentos"
#: build/lib/core/models.py:1976 core/models.py:1976 #: build/lib/core/models.py:1994 core/models.py:1994
msgid "This email is already associated to a registered user." msgid "This email is already associated to a registered user."
msgstr "Este correo electrónico está asociado a un usuario registrado." msgstr "Este correo electrónico está asociado a un usuario registrado."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: lasuite-docs\n" "Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-06 09:37+0000\n" "POT-Creation-Date: 2026-03-12 13:31+0000\n"
"PO-Revision-Date: 2026-03-09 14:02\n" "PO-Revision-Date: 2026-03-13 16:53\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: French\n" "Language-Team: French\n"
"Language: fr_FR\n" "Language: fr_FR\n"
@@ -58,24 +58,24 @@ msgstr "Masqué"
msgid "Favorite" msgid "Favorite"
msgstr "Favoris" msgstr "Favoris"
#: build/lib/core/api/serializers.py:513 core/api/serializers.py:513 #: build/lib/core/api/serializers.py:526 core/api/serializers.py:526
msgid "A new document was created on your behalf!" msgid "A new document was created on your behalf!"
msgstr "Un nouveau document a été créé pour vous !" msgstr "Un nouveau document a été créé pour vous !"
#: build/lib/core/api/serializers.py:517 core/api/serializers.py:517 #: build/lib/core/api/serializers.py:530 core/api/serializers.py:530
msgid "You have been granted ownership of a new document:" msgid "You have been granted ownership of a new document:"
msgstr "Vous avez été déclaré propriétaire d'un nouveau document :" msgstr "Vous avez été déclaré propriétaire d'un nouveau document :"
#: build/lib/core/api/serializers.py:553 core/api/serializers.py:553 #: build/lib/core/api/serializers.py:566 core/api/serializers.py:566
msgid "This field is required." msgid "This field is required."
msgstr "Ce champ est obligatoire." msgstr "Ce champ est obligatoire."
#: build/lib/core/api/serializers.py:564 core/api/serializers.py:564 #: build/lib/core/api/serializers.py:577 core/api/serializers.py:577
#, python-format #, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration." msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr "La portée du lien '%(link_reach)s' n'est pas autorisée en fonction de la configuration du document parent." msgstr "La portée du lien '%(link_reach)s' n'est pas autorisée en fonction de la configuration du document parent."
#: build/lib/core/api/viewsets.py:1279 core/api/viewsets.py:1279 #: build/lib/core/api/viewsets.py:1298 core/api/viewsets.py:1298
#, python-brace-format #, python-brace-format
msgid "copy of {title}" msgid "copy of {title}"
msgstr "copie de {title}" msgstr "copie de {title}"
@@ -231,54 +231,62 @@ msgstr "actif"
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts." msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Si cet utilisateur doit être traité comme actif. Désélectionnez ceci au lieu de supprimer des comptes." msgstr "Si cet utilisateur doit être traité comme actif. Désélectionnez ceci au lieu de supprimer des comptes."
#: build/lib/core/models.py:204 core/models.py:204 #: build/lib/core/models.py:197 core/models.py:197
msgid "first connection status"
msgstr "état de la première connexion"
#: build/lib/core/models.py:199 core/models.py:199
msgid "Whether the user has completed the first connection process."
msgstr "Si l'utilisateur a terminé le processus de première connexion."
#: build/lib/core/models.py:209 core/models.py:209
msgid "user" msgid "user"
msgstr "utilisateur" msgstr "utilisateur"
#: build/lib/core/models.py:205 core/models.py:205 #: build/lib/core/models.py:210 core/models.py:210
msgid "users" msgid "users"
msgstr "utilisateurs" msgstr "utilisateurs"
#: build/lib/core/models.py:360 core/models.py:360 #: build/lib/core/models.py:378 core/models.py:378
msgid "Active email address" msgid "Active email address"
msgstr "Adresse email active" msgstr "Adresse email active"
#: build/lib/core/models.py:361 core/models.py:361 #: build/lib/core/models.py:379 core/models.py:379
msgid "Email address to deactivate" msgid "Email address to deactivate"
msgstr "Adresse email à désactiver" msgstr "Adresse email à désactiver"
#: build/lib/core/models.py:388 core/models.py:388 #: build/lib/core/models.py:406 core/models.py:406
msgid "Unique ID in the source file" msgid "Unique ID in the source file"
msgstr "Identifiant unique dans le fichier source" msgstr "Identifiant unique dans le fichier source"
#: build/lib/core/models.py:394 build/lib/core/models.py:692 core/models.py:394 #: build/lib/core/models.py:412 build/lib/core/models.py:710 core/models.py:412
#: core/models.py:692 #: core/models.py:710
msgid "Pending" msgid "Pending"
msgstr "En attente" msgstr "En attente"
#: build/lib/core/models.py:395 core/models.py:395 #: build/lib/core/models.py:413 core/models.py:413
msgid "Ready" msgid "Ready"
msgstr "Prêt" msgstr "Prêt"
#: build/lib/core/models.py:396 build/lib/core/models.py:694 core/models.py:396 #: build/lib/core/models.py:414 build/lib/core/models.py:712 core/models.py:414
#: core/models.py:694 #: core/models.py:712
msgid "Done" msgid "Done"
msgstr "Terminé" msgstr "Terminé"
#: build/lib/core/models.py:397 build/lib/core/models.py:695 core/models.py:397 #: build/lib/core/models.py:415 build/lib/core/models.py:713 core/models.py:415
#: core/models.py:695 #: core/models.py:713
msgid "Error" msgid "Error"
msgstr "Erreur" msgstr "Erreur"
#: build/lib/core/models.py:405 core/models.py:405 #: build/lib/core/models.py:423 core/models.py:423
msgid "user reconciliation" msgid "user reconciliation"
msgstr "rapprochement de l'utilisateur" msgstr "rapprochement de l'utilisateur"
#: build/lib/core/models.py:406 core/models.py:406 #: build/lib/core/models.py:424 core/models.py:424
msgid "user reconciliations" msgid "user reconciliations"
msgstr "rapprochements de l'utilisateur" msgstr "rapprochements de l'utilisateur"
#: build/lib/core/models.py:644 core/models.py:644 #: build/lib/core/models.py:662 core/models.py:662
msgid "You have requested a reconciliation of your user accounts on Docs.\n" msgid "You have requested a reconciliation of your user accounts on Docs.\n"
" To confirm that you are the one who initiated the request\n" " To confirm that you are the one who initiated the request\n"
" and that this email belongs to you:" " and that this email belongs to you:"
@@ -286,54 +294,54 @@ msgstr "Vous avez demandé un rapprochement de vos comptes utilisateur sur Docs.
" Pour confirmer que vous êtes bien à l'origine de cette demande\n" " Pour confirmer que vous êtes bien à l'origine de cette demande\n"
" et que cet e-mail vous appartient :" " et que cet e-mail vous appartient :"
#: build/lib/core/models.py:650 core/models.py:650 #: build/lib/core/models.py:668 core/models.py:668
msgid "Confirm by clicking the link to start the reconciliation" msgid "Confirm by clicking the link to start the reconciliation"
msgstr "Confirmez en cliquant sur le lien pour commencer le rapprochement" msgstr "Confirmez en cliquant sur le lien pour commencer le rapprochement"
#: build/lib/core/models.py:655 build/lib/core/models.py:761 core/models.py:655 #: build/lib/core/models.py:673 build/lib/core/models.py:779 core/models.py:673
#: core/models.py:761 #: core/models.py:779
msgid "Click here" msgid "Click here"
msgstr "Cliquez ici" msgstr "Cliquez ici"
#: build/lib/core/models.py:656 core/models.py:656 #: build/lib/core/models.py:674 core/models.py:674
msgid "Confirm" msgid "Confirm"
msgstr "Confirmer" msgstr "Confirmer"
#: build/lib/core/models.py:667 core/models.py:667 #: build/lib/core/models.py:685 core/models.py:685
msgid "Your reconciliation request has been processed.\n" msgid "Your reconciliation request has been processed.\n"
" New documents are likely associated with your account:" " New documents are likely associated with your account:"
msgstr "Votre demande de rapprochement a été traitée.\n" msgstr "Votre demande de rapprochement a été traitée.\n"
" De nouveaux documents sont probablement associés à votre compte :" " De nouveaux documents sont probablement associés à votre compte :"
#: build/lib/core/models.py:672 core/models.py:672 #: build/lib/core/models.py:690 core/models.py:690
msgid "Your accounts have been merged" msgid "Your accounts have been merged"
msgstr "Vos comptes ont été fusionnés" msgstr "Vos comptes ont été fusionnés"
#: build/lib/core/models.py:677 core/models.py:677 #: build/lib/core/models.py:695 core/models.py:695
msgid "Click here to see" msgid "Click here to see"
msgstr "Cliquez ici pour voir" msgstr "Cliquez ici pour voir"
#: build/lib/core/models.py:678 core/models.py:678 #: build/lib/core/models.py:696 core/models.py:696
msgid "See my documents" msgid "See my documents"
msgstr "Voir mes documents" msgstr "Voir mes documents"
#: build/lib/core/models.py:688 core/models.py:688 #: build/lib/core/models.py:706 core/models.py:706
msgid "CSV file" msgid "CSV file"
msgstr "Fichier CSV" msgstr "Fichier CSV"
#: build/lib/core/models.py:693 core/models.py:693 #: build/lib/core/models.py:711 core/models.py:711
msgid "Running" msgid "Running"
msgstr "En cours" msgstr "En cours"
#: build/lib/core/models.py:703 core/models.py:703 #: build/lib/core/models.py:721 core/models.py:721
msgid "user reconciliation CSV import" msgid "user reconciliation CSV import"
msgstr "importation CSV de rapprochement utilisateur" msgstr "importation CSV de rapprochement utilisateur"
#: build/lib/core/models.py:704 core/models.py:704 #: build/lib/core/models.py:722 core/models.py:722
msgid "user reconciliation CSV imports" msgid "user reconciliation CSV imports"
msgstr "importations CSV de rapprochement utilisateur" msgstr "importations CSV de rapprochement utilisateur"
#: build/lib/core/models.py:748 core/models.py:748 #: build/lib/core/models.py:766 core/models.py:766
#, python-brace-format #, python-brace-format
msgid "Your request for reconciliation was unsuccessful.\n" msgid "Your request for reconciliation was unsuccessful.\n"
" Reconciliation failed for the following email addresses:\n" " Reconciliation failed for the following email addresses:\n"
@@ -346,171 +354,171 @@ msgstr "Votre demande de rapprochement n'a pas abouti.\n"
" Veuillez vérifier qu'il n'y a pas de fautes de frappe.\n" " Veuillez vérifier qu'il n'y a pas de fautes de frappe.\n"
" Vous pouvez envoyer une nouvelle demande avec des adresses e-mail valides." " Vous pouvez envoyer une nouvelle demande avec des adresses e-mail valides."
#: build/lib/core/models.py:756 core/models.py:756 #: build/lib/core/models.py:774 core/models.py:774
msgid "Reconciliation of your Docs accounts not completed" msgid "Reconciliation of your Docs accounts not completed"
msgstr "Le rapprochement de vos comptes Docs n'est pas terminé" msgstr "Le rapprochement de vos comptes Docs n'est pas terminé"
#: build/lib/core/models.py:762 core/models.py:762 #: build/lib/core/models.py:780 core/models.py:780
msgid "Make a new request" msgid "Make a new request"
msgstr "Faire une nouvelle demande" msgstr "Faire une nouvelle demande"
#: build/lib/core/models.py:861 core/models.py:861 #: build/lib/core/models.py:879 core/models.py:879
msgid "title" msgid "title"
msgstr "titre" msgstr "titre"
#: build/lib/core/models.py:862 core/models.py:862 #: build/lib/core/models.py:880 core/models.py:880
msgid "excerpt" msgid "excerpt"
msgstr "extrait" msgstr "extrait"
#: build/lib/core/models.py:911 core/models.py:911 #: build/lib/core/models.py:929 core/models.py:929
msgid "Document" msgid "Document"
msgstr "Document" msgstr "Document"
#: build/lib/core/models.py:912 core/models.py:912 #: build/lib/core/models.py:930 core/models.py:930
msgid "Documents" msgid "Documents"
msgstr "Documents" msgstr "Documents"
#: build/lib/core/models.py:924 build/lib/core/models.py:1328 #: build/lib/core/models.py:942 build/lib/core/models.py:1346
#: core/models.py:924 core/models.py:1328 #: core/models.py:942 core/models.py:1346
msgid "Untitled Document" msgid "Untitled Document"
msgstr "Document sans titre" msgstr "Document sans titre"
#: build/lib/core/models.py:1329 core/models.py:1329 #: build/lib/core/models.py:1347 core/models.py:1347
msgid "Open" msgid "Open"
msgstr "Ouvrir" msgstr "Ouvrir"
#: build/lib/core/models.py:1364 core/models.py:1364 #: build/lib/core/models.py:1382 core/models.py:1382
#, python-brace-format #, python-brace-format
msgid "{name} shared a document with you!" msgid "{name} shared a document with you!"
msgstr "{name} a partagé un document avec vous!" msgstr "{name} a partagé un document avec vous!"
#: build/lib/core/models.py:1368 core/models.py:1368 #: build/lib/core/models.py:1386 core/models.py:1386
#, python-brace-format #, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:" 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 :" msgstr "{name} vous a invité avec le rôle \"{role}\" sur le document suivant :"
#: build/lib/core/models.py:1374 core/models.py:1374 #: build/lib/core/models.py:1392 core/models.py:1392
#, python-brace-format #, python-brace-format
msgid "{name} shared a document with you: {title}" msgid "{name} shared a document with you: {title}"
msgstr "{name} a partagé un document avec vous : {title}" msgstr "{name} a partagé un document avec vous : {title}"
#: build/lib/core/models.py:1475 core/models.py:1475 #: build/lib/core/models.py:1493 core/models.py:1493
msgid "Document/user link trace" msgid "Document/user link trace"
msgstr "Trace du lien document/utilisateur" msgstr "Trace du lien document/utilisateur"
#: build/lib/core/models.py:1476 core/models.py:1476 #: build/lib/core/models.py:1494 core/models.py:1494
msgid "Document/user link traces" msgid "Document/user link traces"
msgstr "Traces du lien document/utilisateur" msgstr "Traces du lien document/utilisateur"
#: build/lib/core/models.py:1482 core/models.py:1482 #: build/lib/core/models.py:1500 core/models.py:1500
msgid "A link trace already exists for this document/user." msgid "A link trace already exists for this document/user."
msgstr "Une trace de lien existe déjà pour ce document/utilisateur." msgstr "Une trace de lien existe déjà pour ce document/utilisateur."
#: build/lib/core/models.py:1505 core/models.py:1505 #: build/lib/core/models.py:1523 core/models.py:1523
msgid "Document favorite" msgid "Document favorite"
msgstr "Document favori" msgstr "Document favori"
#: build/lib/core/models.py:1506 core/models.py:1506 #: build/lib/core/models.py:1524 core/models.py:1524
msgid "Document favorites" msgid "Document favorites"
msgstr "Documents favoris" msgstr "Documents favoris"
#: build/lib/core/models.py:1512 core/models.py:1512 #: build/lib/core/models.py:1530 core/models.py:1530
msgid "This document is already targeted by a favorite relation instance for the same user." 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." msgstr "Ce document est déjà un favori de cet utilisateur."
#: build/lib/core/models.py:1534 core/models.py:1534 #: build/lib/core/models.py:1552 core/models.py:1552
msgid "Document/user relation" msgid "Document/user relation"
msgstr "Relation document/utilisateur" msgstr "Relation document/utilisateur"
#: build/lib/core/models.py:1535 core/models.py:1535 #: build/lib/core/models.py:1553 core/models.py:1553
msgid "Document/user relations" msgid "Document/user relations"
msgstr "Relations document/utilisateur" msgstr "Relations document/utilisateur"
#: build/lib/core/models.py:1541 core/models.py:1541 #: build/lib/core/models.py:1559 core/models.py:1559
msgid "This user is already in this document." msgid "This user is already in this document."
msgstr "Cet utilisateur est déjà dans ce document." msgstr "Cet utilisateur est déjà dans ce document."
#: build/lib/core/models.py:1547 core/models.py:1547 #: build/lib/core/models.py:1565 core/models.py:1565
msgid "This team is already in this document." msgid "This team is already in this document."
msgstr "Cette équipe est déjà dans ce document." msgstr "Cette équipe est déjà dans ce document."
#: build/lib/core/models.py:1553 core/models.py:1553 #: build/lib/core/models.py:1571 core/models.py:1571
msgid "Either user or team must be set, not both." msgid "Either user or team must be set, not both."
msgstr "L'utilisateur ou l'équipe doivent être définis, pas les deux." msgstr "L'utilisateur ou l'équipe doivent être définis, pas les deux."
#: build/lib/core/models.py:1704 core/models.py:1704 #: build/lib/core/models.py:1722 core/models.py:1722
msgid "Document ask for access" msgid "Document ask for access"
msgstr "Demande d'accès au document" msgstr "Demande d'accès au document"
#: build/lib/core/models.py:1705 core/models.py:1705 #: build/lib/core/models.py:1723 core/models.py:1723
msgid "Document ask for accesses" msgid "Document ask for accesses"
msgstr "Demande d'accès au document" msgstr "Demande d'accès au document"
#: build/lib/core/models.py:1711 core/models.py:1711 #: build/lib/core/models.py:1729 core/models.py:1729
msgid "This user has already asked for access to this document." msgid "This user has already asked for access to this document."
msgstr "Cet utilisateur a déjà demandé l'accès à ce document." msgstr "Cet utilisateur a déjà demandé l'accès à ce document."
#: build/lib/core/models.py:1768 core/models.py:1768 #: build/lib/core/models.py:1786 core/models.py:1786
#, python-brace-format #, python-brace-format
msgid "{name} would like access to a document!" msgid "{name} would like access to a document!"
msgstr "{name} souhaiterait accéder au document suivant !" msgstr "{name} souhaiterait accéder au document suivant !"
#: build/lib/core/models.py:1772 core/models.py:1772 #: build/lib/core/models.py:1790 core/models.py:1790
#, python-brace-format #, python-brace-format
msgid "{name} would like access to the following document:" msgid "{name} would like access to the following document:"
msgstr "{name} souhaiterait accéder au document suivant :" msgstr "{name} souhaiterait accéder au document suivant :"
#: build/lib/core/models.py:1778 core/models.py:1778 #: build/lib/core/models.py:1796 core/models.py:1796
#, python-brace-format #, python-brace-format
msgid "{name} is asking for access to the document: {title}" msgid "{name} is asking for access to the document: {title}"
msgstr "{name} demande l'accès au document : {title}" msgstr "{name} demande l'accès au document : {title}"
#: build/lib/core/models.py:1820 core/models.py:1820 #: build/lib/core/models.py:1838 core/models.py:1838
msgid "Thread" msgid "Thread"
msgstr "Conversation" msgstr "Conversation"
#: build/lib/core/models.py:1821 core/models.py:1821 #: build/lib/core/models.py:1839 core/models.py:1839
msgid "Threads" msgid "Threads"
msgstr "Conversations" msgstr "Conversations"
#: build/lib/core/models.py:1824 build/lib/core/models.py:1876 #: build/lib/core/models.py:1842 build/lib/core/models.py:1894
#: core/models.py:1824 core/models.py:1876 #: core/models.py:1842 core/models.py:1894
msgid "Anonymous" msgid "Anonymous"
msgstr "Anonyme" msgstr "Anonyme"
#: build/lib/core/models.py:1871 core/models.py:1871 #: build/lib/core/models.py:1889 core/models.py:1889
msgid "Comment" msgid "Comment"
msgstr "Commentaire" msgstr "Commentaire"
#: build/lib/core/models.py:1872 core/models.py:1872 #: build/lib/core/models.py:1890 core/models.py:1890
msgid "Comments" msgid "Comments"
msgstr "Commentaires" msgstr "Commentaires"
#: build/lib/core/models.py:1921 core/models.py:1921 #: build/lib/core/models.py:1939 core/models.py:1939
msgid "This emoji has already been reacted to this comment." msgid "This emoji has already been reacted to this comment."
msgstr "Cet émoji a déjà été réagi à ce commentaire." msgstr "Cet émoji a déjà été réagi à ce commentaire."
#: build/lib/core/models.py:1925 core/models.py:1925 #: build/lib/core/models.py:1943 core/models.py:1943
msgid "Reaction" msgid "Reaction"
msgstr "Réaction" msgstr "Réaction"
#: build/lib/core/models.py:1926 core/models.py:1926 #: build/lib/core/models.py:1944 core/models.py:1944
msgid "Reactions" msgid "Reactions"
msgstr "Réactions" msgstr "Réactions"
#: build/lib/core/models.py:1936 core/models.py:1936 #: build/lib/core/models.py:1954 core/models.py:1954
msgid "email address" msgid "email address"
msgstr "adresse e-mail" msgstr "adresse e-mail"
#: build/lib/core/models.py:1955 core/models.py:1955 #: build/lib/core/models.py:1973 core/models.py:1973
msgid "Document invitation" msgid "Document invitation"
msgstr "Invitation à un document" msgstr "Invitation à un document"
#: build/lib/core/models.py:1956 core/models.py:1956 #: build/lib/core/models.py:1974 core/models.py:1974
msgid "Document invitations" msgid "Document invitations"
msgstr "Invitations à un document" msgstr "Invitations à un document"
#: build/lib/core/models.py:1976 core/models.py:1976 #: build/lib/core/models.py:1994 core/models.py:1994
msgid "This email is already associated to a registered user." msgid "This email is already associated to a registered user."
msgstr "Cette adresse email est déjà associée à un utilisateur inscrit." msgstr "Cette adresse email est déjà associée à un utilisateur inscrit."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: lasuite-docs\n" "Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-06 09:37+0000\n" "POT-Creation-Date: 2026-03-12 13:31+0000\n"
"PO-Revision-Date: 2026-03-09 14:02\n" "PO-Revision-Date: 2026-03-13 16:53\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Italian\n" "Language-Team: Italian\n"
"Language: it_IT\n" "Language: it_IT\n"
@@ -58,24 +58,24 @@ msgstr ""
msgid "Favorite" msgid "Favorite"
msgstr "Preferiti" msgstr "Preferiti"
#: build/lib/core/api/serializers.py:513 core/api/serializers.py:513 #: build/lib/core/api/serializers.py:526 core/api/serializers.py:526
msgid "A new document was created on your behalf!" msgid "A new document was created on your behalf!"
msgstr "Un nuovo documento è stato creato a tuo nome!" msgstr "Un nuovo documento è stato creato a tuo nome!"
#: build/lib/core/api/serializers.py:517 core/api/serializers.py:517 #: build/lib/core/api/serializers.py:530 core/api/serializers.py:530
msgid "You have been granted ownership of a new document:" msgid "You have been granted ownership of a new document:"
msgstr "Sei ora proprietario di un nuovo documento:" msgstr "Sei ora proprietario di un nuovo documento:"
#: build/lib/core/api/serializers.py:553 core/api/serializers.py:553 #: build/lib/core/api/serializers.py:566 core/api/serializers.py:566
msgid "This field is required." msgid "This field is required."
msgstr "" msgstr ""
#: build/lib/core/api/serializers.py:564 core/api/serializers.py:564 #: build/lib/core/api/serializers.py:577 core/api/serializers.py:577
#, python-format #, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration." msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr "" msgstr ""
#: build/lib/core/api/viewsets.py:1279 core/api/viewsets.py:1279 #: build/lib/core/api/viewsets.py:1298 core/api/viewsets.py:1298
#, python-brace-format #, python-brace-format
msgid "copy of {title}" msgid "copy of {title}"
msgstr "copia di {title}" msgstr "copia di {title}"
@@ -231,106 +231,114 @@ msgstr "attivo"
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts." msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Indica se questo utente deve essere trattato come attivo. Deseleziona invece di eliminare gli account." msgstr "Indica se questo utente deve essere trattato come attivo. Deseleziona invece di eliminare gli account."
#: build/lib/core/models.py:204 core/models.py:204 #: build/lib/core/models.py:197 core/models.py:197
msgid "first connection status"
msgstr ""
#: build/lib/core/models.py:199 core/models.py:199
msgid "Whether the user has completed the first connection process."
msgstr ""
#: build/lib/core/models.py:209 core/models.py:209
msgid "user" msgid "user"
msgstr "utente" msgstr "utente"
#: build/lib/core/models.py:205 core/models.py:205 #: build/lib/core/models.py:210 core/models.py:210
msgid "users" msgid "users"
msgstr "utenti" msgstr "utenti"
#: build/lib/core/models.py:360 core/models.py:360 #: build/lib/core/models.py:378 core/models.py:378
msgid "Active email address" msgid "Active email address"
msgstr "" msgstr ""
#: build/lib/core/models.py:361 core/models.py:361 #: build/lib/core/models.py:379 core/models.py:379
msgid "Email address to deactivate" msgid "Email address to deactivate"
msgstr "" msgstr ""
#: build/lib/core/models.py:388 core/models.py:388 #: build/lib/core/models.py:406 core/models.py:406
msgid "Unique ID in the source file" msgid "Unique ID in the source file"
msgstr "" msgstr ""
#: build/lib/core/models.py:394 build/lib/core/models.py:692 core/models.py:394 #: build/lib/core/models.py:412 build/lib/core/models.py:710 core/models.py:412
#: core/models.py:692 #: core/models.py:710
msgid "Pending" msgid "Pending"
msgstr "" msgstr ""
#: build/lib/core/models.py:395 core/models.py:395 #: build/lib/core/models.py:413 core/models.py:413
msgid "Ready" msgid "Ready"
msgstr "" msgstr ""
#: build/lib/core/models.py:396 build/lib/core/models.py:694 core/models.py:396 #: build/lib/core/models.py:414 build/lib/core/models.py:712 core/models.py:414
#: core/models.py:694 #: core/models.py:712
msgid "Done" msgid "Done"
msgstr "" msgstr ""
#: build/lib/core/models.py:397 build/lib/core/models.py:695 core/models.py:397 #: build/lib/core/models.py:415 build/lib/core/models.py:713 core/models.py:415
#: core/models.py:695 #: core/models.py:713
msgid "Error" msgid "Error"
msgstr "" msgstr ""
#: build/lib/core/models.py:405 core/models.py:405 #: build/lib/core/models.py:423 core/models.py:423
msgid "user reconciliation" msgid "user reconciliation"
msgstr "" msgstr ""
#: build/lib/core/models.py:406 core/models.py:406 #: build/lib/core/models.py:424 core/models.py:424
msgid "user reconciliations" msgid "user reconciliations"
msgstr "" msgstr ""
#: build/lib/core/models.py:644 core/models.py:644 #: build/lib/core/models.py:662 core/models.py:662
msgid "You have requested a reconciliation of your user accounts on Docs.\n" msgid "You have requested a reconciliation of your user accounts on Docs.\n"
" To confirm that you are the one who initiated the request\n" " To confirm that you are the one who initiated the request\n"
" and that this email belongs to you:" " and that this email belongs to you:"
msgstr "" msgstr ""
#: build/lib/core/models.py:650 core/models.py:650 #: build/lib/core/models.py:668 core/models.py:668
msgid "Confirm by clicking the link to start the reconciliation" msgid "Confirm by clicking the link to start the reconciliation"
msgstr "" msgstr ""
#: build/lib/core/models.py:655 build/lib/core/models.py:761 core/models.py:655 #: build/lib/core/models.py:673 build/lib/core/models.py:779 core/models.py:673
#: core/models.py:761 #: core/models.py:779
msgid "Click here" msgid "Click here"
msgstr "" msgstr ""
#: build/lib/core/models.py:656 core/models.py:656 #: build/lib/core/models.py:674 core/models.py:674
msgid "Confirm" msgid "Confirm"
msgstr "" msgstr ""
#: build/lib/core/models.py:667 core/models.py:667 #: build/lib/core/models.py:685 core/models.py:685
msgid "Your reconciliation request has been processed.\n" msgid "Your reconciliation request has been processed.\n"
" New documents are likely associated with your account:" " New documents are likely associated with your account:"
msgstr "" msgstr ""
#: build/lib/core/models.py:672 core/models.py:672 #: build/lib/core/models.py:690 core/models.py:690
msgid "Your accounts have been merged" msgid "Your accounts have been merged"
msgstr "" msgstr ""
#: build/lib/core/models.py:677 core/models.py:677 #: build/lib/core/models.py:695 core/models.py:695
msgid "Click here to see" msgid "Click here to see"
msgstr "" msgstr ""
#: build/lib/core/models.py:678 core/models.py:678 #: build/lib/core/models.py:696 core/models.py:696
msgid "See my documents" msgid "See my documents"
msgstr "" msgstr ""
#: build/lib/core/models.py:688 core/models.py:688 #: build/lib/core/models.py:706 core/models.py:706
msgid "CSV file" msgid "CSV file"
msgstr "" msgstr ""
#: build/lib/core/models.py:693 core/models.py:693 #: build/lib/core/models.py:711 core/models.py:711
msgid "Running" msgid "Running"
msgstr "" msgstr ""
#: build/lib/core/models.py:703 core/models.py:703 #: build/lib/core/models.py:721 core/models.py:721
msgid "user reconciliation CSV import" msgid "user reconciliation CSV import"
msgstr "" msgstr ""
#: build/lib/core/models.py:704 core/models.py:704 #: build/lib/core/models.py:722 core/models.py:722
msgid "user reconciliation CSV imports" msgid "user reconciliation CSV imports"
msgstr "" msgstr ""
#: build/lib/core/models.py:748 core/models.py:748 #: build/lib/core/models.py:766 core/models.py:766
#, python-brace-format #, python-brace-format
msgid "Your request for reconciliation was unsuccessful.\n" msgid "Your request for reconciliation was unsuccessful.\n"
" Reconciliation failed for the following email addresses:\n" " Reconciliation failed for the following email addresses:\n"
@@ -339,171 +347,171 @@ msgid "Your request for reconciliation was unsuccessful.\n"
" You can submit another request with the valid email addresses." " You can submit another request with the valid email addresses."
msgstr "" msgstr ""
#: build/lib/core/models.py:756 core/models.py:756 #: build/lib/core/models.py:774 core/models.py:774
msgid "Reconciliation of your Docs accounts not completed" msgid "Reconciliation of your Docs accounts not completed"
msgstr "" msgstr ""
#: build/lib/core/models.py:762 core/models.py:762 #: build/lib/core/models.py:780 core/models.py:780
msgid "Make a new request" msgid "Make a new request"
msgstr "" msgstr ""
#: build/lib/core/models.py:861 core/models.py:861 #: build/lib/core/models.py:879 core/models.py:879
msgid "title" msgid "title"
msgstr "titolo" msgstr "titolo"
#: build/lib/core/models.py:862 core/models.py:862 #: build/lib/core/models.py:880 core/models.py:880
msgid "excerpt" msgid "excerpt"
msgstr "" msgstr ""
#: build/lib/core/models.py:911 core/models.py:911 #: build/lib/core/models.py:929 core/models.py:929
msgid "Document" msgid "Document"
msgstr "Documento" msgstr "Documento"
#: build/lib/core/models.py:912 core/models.py:912 #: build/lib/core/models.py:930 core/models.py:930
msgid "Documents" msgid "Documents"
msgstr "Documenti" msgstr "Documenti"
#: build/lib/core/models.py:924 build/lib/core/models.py:1328 #: build/lib/core/models.py:942 build/lib/core/models.py:1346
#: core/models.py:924 core/models.py:1328 #: core/models.py:942 core/models.py:1346
msgid "Untitled Document" msgid "Untitled Document"
msgstr "Documento senza titolo" msgstr "Documento senza titolo"
#: build/lib/core/models.py:1329 core/models.py:1329 #: build/lib/core/models.py:1347 core/models.py:1347
msgid "Open" msgid "Open"
msgstr "Apri" msgstr "Apri"
#: build/lib/core/models.py:1364 core/models.py:1364 #: build/lib/core/models.py:1382 core/models.py:1382
#, python-brace-format #, python-brace-format
msgid "{name} shared a document with you!" msgid "{name} shared a document with you!"
msgstr "{name} ha condiviso un documento con te!" msgstr "{name} ha condiviso un documento con te!"
#: build/lib/core/models.py:1368 core/models.py:1368 #: build/lib/core/models.py:1386 core/models.py:1386
#, python-brace-format #, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:" msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} ti ha invitato con il ruolo \"{role}\" nel seguente documento:" msgstr "{name} ti ha invitato con il ruolo \"{role}\" nel seguente documento:"
#: build/lib/core/models.py:1374 core/models.py:1374 #: build/lib/core/models.py:1392 core/models.py:1392
#, python-brace-format #, python-brace-format
msgid "{name} shared a document with you: {title}" msgid "{name} shared a document with you: {title}"
msgstr "{name} ha condiviso un documento con te: {title}" msgstr "{name} ha condiviso un documento con te: {title}"
#: build/lib/core/models.py:1475 core/models.py:1475 #: build/lib/core/models.py:1493 core/models.py:1493
msgid "Document/user link trace" msgid "Document/user link trace"
msgstr "" msgstr ""
#: build/lib/core/models.py:1476 core/models.py:1476 #: build/lib/core/models.py:1494 core/models.py:1494
msgid "Document/user link traces" msgid "Document/user link traces"
msgstr "" msgstr ""
#: build/lib/core/models.py:1482 core/models.py:1482 #: build/lib/core/models.py:1500 core/models.py:1500
msgid "A link trace already exists for this document/user." msgid "A link trace already exists for this document/user."
msgstr "" msgstr ""
#: build/lib/core/models.py:1505 core/models.py:1505 #: build/lib/core/models.py:1523 core/models.py:1523
msgid "Document favorite" msgid "Document favorite"
msgstr "Documento preferito" msgstr "Documento preferito"
#: build/lib/core/models.py:1506 core/models.py:1506 #: build/lib/core/models.py:1524 core/models.py:1524
msgid "Document favorites" msgid "Document favorites"
msgstr "Documenti preferiti" msgstr "Documenti preferiti"
#: build/lib/core/models.py:1512 core/models.py:1512 #: build/lib/core/models.py:1530 core/models.py:1530
msgid "This document is already targeted by a favorite relation instance for the same user." msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "" msgstr ""
#: build/lib/core/models.py:1534 core/models.py:1534 #: build/lib/core/models.py:1552 core/models.py:1552
msgid "Document/user relation" msgid "Document/user relation"
msgstr "" msgstr ""
#: build/lib/core/models.py:1535 core/models.py:1535 #: build/lib/core/models.py:1553 core/models.py:1553
msgid "Document/user relations" msgid "Document/user relations"
msgstr "" msgstr ""
#: build/lib/core/models.py:1541 core/models.py:1541 #: build/lib/core/models.py:1559 core/models.py:1559
msgid "This user is already in this document." msgid "This user is already in this document."
msgstr "Questo utente è già presente in questo documento." msgstr "Questo utente è già presente in questo documento."
#: build/lib/core/models.py:1547 core/models.py:1547 #: build/lib/core/models.py:1565 core/models.py:1565
msgid "This team is already in this document." msgid "This team is already in this document."
msgstr "Questo team è già presente in questo documento." msgstr "Questo team è già presente in questo documento."
#: build/lib/core/models.py:1553 core/models.py:1553 #: build/lib/core/models.py:1571 core/models.py:1571
msgid "Either user or team must be set, not both." msgid "Either user or team must be set, not both."
msgstr "" msgstr ""
#: build/lib/core/models.py:1704 core/models.py:1704 #: build/lib/core/models.py:1722 core/models.py:1722
msgid "Document ask for access" msgid "Document ask for access"
msgstr "" msgstr ""
#: build/lib/core/models.py:1705 core/models.py:1705 #: build/lib/core/models.py:1723 core/models.py:1723
msgid "Document ask for accesses" msgid "Document ask for accesses"
msgstr "" msgstr ""
#: build/lib/core/models.py:1711 core/models.py:1711 #: build/lib/core/models.py:1729 core/models.py:1729
msgid "This user has already asked for access to this document." msgid "This user has already asked for access to this document."
msgstr "" msgstr ""
#: build/lib/core/models.py:1768 core/models.py:1768 #: build/lib/core/models.py:1786 core/models.py:1786
#, python-brace-format #, python-brace-format
msgid "{name} would like access to a document!" msgid "{name} would like access to a document!"
msgstr "" msgstr ""
#: build/lib/core/models.py:1772 core/models.py:1772 #: build/lib/core/models.py:1790 core/models.py:1790
#, python-brace-format #, python-brace-format
msgid "{name} would like access to the following document:" msgid "{name} would like access to the following document:"
msgstr "" msgstr ""
#: build/lib/core/models.py:1778 core/models.py:1778 #: build/lib/core/models.py:1796 core/models.py:1796
#, python-brace-format #, python-brace-format
msgid "{name} is asking for access to the document: {title}" msgid "{name} is asking for access to the document: {title}"
msgstr "" msgstr ""
#: build/lib/core/models.py:1820 core/models.py:1820 #: build/lib/core/models.py:1838 core/models.py:1838
msgid "Thread" msgid "Thread"
msgstr "" msgstr ""
#: build/lib/core/models.py:1821 core/models.py:1821 #: build/lib/core/models.py:1839 core/models.py:1839
msgid "Threads" msgid "Threads"
msgstr "" msgstr ""
#: build/lib/core/models.py:1824 build/lib/core/models.py:1876 #: build/lib/core/models.py:1842 build/lib/core/models.py:1894
#: core/models.py:1824 core/models.py:1876 #: core/models.py:1842 core/models.py:1894
msgid "Anonymous" msgid "Anonymous"
msgstr "" msgstr ""
#: build/lib/core/models.py:1871 core/models.py:1871 #: build/lib/core/models.py:1889 core/models.py:1889
msgid "Comment" msgid "Comment"
msgstr "" msgstr ""
#: build/lib/core/models.py:1872 core/models.py:1872 #: build/lib/core/models.py:1890 core/models.py:1890
msgid "Comments" msgid "Comments"
msgstr "" msgstr ""
#: build/lib/core/models.py:1921 core/models.py:1921 #: build/lib/core/models.py:1939 core/models.py:1939
msgid "This emoji has already been reacted to this comment." msgid "This emoji has already been reacted to this comment."
msgstr "" msgstr ""
#: build/lib/core/models.py:1925 core/models.py:1925 #: build/lib/core/models.py:1943 core/models.py:1943
msgid "Reaction" msgid "Reaction"
msgstr "" msgstr ""
#: build/lib/core/models.py:1926 core/models.py:1926 #: build/lib/core/models.py:1944 core/models.py:1944
msgid "Reactions" msgid "Reactions"
msgstr "" msgstr ""
#: build/lib/core/models.py:1936 core/models.py:1936 #: build/lib/core/models.py:1954 core/models.py:1954
msgid "email address" msgid "email address"
msgstr "indirizzo e-mail" msgstr "indirizzo e-mail"
#: build/lib/core/models.py:1955 core/models.py:1955 #: build/lib/core/models.py:1973 core/models.py:1973
msgid "Document invitation" msgid "Document invitation"
msgstr "Invito al documento" msgstr "Invito al documento"
#: build/lib/core/models.py:1956 core/models.py:1956 #: build/lib/core/models.py:1974 core/models.py:1974
msgid "Document invitations" msgid "Document invitations"
msgstr "Inviti al documento" msgstr "Inviti al documento"
#: build/lib/core/models.py:1976 core/models.py:1976 #: build/lib/core/models.py:1994 core/models.py:1994
msgid "This email is already associated to a registered user." msgid "This email is already associated to a registered user."
msgstr "Questa email è già associata a un utente registrato." msgstr "Questa email è già associata a un utente registrato."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: lasuite-docs\n" "Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-06 09:37+0000\n" "POT-Creation-Date: 2026-03-12 13:31+0000\n"
"PO-Revision-Date: 2026-03-09 14:02\n" "PO-Revision-Date: 2026-03-13 16:53\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Dutch\n" "Language-Team: Dutch\n"
"Language: nl_NL\n" "Language: nl_NL\n"
@@ -58,24 +58,24 @@ msgstr "Gemaskeerd"
msgid "Favorite" msgid "Favorite"
msgstr "Favoriet" msgstr "Favoriet"
#: build/lib/core/api/serializers.py:513 core/api/serializers.py:513 #: build/lib/core/api/serializers.py:526 core/api/serializers.py:526
msgid "A new document was created on your behalf!" msgid "A new document was created on your behalf!"
msgstr "Een nieuw document is namens u gemaakt!" msgstr "Een nieuw document is namens u gemaakt!"
#: build/lib/core/api/serializers.py:517 core/api/serializers.py:517 #: build/lib/core/api/serializers.py:530 core/api/serializers.py:530
msgid "You have been granted ownership of a new document:" msgid "You have been granted ownership of a new document:"
msgstr "U heeft eigenaarschap van een nieuw document gekregen:" msgstr "U heeft eigenaarschap van een nieuw document gekregen:"
#: build/lib/core/api/serializers.py:553 core/api/serializers.py:553 #: build/lib/core/api/serializers.py:566 core/api/serializers.py:566
msgid "This field is required." msgid "This field is required."
msgstr "Dit veld is verplicht." msgstr "Dit veld is verplicht."
#: build/lib/core/api/serializers.py:564 core/api/serializers.py:564 #: build/lib/core/api/serializers.py:577 core/api/serializers.py:577
#, python-format #, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration." msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr "Link bereik '%(link_reach)s' is niet toegestaan op basis van bovenliggende documentconfiguratie." msgstr "Link bereik '%(link_reach)s' is niet toegestaan op basis van bovenliggende documentconfiguratie."
#: build/lib/core/api/viewsets.py:1279 core/api/viewsets.py:1279 #: build/lib/core/api/viewsets.py:1298 core/api/viewsets.py:1298
#, python-brace-format #, python-brace-format
msgid "copy of {title}" msgid "copy of {title}"
msgstr "kopie van {title}" msgstr "kopie van {title}"
@@ -231,54 +231,62 @@ msgstr "actief"
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts." msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Of een gebruiker als actief moet worden beschouwd. Deselecteer dit in plaats van het account te deleten." msgstr "Of een gebruiker als actief moet worden beschouwd. Deselecteer dit in plaats van het account te deleten."
#: build/lib/core/models.py:204 core/models.py:204 #: build/lib/core/models.py:197 core/models.py:197
msgid "first connection status"
msgstr ""
#: build/lib/core/models.py:199 core/models.py:199
msgid "Whether the user has completed the first connection process."
msgstr ""
#: build/lib/core/models.py:209 core/models.py:209
msgid "user" msgid "user"
msgstr "gebruiker" msgstr "gebruiker"
#: build/lib/core/models.py:205 core/models.py:205 #: build/lib/core/models.py:210 core/models.py:210
msgid "users" msgid "users"
msgstr "gebruikers" msgstr "gebruikers"
#: build/lib/core/models.py:360 core/models.py:360 #: build/lib/core/models.py:378 core/models.py:378
msgid "Active email address" msgid "Active email address"
msgstr "Actieve e-mail adres" msgstr "Actieve e-mail adres"
#: build/lib/core/models.py:361 core/models.py:361 #: build/lib/core/models.py:379 core/models.py:379
msgid "Email address to deactivate" msgid "Email address to deactivate"
msgstr "E-mailadres om te deactiveren" msgstr "E-mailadres om te deactiveren"
#: build/lib/core/models.py:388 core/models.py:388 #: build/lib/core/models.py:406 core/models.py:406
msgid "Unique ID in the source file" msgid "Unique ID in the source file"
msgstr "Unieke ID in het bronbestand" msgstr "Unieke ID in het bronbestand"
#: build/lib/core/models.py:394 build/lib/core/models.py:692 core/models.py:394 #: build/lib/core/models.py:412 build/lib/core/models.py:710 core/models.py:412
#: core/models.py:692 #: core/models.py:710
msgid "Pending" msgid "Pending"
msgstr "In behandeling" msgstr "In behandeling"
#: build/lib/core/models.py:395 core/models.py:395 #: build/lib/core/models.py:413 core/models.py:413
msgid "Ready" msgid "Ready"
msgstr "Klaar" msgstr "Klaar"
#: build/lib/core/models.py:396 build/lib/core/models.py:694 core/models.py:396 #: build/lib/core/models.py:414 build/lib/core/models.py:712 core/models.py:414
#: core/models.py:694 #: core/models.py:712
msgid "Done" msgid "Done"
msgstr "Klaar" msgstr "Klaar"
#: build/lib/core/models.py:397 build/lib/core/models.py:695 core/models.py:397 #: build/lib/core/models.py:415 build/lib/core/models.py:713 core/models.py:415
#: core/models.py:695 #: core/models.py:713
msgid "Error" msgid "Error"
msgstr "Fout" msgstr "Fout"
#: build/lib/core/models.py:405 core/models.py:405 #: build/lib/core/models.py:423 core/models.py:423
msgid "user reconciliation" msgid "user reconciliation"
msgstr "gebruiker samenvoegen" msgstr "gebruiker samenvoegen"
#: build/lib/core/models.py:406 core/models.py:406 #: build/lib/core/models.py:424 core/models.py:424
msgid "user reconciliations" msgid "user reconciliations"
msgstr "gebruikers samenvoegen" msgstr "gebruikers samenvoegen"
#: build/lib/core/models.py:644 core/models.py:644 #: build/lib/core/models.py:662 core/models.py:662
msgid "You have requested a reconciliation of your user accounts on Docs.\n" msgid "You have requested a reconciliation of your user accounts on Docs.\n"
" To confirm that you are the one who initiated the request\n" " To confirm that you are the one who initiated the request\n"
" and that this email belongs to you:" " and that this email belongs to you:"
@@ -286,54 +294,54 @@ msgstr "Je hebt gevraagd om een samenvoeging van je gebruikersaccounts op Docs.\
" Om te bevestigen dat u degene bent die het verzoek\n" " Om te bevestigen dat u degene bent die het verzoek\n"
" heeft geïnitieerd en dat deze e-mail van u is:" " heeft geïnitieerd en dat deze e-mail van u is:"
#: build/lib/core/models.py:650 core/models.py:650 #: build/lib/core/models.py:668 core/models.py:668
msgid "Confirm by clicking the link to start the reconciliation" msgid "Confirm by clicking the link to start the reconciliation"
msgstr "Bevestig door te klikken op de link om de samenvoeging te starten" msgstr "Bevestig door te klikken op de link om de samenvoeging te starten"
#: build/lib/core/models.py:655 build/lib/core/models.py:761 core/models.py:655 #: build/lib/core/models.py:673 build/lib/core/models.py:779 core/models.py:673
#: core/models.py:761 #: core/models.py:779
msgid "Click here" msgid "Click here"
msgstr "Klik hier" msgstr "Klik hier"
#: build/lib/core/models.py:656 core/models.py:656 #: build/lib/core/models.py:674 core/models.py:674
msgid "Confirm" msgid "Confirm"
msgstr "Bevestig" msgstr "Bevestig"
#: build/lib/core/models.py:667 core/models.py:667 #: build/lib/core/models.py:685 core/models.py:685
msgid "Your reconciliation request has been processed.\n" msgid "Your reconciliation request has been processed.\n"
" New documents are likely associated with your account:" " New documents are likely associated with your account:"
msgstr "Uw samenvoegingsverzoek is verwerkt.\n" msgstr "Uw samenvoegingsverzoek is verwerkt.\n"
" Nieuwe documenten worden waarschijnlijk geassocieerd met uw account:" " Nieuwe documenten worden waarschijnlijk geassocieerd met uw account:"
#: build/lib/core/models.py:672 core/models.py:672 #: build/lib/core/models.py:690 core/models.py:690
msgid "Your accounts have been merged" msgid "Your accounts have been merged"
msgstr "Je accounts zijn samengevoegd" msgstr "Je accounts zijn samengevoegd"
#: build/lib/core/models.py:677 core/models.py:677 #: build/lib/core/models.py:695 core/models.py:695
msgid "Click here to see" msgid "Click here to see"
msgstr "Klik hier om te bekijken" msgstr "Klik hier om te bekijken"
#: build/lib/core/models.py:678 core/models.py:678 #: build/lib/core/models.py:696 core/models.py:696
msgid "See my documents" msgid "See my documents"
msgstr "Mijn documenten bekijken" msgstr "Mijn documenten bekijken"
#: build/lib/core/models.py:688 core/models.py:688 #: build/lib/core/models.py:706 core/models.py:706
msgid "CSV file" msgid "CSV file"
msgstr "CSV bestand" msgstr "CSV bestand"
#: build/lib/core/models.py:693 core/models.py:693 #: build/lib/core/models.py:711 core/models.py:711
msgid "Running" msgid "Running"
msgstr "Bezig" msgstr "Bezig"
#: build/lib/core/models.py:703 core/models.py:703 #: build/lib/core/models.py:721 core/models.py:721
msgid "user reconciliation CSV import" msgid "user reconciliation CSV import"
msgstr "gebruiker samenvoeging CSV import" msgstr "gebruiker samenvoeging CSV import"
#: build/lib/core/models.py:704 core/models.py:704 #: build/lib/core/models.py:722 core/models.py:722
msgid "user reconciliation CSV imports" msgid "user reconciliation CSV imports"
msgstr "gebruiker reconciliation CSV imports" msgstr "gebruiker reconciliation CSV imports"
#: build/lib/core/models.py:748 core/models.py:748 #: build/lib/core/models.py:766 core/models.py:766
#, python-brace-format #, python-brace-format
msgid "Your request for reconciliation was unsuccessful.\n" msgid "Your request for reconciliation was unsuccessful.\n"
" Reconciliation failed for the following email addresses:\n" " Reconciliation failed for the following email addresses:\n"
@@ -346,171 +354,171 @@ msgstr "Uw verzoek tot verzoening is mislukt.\n"
" Controleer op typefouten.\n" " Controleer op typefouten.\n"
" U kunt een ander verzoek indienen met de geldige e-mailadressen." " U kunt een ander verzoek indienen met de geldige e-mailadressen."
#: build/lib/core/models.py:756 core/models.py:756 #: build/lib/core/models.py:774 core/models.py:774
msgid "Reconciliation of your Docs accounts not completed" msgid "Reconciliation of your Docs accounts not completed"
msgstr "Samenvoeging van je Docs accounts is niet voltooid" msgstr "Samenvoeging van je Docs accounts is niet voltooid"
#: build/lib/core/models.py:762 core/models.py:762 #: build/lib/core/models.py:780 core/models.py:780
msgid "Make a new request" msgid "Make a new request"
msgstr "Maak een nieuw verzoek" msgstr "Maak een nieuw verzoek"
#: build/lib/core/models.py:861 core/models.py:861 #: build/lib/core/models.py:879 core/models.py:879
msgid "title" msgid "title"
msgstr "titel" msgstr "titel"
#: build/lib/core/models.py:862 core/models.py:862 #: build/lib/core/models.py:880 core/models.py:880
msgid "excerpt" msgid "excerpt"
msgstr "uittreksel" msgstr "uittreksel"
#: build/lib/core/models.py:911 core/models.py:911 #: build/lib/core/models.py:929 core/models.py:929
msgid "Document" msgid "Document"
msgstr "Document" msgstr "Document"
#: build/lib/core/models.py:912 core/models.py:912 #: build/lib/core/models.py:930 core/models.py:930
msgid "Documents" msgid "Documents"
msgstr "Documenten" msgstr "Documenten"
#: build/lib/core/models.py:924 build/lib/core/models.py:1328 #: build/lib/core/models.py:942 build/lib/core/models.py:1346
#: core/models.py:924 core/models.py:1328 #: core/models.py:942 core/models.py:1346
msgid "Untitled Document" msgid "Untitled Document"
msgstr "Naamloos Document" msgstr "Naamloos Document"
#: build/lib/core/models.py:1329 core/models.py:1329 #: build/lib/core/models.py:1347 core/models.py:1347
msgid "Open" msgid "Open"
msgstr "Open" msgstr "Open"
#: build/lib/core/models.py:1364 core/models.py:1364 #: build/lib/core/models.py:1382 core/models.py:1382
#, python-brace-format #, python-brace-format
msgid "{name} shared a document with you!" msgid "{name} shared a document with you!"
msgstr "{name} heeft een document met u gedeeld!" msgstr "{name} heeft een document met u gedeeld!"
#: build/lib/core/models.py:1368 core/models.py:1368 #: build/lib/core/models.py:1386 core/models.py:1386
#, python-brace-format #, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:" 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:" msgstr "{name} heeft u uitgenodigd met de rol \"{role}\" op het volgende document:"
#: build/lib/core/models.py:1374 core/models.py:1374 #: build/lib/core/models.py:1392 core/models.py:1392
#, python-brace-format #, python-brace-format
msgid "{name} shared a document with you: {title}" msgid "{name} shared a document with you: {title}"
msgstr "{name} heeft een document met u gedeeld: {title}" msgstr "{name} heeft een document met u gedeeld: {title}"
#: build/lib/core/models.py:1475 core/models.py:1475 #: build/lib/core/models.py:1493 core/models.py:1493
msgid "Document/user link trace" msgid "Document/user link trace"
msgstr "Document/gebruiker link" msgstr "Document/gebruiker link"
#: build/lib/core/models.py:1476 core/models.py:1476 #: build/lib/core/models.py:1494 core/models.py:1494
msgid "Document/user link traces" msgid "Document/user link traces"
msgstr "Document/gebruiker link" msgstr "Document/gebruiker link"
#: build/lib/core/models.py:1482 core/models.py:1482 #: build/lib/core/models.py:1500 core/models.py:1500
msgid "A link trace already exists for this document/user." msgid "A link trace already exists for this document/user."
msgstr "Een link bestaat al voor dit document/deze gebruiker." msgstr "Een link bestaat al voor dit document/deze gebruiker."
#: build/lib/core/models.py:1505 core/models.py:1505 #: build/lib/core/models.py:1523 core/models.py:1523
msgid "Document favorite" msgid "Document favorite"
msgstr "Document favoriet" msgstr "Document favoriet"
#: build/lib/core/models.py:1506 core/models.py:1506 #: build/lib/core/models.py:1524 core/models.py:1524
msgid "Document favorites" msgid "Document favorites"
msgstr "Document favorieten" msgstr "Document favorieten"
#: build/lib/core/models.py:1512 core/models.py:1512 #: build/lib/core/models.py:1530 core/models.py:1530
msgid "This document is already targeted by a favorite relation instance for the same user." msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Dit document is al in gebruik als favoriet door dezelfde gebruiker." msgstr "Dit document is al in gebruik als favoriet door dezelfde gebruiker."
#: build/lib/core/models.py:1534 core/models.py:1534 #: build/lib/core/models.py:1552 core/models.py:1552
msgid "Document/user relation" msgid "Document/user relation"
msgstr "Document/gebruiker relatie" msgstr "Document/gebruiker relatie"
#: build/lib/core/models.py:1535 core/models.py:1535 #: build/lib/core/models.py:1553 core/models.py:1553
msgid "Document/user relations" msgid "Document/user relations"
msgstr "Document/gebruiker relaties" msgstr "Document/gebruiker relaties"
#: build/lib/core/models.py:1541 core/models.py:1541 #: build/lib/core/models.py:1559 core/models.py:1559
msgid "This user is already in this document." msgid "This user is already in this document."
msgstr "De gebruiker bestaat al in dit document." msgstr "De gebruiker bestaat al in dit document."
#: build/lib/core/models.py:1547 core/models.py:1547 #: build/lib/core/models.py:1565 core/models.py:1565
msgid "This team is already in this document." msgid "This team is already in this document."
msgstr "Dit team bestaat al in dit document." msgstr "Dit team bestaat al in dit document."
#: build/lib/core/models.py:1553 core/models.py:1553 #: build/lib/core/models.py:1571 core/models.py:1571
msgid "Either user or team must be set, not both." msgid "Either user or team must be set, not both."
msgstr "Een gebruiker of team moet gekozen worden, maar niet beide." msgstr "Een gebruiker of team moet gekozen worden, maar niet beide."
#: build/lib/core/models.py:1704 core/models.py:1704 #: build/lib/core/models.py:1722 core/models.py:1722
msgid "Document ask for access" msgid "Document ask for access"
msgstr "Document verzoekt om toegang" msgstr "Document verzoekt om toegang"
#: build/lib/core/models.py:1705 core/models.py:1705 #: build/lib/core/models.py:1723 core/models.py:1723
msgid "Document ask for accesses" msgid "Document ask for accesses"
msgstr "Document verzoekt om toegangen" msgstr "Document verzoekt om toegangen"
#: build/lib/core/models.py:1711 core/models.py:1711 #: build/lib/core/models.py:1729 core/models.py:1729
msgid "This user has already asked for access to this document." msgid "This user has already asked for access to this document."
msgstr "Deze gebruiker heeft al om toegang tot dit document gevraagd." msgstr "Deze gebruiker heeft al om toegang tot dit document gevraagd."
#: build/lib/core/models.py:1768 core/models.py:1768 #: build/lib/core/models.py:1786 core/models.py:1786
#, python-brace-format #, python-brace-format
msgid "{name} would like access to a document!" msgid "{name} would like access to a document!"
msgstr "{name} verzoekt toegang tot een document!" msgstr "{name} verzoekt toegang tot een document!"
#: build/lib/core/models.py:1772 core/models.py:1772 #: build/lib/core/models.py:1790 core/models.py:1790
#, python-brace-format #, python-brace-format
msgid "{name} would like access to the following document:" msgid "{name} would like access to the following document:"
msgstr "{name} verzoekt toegang tot het volgende document:" msgstr "{name} verzoekt toegang tot het volgende document:"
#: build/lib/core/models.py:1778 core/models.py:1778 #: build/lib/core/models.py:1796 core/models.py:1796
#, python-brace-format #, python-brace-format
msgid "{name} is asking for access to the document: {title}" msgid "{name} is asking for access to the document: {title}"
msgstr "{name} verzoekt toegang tot het document: {title}" msgstr "{name} verzoekt toegang tot het document: {title}"
#: build/lib/core/models.py:1820 core/models.py:1820 #: build/lib/core/models.py:1838 core/models.py:1838
msgid "Thread" msgid "Thread"
msgstr "Kanaal" msgstr "Kanaal"
#: build/lib/core/models.py:1821 core/models.py:1821 #: build/lib/core/models.py:1839 core/models.py:1839
msgid "Threads" msgid "Threads"
msgstr "Kanalen" msgstr "Kanalen"
#: build/lib/core/models.py:1824 build/lib/core/models.py:1876 #: build/lib/core/models.py:1842 build/lib/core/models.py:1894
#: core/models.py:1824 core/models.py:1876 #: core/models.py:1842 core/models.py:1894
msgid "Anonymous" msgid "Anonymous"
msgstr "Anoniem" msgstr "Anoniem"
#: build/lib/core/models.py:1871 core/models.py:1871 #: build/lib/core/models.py:1889 core/models.py:1889
msgid "Comment" msgid "Comment"
msgstr "Reactie" msgstr "Reactie"
#: build/lib/core/models.py:1872 core/models.py:1872 #: build/lib/core/models.py:1890 core/models.py:1890
msgid "Comments" msgid "Comments"
msgstr "Reacties" msgstr "Reacties"
#: build/lib/core/models.py:1921 core/models.py:1921 #: build/lib/core/models.py:1939 core/models.py:1939
msgid "This emoji has already been reacted to this comment." msgid "This emoji has already been reacted to this comment."
msgstr "Deze emoji is al op deze opmerking gereageerd." msgstr "Deze emoji is al op deze opmerking gereageerd."
#: build/lib/core/models.py:1925 core/models.py:1925 #: build/lib/core/models.py:1943 core/models.py:1943
msgid "Reaction" msgid "Reaction"
msgstr "Reactie" msgstr "Reactie"
#: build/lib/core/models.py:1926 core/models.py:1926 #: build/lib/core/models.py:1944 core/models.py:1944
msgid "Reactions" msgid "Reactions"
msgstr "Reacties" msgstr "Reacties"
#: build/lib/core/models.py:1936 core/models.py:1936 #: build/lib/core/models.py:1954 core/models.py:1954
msgid "email address" msgid "email address"
msgstr "e-mailadres" msgstr "e-mailadres"
#: build/lib/core/models.py:1955 core/models.py:1955 #: build/lib/core/models.py:1973 core/models.py:1973
msgid "Document invitation" msgid "Document invitation"
msgstr "Document uitnodiging" msgstr "Document uitnodiging"
#: build/lib/core/models.py:1956 core/models.py:1956 #: build/lib/core/models.py:1974 core/models.py:1974
msgid "Document invitations" msgid "Document invitations"
msgstr "Document uitnodigingen" msgstr "Document uitnodigingen"
#: build/lib/core/models.py:1976 core/models.py:1976 #: build/lib/core/models.py:1994 core/models.py:1994
msgid "This email is already associated to a registered user." msgid "This email is already associated to a registered user."
msgstr "Deze email is al geassocieerd met een geregistreerde gebruiker." msgstr "Deze email is al geassocieerd met een geregistreerde gebruiker."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: lasuite-docs\n" "Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-06 09:37+0000\n" "POT-Creation-Date: 2026-03-12 13:31+0000\n"
"PO-Revision-Date: 2026-03-09 14:02\n" "PO-Revision-Date: 2026-03-13 16:53\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Portuguese\n" "Language-Team: Portuguese\n"
"Language: pt_PT\n" "Language: pt_PT\n"
@@ -58,24 +58,24 @@ msgstr ""
msgid "Favorite" msgid "Favorite"
msgstr "Favorito" msgstr "Favorito"
#: build/lib/core/api/serializers.py:513 core/api/serializers.py:513 #: build/lib/core/api/serializers.py:526 core/api/serializers.py:526
msgid "A new document was created on your behalf!" msgid "A new document was created on your behalf!"
msgstr "Um novo documento foi criado em seu nome!" msgstr "Um novo documento foi criado em seu nome!"
#: build/lib/core/api/serializers.py:517 core/api/serializers.py:517 #: build/lib/core/api/serializers.py:530 core/api/serializers.py:530
msgid "You have been granted ownership of a new document:" msgid "You have been granted ownership of a new document:"
msgstr "A propriedade de um novo documento foi concedida a você:" msgstr "A propriedade de um novo documento foi concedida a você:"
#: build/lib/core/api/serializers.py:553 core/api/serializers.py:553 #: build/lib/core/api/serializers.py:566 core/api/serializers.py:566
msgid "This field is required." msgid "This field is required."
msgstr "" msgstr ""
#: build/lib/core/api/serializers.py:564 core/api/serializers.py:564 #: build/lib/core/api/serializers.py:577 core/api/serializers.py:577
#, python-format #, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration." msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr "" msgstr ""
#: build/lib/core/api/viewsets.py:1279 core/api/viewsets.py:1279 #: build/lib/core/api/viewsets.py:1298 core/api/viewsets.py:1298
#, python-brace-format #, python-brace-format
msgid "copy of {title}" msgid "copy of {title}"
msgstr "cópia de {title}" msgstr "cópia de {title}"
@@ -231,106 +231,114 @@ msgstr ""
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts." msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "" msgstr ""
#: build/lib/core/models.py:204 core/models.py:204 #: build/lib/core/models.py:197 core/models.py:197
msgid "first connection status"
msgstr ""
#: build/lib/core/models.py:199 core/models.py:199
msgid "Whether the user has completed the first connection process."
msgstr ""
#: build/lib/core/models.py:209 core/models.py:209
msgid "user" msgid "user"
msgstr "" msgstr ""
#: build/lib/core/models.py:205 core/models.py:205 #: build/lib/core/models.py:210 core/models.py:210
msgid "users" msgid "users"
msgstr "" msgstr ""
#: build/lib/core/models.py:360 core/models.py:360 #: build/lib/core/models.py:378 core/models.py:378
msgid "Active email address" msgid "Active email address"
msgstr "" msgstr ""
#: build/lib/core/models.py:361 core/models.py:361 #: build/lib/core/models.py:379 core/models.py:379
msgid "Email address to deactivate" msgid "Email address to deactivate"
msgstr "" msgstr ""
#: build/lib/core/models.py:388 core/models.py:388 #: build/lib/core/models.py:406 core/models.py:406
msgid "Unique ID in the source file" msgid "Unique ID in the source file"
msgstr "" msgstr ""
#: build/lib/core/models.py:394 build/lib/core/models.py:692 core/models.py:394 #: build/lib/core/models.py:412 build/lib/core/models.py:710 core/models.py:412
#: core/models.py:692 #: core/models.py:710
msgid "Pending" msgid "Pending"
msgstr "" msgstr ""
#: build/lib/core/models.py:395 core/models.py:395 #: build/lib/core/models.py:413 core/models.py:413
msgid "Ready" msgid "Ready"
msgstr "" msgstr ""
#: build/lib/core/models.py:396 build/lib/core/models.py:694 core/models.py:396 #: build/lib/core/models.py:414 build/lib/core/models.py:712 core/models.py:414
#: core/models.py:694 #: core/models.py:712
msgid "Done" msgid "Done"
msgstr "" msgstr ""
#: build/lib/core/models.py:397 build/lib/core/models.py:695 core/models.py:397 #: build/lib/core/models.py:415 build/lib/core/models.py:713 core/models.py:415
#: core/models.py:695 #: core/models.py:713
msgid "Error" msgid "Error"
msgstr "" msgstr ""
#: build/lib/core/models.py:405 core/models.py:405 #: build/lib/core/models.py:423 core/models.py:423
msgid "user reconciliation" msgid "user reconciliation"
msgstr "" msgstr ""
#: build/lib/core/models.py:406 core/models.py:406 #: build/lib/core/models.py:424 core/models.py:424
msgid "user reconciliations" msgid "user reconciliations"
msgstr "" msgstr ""
#: build/lib/core/models.py:644 core/models.py:644 #: build/lib/core/models.py:662 core/models.py:662
msgid "You have requested a reconciliation of your user accounts on Docs.\n" msgid "You have requested a reconciliation of your user accounts on Docs.\n"
" To confirm that you are the one who initiated the request\n" " To confirm that you are the one who initiated the request\n"
" and that this email belongs to you:" " and that this email belongs to you:"
msgstr "" msgstr ""
#: build/lib/core/models.py:650 core/models.py:650 #: build/lib/core/models.py:668 core/models.py:668
msgid "Confirm by clicking the link to start the reconciliation" msgid "Confirm by clicking the link to start the reconciliation"
msgstr "" msgstr ""
#: build/lib/core/models.py:655 build/lib/core/models.py:761 core/models.py:655 #: build/lib/core/models.py:673 build/lib/core/models.py:779 core/models.py:673
#: core/models.py:761 #: core/models.py:779
msgid "Click here" msgid "Click here"
msgstr "" msgstr ""
#: build/lib/core/models.py:656 core/models.py:656 #: build/lib/core/models.py:674 core/models.py:674
msgid "Confirm" msgid "Confirm"
msgstr "" msgstr ""
#: build/lib/core/models.py:667 core/models.py:667 #: build/lib/core/models.py:685 core/models.py:685
msgid "Your reconciliation request has been processed.\n" msgid "Your reconciliation request has been processed.\n"
" New documents are likely associated with your account:" " New documents are likely associated with your account:"
msgstr "" msgstr ""
#: build/lib/core/models.py:672 core/models.py:672 #: build/lib/core/models.py:690 core/models.py:690
msgid "Your accounts have been merged" msgid "Your accounts have been merged"
msgstr "" msgstr ""
#: build/lib/core/models.py:677 core/models.py:677 #: build/lib/core/models.py:695 core/models.py:695
msgid "Click here to see" msgid "Click here to see"
msgstr "" msgstr ""
#: build/lib/core/models.py:678 core/models.py:678 #: build/lib/core/models.py:696 core/models.py:696
msgid "See my documents" msgid "See my documents"
msgstr "" msgstr ""
#: build/lib/core/models.py:688 core/models.py:688 #: build/lib/core/models.py:706 core/models.py:706
msgid "CSV file" msgid "CSV file"
msgstr "" msgstr ""
#: build/lib/core/models.py:693 core/models.py:693 #: build/lib/core/models.py:711 core/models.py:711
msgid "Running" msgid "Running"
msgstr "" msgstr ""
#: build/lib/core/models.py:703 core/models.py:703 #: build/lib/core/models.py:721 core/models.py:721
msgid "user reconciliation CSV import" msgid "user reconciliation CSV import"
msgstr "" msgstr ""
#: build/lib/core/models.py:704 core/models.py:704 #: build/lib/core/models.py:722 core/models.py:722
msgid "user reconciliation CSV imports" msgid "user reconciliation CSV imports"
msgstr "" msgstr ""
#: build/lib/core/models.py:748 core/models.py:748 #: build/lib/core/models.py:766 core/models.py:766
#, python-brace-format #, python-brace-format
msgid "Your request for reconciliation was unsuccessful.\n" msgid "Your request for reconciliation was unsuccessful.\n"
" Reconciliation failed for the following email addresses:\n" " Reconciliation failed for the following email addresses:\n"
@@ -339,171 +347,171 @@ msgid "Your request for reconciliation was unsuccessful.\n"
" You can submit another request with the valid email addresses." " You can submit another request with the valid email addresses."
msgstr "" msgstr ""
#: build/lib/core/models.py:756 core/models.py:756 #: build/lib/core/models.py:774 core/models.py:774
msgid "Reconciliation of your Docs accounts not completed" msgid "Reconciliation of your Docs accounts not completed"
msgstr "" msgstr ""
#: build/lib/core/models.py:762 core/models.py:762 #: build/lib/core/models.py:780 core/models.py:780
msgid "Make a new request" msgid "Make a new request"
msgstr "" msgstr ""
#: build/lib/core/models.py:861 core/models.py:861 #: build/lib/core/models.py:879 core/models.py:879
msgid "title" msgid "title"
msgstr "" msgstr ""
#: build/lib/core/models.py:862 core/models.py:862 #: build/lib/core/models.py:880 core/models.py:880
msgid "excerpt" msgid "excerpt"
msgstr "" msgstr ""
#: build/lib/core/models.py:911 core/models.py:911 #: build/lib/core/models.py:929 core/models.py:929
msgid "Document" msgid "Document"
msgstr "" msgstr ""
#: build/lib/core/models.py:912 core/models.py:912 #: build/lib/core/models.py:930 core/models.py:930
msgid "Documents" msgid "Documents"
msgstr "" msgstr ""
#: build/lib/core/models.py:924 build/lib/core/models.py:1328 #: build/lib/core/models.py:942 build/lib/core/models.py:1346
#: core/models.py:924 core/models.py:1328 #: core/models.py:942 core/models.py:1346
msgid "Untitled Document" msgid "Untitled Document"
msgstr "" msgstr ""
#: build/lib/core/models.py:1329 core/models.py:1329 #: build/lib/core/models.py:1347 core/models.py:1347
msgid "Open" msgid "Open"
msgstr "" msgstr ""
#: build/lib/core/models.py:1364 core/models.py:1364 #: build/lib/core/models.py:1382 core/models.py:1382
#, python-brace-format #, python-brace-format
msgid "{name} shared a document with you!" msgid "{name} shared a document with you!"
msgstr "" msgstr ""
#: build/lib/core/models.py:1368 core/models.py:1368 #: build/lib/core/models.py:1386 core/models.py:1386
#, python-brace-format #, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:" msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "" msgstr ""
#: build/lib/core/models.py:1374 core/models.py:1374 #: build/lib/core/models.py:1392 core/models.py:1392
#, python-brace-format #, python-brace-format
msgid "{name} shared a document with you: {title}" msgid "{name} shared a document with you: {title}"
msgstr "" msgstr ""
#: build/lib/core/models.py:1475 core/models.py:1475 #: build/lib/core/models.py:1493 core/models.py:1493
msgid "Document/user link trace" msgid "Document/user link trace"
msgstr "" msgstr ""
#: build/lib/core/models.py:1476 core/models.py:1476 #: build/lib/core/models.py:1494 core/models.py:1494
msgid "Document/user link traces" msgid "Document/user link traces"
msgstr "" msgstr ""
#: build/lib/core/models.py:1482 core/models.py:1482 #: build/lib/core/models.py:1500 core/models.py:1500
msgid "A link trace already exists for this document/user." msgid "A link trace already exists for this document/user."
msgstr "" msgstr ""
#: build/lib/core/models.py:1505 core/models.py:1505 #: build/lib/core/models.py:1523 core/models.py:1523
msgid "Document favorite" msgid "Document favorite"
msgstr "" msgstr ""
#: build/lib/core/models.py:1506 core/models.py:1506 #: build/lib/core/models.py:1524 core/models.py:1524
msgid "Document favorites" msgid "Document favorites"
msgstr "" msgstr ""
#: build/lib/core/models.py:1512 core/models.py:1512 #: build/lib/core/models.py:1530 core/models.py:1530
msgid "This document is already targeted by a favorite relation instance for the same user." msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "" msgstr ""
#: build/lib/core/models.py:1534 core/models.py:1534 #: build/lib/core/models.py:1552 core/models.py:1552
msgid "Document/user relation" msgid "Document/user relation"
msgstr "" msgstr ""
#: build/lib/core/models.py:1535 core/models.py:1535 #: build/lib/core/models.py:1553 core/models.py:1553
msgid "Document/user relations" msgid "Document/user relations"
msgstr "" msgstr ""
#: build/lib/core/models.py:1541 core/models.py:1541 #: build/lib/core/models.py:1559 core/models.py:1559
msgid "This user is already in this document." msgid "This user is already in this document."
msgstr "" msgstr ""
#: build/lib/core/models.py:1547 core/models.py:1547 #: build/lib/core/models.py:1565 core/models.py:1565
msgid "This team is already in this document." msgid "This team is already in this document."
msgstr "" msgstr ""
#: build/lib/core/models.py:1553 core/models.py:1553 #: build/lib/core/models.py:1571 core/models.py:1571
msgid "Either user or team must be set, not both." msgid "Either user or team must be set, not both."
msgstr "" msgstr ""
#: build/lib/core/models.py:1704 core/models.py:1704 #: build/lib/core/models.py:1722 core/models.py:1722
msgid "Document ask for access" msgid "Document ask for access"
msgstr "" msgstr ""
#: build/lib/core/models.py:1705 core/models.py:1705 #: build/lib/core/models.py:1723 core/models.py:1723
msgid "Document ask for accesses" msgid "Document ask for accesses"
msgstr "" msgstr ""
#: build/lib/core/models.py:1711 core/models.py:1711 #: build/lib/core/models.py:1729 core/models.py:1729
msgid "This user has already asked for access to this document." msgid "This user has already asked for access to this document."
msgstr "" msgstr ""
#: build/lib/core/models.py:1768 core/models.py:1768 #: build/lib/core/models.py:1786 core/models.py:1786
#, python-brace-format #, python-brace-format
msgid "{name} would like access to a document!" msgid "{name} would like access to a document!"
msgstr "" msgstr ""
#: build/lib/core/models.py:1772 core/models.py:1772 #: build/lib/core/models.py:1790 core/models.py:1790
#, python-brace-format #, python-brace-format
msgid "{name} would like access to the following document:" msgid "{name} would like access to the following document:"
msgstr "" msgstr ""
#: build/lib/core/models.py:1778 core/models.py:1778 #: build/lib/core/models.py:1796 core/models.py:1796
#, python-brace-format #, python-brace-format
msgid "{name} is asking for access to the document: {title}" msgid "{name} is asking for access to the document: {title}"
msgstr "" msgstr ""
#: build/lib/core/models.py:1820 core/models.py:1820 #: build/lib/core/models.py:1838 core/models.py:1838
msgid "Thread" msgid "Thread"
msgstr "" msgstr ""
#: build/lib/core/models.py:1821 core/models.py:1821 #: build/lib/core/models.py:1839 core/models.py:1839
msgid "Threads" msgid "Threads"
msgstr "" msgstr ""
#: build/lib/core/models.py:1824 build/lib/core/models.py:1876 #: build/lib/core/models.py:1842 build/lib/core/models.py:1894
#: core/models.py:1824 core/models.py:1876 #: core/models.py:1842 core/models.py:1894
msgid "Anonymous" msgid "Anonymous"
msgstr "" msgstr ""
#: build/lib/core/models.py:1871 core/models.py:1871 #: build/lib/core/models.py:1889 core/models.py:1889
msgid "Comment" msgid "Comment"
msgstr "" msgstr ""
#: build/lib/core/models.py:1872 core/models.py:1872 #: build/lib/core/models.py:1890 core/models.py:1890
msgid "Comments" msgid "Comments"
msgstr "" msgstr ""
#: build/lib/core/models.py:1921 core/models.py:1921 #: build/lib/core/models.py:1939 core/models.py:1939
msgid "This emoji has already been reacted to this comment." msgid "This emoji has already been reacted to this comment."
msgstr "" msgstr ""
#: build/lib/core/models.py:1925 core/models.py:1925 #: build/lib/core/models.py:1943 core/models.py:1943
msgid "Reaction" msgid "Reaction"
msgstr "" msgstr ""
#: build/lib/core/models.py:1926 core/models.py:1926 #: build/lib/core/models.py:1944 core/models.py:1944
msgid "Reactions" msgid "Reactions"
msgstr "" msgstr ""
#: build/lib/core/models.py:1936 core/models.py:1936 #: build/lib/core/models.py:1954 core/models.py:1954
msgid "email address" msgid "email address"
msgstr "" msgstr ""
#: build/lib/core/models.py:1955 core/models.py:1955 #: build/lib/core/models.py:1973 core/models.py:1973
msgid "Document invitation" msgid "Document invitation"
msgstr "" msgstr ""
#: build/lib/core/models.py:1956 core/models.py:1956 #: build/lib/core/models.py:1974 core/models.py:1974
msgid "Document invitations" msgid "Document invitations"
msgstr "" msgstr ""
#: build/lib/core/models.py:1976 core/models.py:1976 #: build/lib/core/models.py:1994 core/models.py:1994
msgid "This email is already associated to a registered user." msgid "This email is already associated to a registered user."
msgstr "" msgstr ""

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: lasuite-docs\n" "Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-06 09:37+0000\n" "POT-Creation-Date: 2026-03-12 13:31+0000\n"
"PO-Revision-Date: 2026-03-09 14:02\n" "PO-Revision-Date: 2026-03-13 16:53\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Russian\n" "Language-Team: Russian\n"
"Language: ru_RU\n" "Language: ru_RU\n"
@@ -58,24 +58,24 @@ msgstr "Скрытый"
msgid "Favorite" msgid "Favorite"
msgstr "Избранное" msgstr "Избранное"
#: build/lib/core/api/serializers.py:513 core/api/serializers.py:513 #: build/lib/core/api/serializers.py:526 core/api/serializers.py:526
msgid "A new document was created on your behalf!" msgid "A new document was created on your behalf!"
msgstr "Новый документ был создан от вашего имени!" msgstr "Новый документ был создан от вашего имени!"
#: build/lib/core/api/serializers.py:517 core/api/serializers.py:517 #: build/lib/core/api/serializers.py:530 core/api/serializers.py:530
msgid "You have been granted ownership of a new document:" msgid "You have been granted ownership of a new document:"
msgstr "Вы назначены владельцем для нового документа:" msgstr "Вы назначены владельцем для нового документа:"
#: build/lib/core/api/serializers.py:553 core/api/serializers.py:553 #: build/lib/core/api/serializers.py:566 core/api/serializers.py:566
msgid "This field is required." msgid "This field is required."
msgstr "Это поле обязательное." msgstr "Это поле обязательное."
#: build/lib/core/api/serializers.py:564 core/api/serializers.py:564 #: build/lib/core/api/serializers.py:577 core/api/serializers.py:577
#, python-format #, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration." msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr "Доступ по ссылке '%(link_reach)s' запрещён в соответствии с настройками родительского документа." msgstr "Доступ по ссылке '%(link_reach)s' запрещён в соответствии с настройками родительского документа."
#: build/lib/core/api/viewsets.py:1279 core/api/viewsets.py:1279 #: build/lib/core/api/viewsets.py:1298 core/api/viewsets.py:1298
#, python-brace-format #, python-brace-format
msgid "copy of {title}" msgid "copy of {title}"
msgstr "копия {title}" msgstr "копия {title}"
@@ -231,54 +231,62 @@ msgstr "активный"
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts." msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Должен ли пользователь рассматриваться как активный. Альтернатива удалению учётных записей." msgstr "Должен ли пользователь рассматриваться как активный. Альтернатива удалению учётных записей."
#: build/lib/core/models.py:204 core/models.py:204 #: build/lib/core/models.py:197 core/models.py:197
msgid "first connection status"
msgstr "состояние первого подключения"
#: build/lib/core/models.py:199 core/models.py:199
msgid "Whether the user has completed the first connection process."
msgstr "Завершил ли пользователь процесс первого соединения."
#: build/lib/core/models.py:209 core/models.py:209
msgid "user" msgid "user"
msgstr "пользователь" msgstr "пользователь"
#: build/lib/core/models.py:205 core/models.py:205 #: build/lib/core/models.py:210 core/models.py:210
msgid "users" msgid "users"
msgstr "пользователи" msgstr "пользователи"
#: build/lib/core/models.py:360 core/models.py:360 #: build/lib/core/models.py:378 core/models.py:378
msgid "Active email address" msgid "Active email address"
msgstr "Активный адрес электронной почты" msgstr "Активный адрес электронной почты"
#: build/lib/core/models.py:361 core/models.py:361 #: build/lib/core/models.py:379 core/models.py:379
msgid "Email address to deactivate" msgid "Email address to deactivate"
msgstr "Адрес электронной почты для деактивации" msgstr "Адрес электронной почты для деактивации"
#: build/lib/core/models.py:388 core/models.py:388 #: build/lib/core/models.py:406 core/models.py:406
msgid "Unique ID in the source file" msgid "Unique ID in the source file"
msgstr "Уникальный идентификатор в исходном файле" msgstr "Уникальный идентификатор в исходном файле"
#: build/lib/core/models.py:394 build/lib/core/models.py:692 core/models.py:394 #: build/lib/core/models.py:412 build/lib/core/models.py:710 core/models.py:412
#: core/models.py:692 #: core/models.py:710
msgid "Pending" msgid "Pending"
msgstr "В обработке" msgstr "В обработке"
#: build/lib/core/models.py:395 core/models.py:395 #: build/lib/core/models.py:413 core/models.py:413
msgid "Ready" msgid "Ready"
msgstr "Готово" msgstr "Готово"
#: build/lib/core/models.py:396 build/lib/core/models.py:694 core/models.py:396 #: build/lib/core/models.py:414 build/lib/core/models.py:712 core/models.py:414
#: core/models.py:694 #: core/models.py:712
msgid "Done" msgid "Done"
msgstr "Выполнено" msgstr "Выполнено"
#: build/lib/core/models.py:397 build/lib/core/models.py:695 core/models.py:397 #: build/lib/core/models.py:415 build/lib/core/models.py:713 core/models.py:415
#: core/models.py:695 #: core/models.py:713
msgid "Error" msgid "Error"
msgstr "Ошибка" msgstr "Ошибка"
#: build/lib/core/models.py:405 core/models.py:405 #: build/lib/core/models.py:423 core/models.py:423
msgid "user reconciliation" msgid "user reconciliation"
msgstr "сверка данных пользователя" msgstr "сверка данных пользователя"
#: build/lib/core/models.py:406 core/models.py:406 #: build/lib/core/models.py:424 core/models.py:424
msgid "user reconciliations" msgid "user reconciliations"
msgstr "сверки данных пользователя" msgstr "сверки данных пользователя"
#: build/lib/core/models.py:644 core/models.py:644 #: build/lib/core/models.py:662 core/models.py:662
msgid "You have requested a reconciliation of your user accounts on Docs.\n" msgid "You have requested a reconciliation of your user accounts on Docs.\n"
" To confirm that you are the one who initiated the request\n" " To confirm that you are the one who initiated the request\n"
" and that this email belongs to you:" " and that this email belongs to you:"
@@ -286,54 +294,54 @@ msgstr "Вы запросили сверку учётных записей по
" Чтобы подтвердить факт того, что вы являетесь инициатором запроса\n" " Чтобы подтвердить факт того, что вы являетесь инициатором запроса\n"
" и что этот адрес принадлежит вам:" " и что этот адрес принадлежит вам:"
#: build/lib/core/models.py:650 core/models.py:650 #: build/lib/core/models.py:668 core/models.py:668
msgid "Confirm by clicking the link to start the reconciliation" msgid "Confirm by clicking the link to start the reconciliation"
msgstr "Чтобы начать сверку, подтвердите это, нажав на ссылку" msgstr "Чтобы начать сверку, подтвердите это, нажав на ссылку"
#: build/lib/core/models.py:655 build/lib/core/models.py:761 core/models.py:655 #: build/lib/core/models.py:673 build/lib/core/models.py:779 core/models.py:673
#: core/models.py:761 #: core/models.py:779
msgid "Click here" msgid "Click here"
msgstr "Нажмите здесь" msgstr "Нажмите здесь"
#: build/lib/core/models.py:656 core/models.py:656 #: build/lib/core/models.py:674 core/models.py:674
msgid "Confirm" msgid "Confirm"
msgstr "Подтверждение" msgstr "Подтверждение"
#: build/lib/core/models.py:667 core/models.py:667 #: build/lib/core/models.py:685 core/models.py:685
msgid "Your reconciliation request has been processed.\n" msgid "Your reconciliation request has been processed.\n"
" New documents are likely associated with your account:" " New documents are likely associated with your account:"
msgstr "Ваш запрос на сверку был обработан.\n" msgstr "Ваш запрос на сверку был обработан.\n"
" Новые документы, вероятно, связаны с вашей учётной записью:" " Новые документы, вероятно, связаны с вашей учётной записью:"
#: build/lib/core/models.py:672 core/models.py:672 #: build/lib/core/models.py:690 core/models.py:690
msgid "Your accounts have been merged" msgid "Your accounts have been merged"
msgstr "Ваши учётные записи были объединены" msgstr "Ваши учётные записи были объединены"
#: build/lib/core/models.py:677 core/models.py:677 #: build/lib/core/models.py:695 core/models.py:695
msgid "Click here to see" msgid "Click here to see"
msgstr "Нажмите здесь, чтобы просмотреть" msgstr "Нажмите здесь, чтобы просмотреть"
#: build/lib/core/models.py:678 core/models.py:678 #: build/lib/core/models.py:696 core/models.py:696
msgid "See my documents" msgid "See my documents"
msgstr "Просмотреть мои документы" msgstr "Просмотреть мои документы"
#: build/lib/core/models.py:688 core/models.py:688 #: build/lib/core/models.py:706 core/models.py:706
msgid "CSV file" msgid "CSV file"
msgstr "CSV-файл" msgstr "CSV-файл"
#: build/lib/core/models.py:693 core/models.py:693 #: build/lib/core/models.py:711 core/models.py:711
msgid "Running" msgid "Running"
msgstr "Выполнение" msgstr "Выполнение"
#: build/lib/core/models.py:703 core/models.py:703 #: build/lib/core/models.py:721 core/models.py:721
msgid "user reconciliation CSV import" msgid "user reconciliation CSV import"
msgstr "импорт из CSV сверки пользователей" msgstr "импорт из CSV сверки пользователей"
#: build/lib/core/models.py:704 core/models.py:704 #: build/lib/core/models.py:722 core/models.py:722
msgid "user reconciliation CSV imports" msgid "user reconciliation CSV imports"
msgstr "импорты из CSV сверки пользователями" msgstr "импорты из CSV сверки пользователями"
#: build/lib/core/models.py:748 core/models.py:748 #: build/lib/core/models.py:766 core/models.py:766
#, python-brace-format #, python-brace-format
msgid "Your request for reconciliation was unsuccessful.\n" msgid "Your request for reconciliation was unsuccessful.\n"
" Reconciliation failed for the following email addresses:\n" " Reconciliation failed for the following email addresses:\n"
@@ -346,171 +354,171 @@ msgstr "Ваш запрос на сверку не удался.\n"
" Пожалуйста, проверьте, нет ли в них опечаток.\n" " Пожалуйста, проверьте, нет ли в них опечаток.\n"
" Вы можете отправить ещё один запрос с действительными адресами электронной почты." " Вы можете отправить ещё один запрос с действительными адресами электронной почты."
#: build/lib/core/models.py:756 core/models.py:756 #: build/lib/core/models.py:774 core/models.py:774
msgid "Reconciliation of your Docs accounts not completed" msgid "Reconciliation of your Docs accounts not completed"
msgstr "Сверка ваших учётных записей Docs не завершена" msgstr "Сверка ваших учётных записей Docs не завершена"
#: build/lib/core/models.py:762 core/models.py:762 #: build/lib/core/models.py:780 core/models.py:780
msgid "Make a new request" msgid "Make a new request"
msgstr "Создать новый запрос" msgstr "Создать новый запрос"
#: build/lib/core/models.py:861 core/models.py:861 #: build/lib/core/models.py:879 core/models.py:879
msgid "title" msgid "title"
msgstr "заголовок" msgstr "заголовок"
#: build/lib/core/models.py:862 core/models.py:862 #: build/lib/core/models.py:880 core/models.py:880
msgid "excerpt" msgid "excerpt"
msgstr "отрывок" msgstr "отрывок"
#: build/lib/core/models.py:911 core/models.py:911 #: build/lib/core/models.py:929 core/models.py:929
msgid "Document" msgid "Document"
msgstr "Документ" msgstr "Документ"
#: build/lib/core/models.py:912 core/models.py:912 #: build/lib/core/models.py:930 core/models.py:930
msgid "Documents" msgid "Documents"
msgstr "Документы" msgstr "Документы"
#: build/lib/core/models.py:924 build/lib/core/models.py:1328 #: build/lib/core/models.py:942 build/lib/core/models.py:1346
#: core/models.py:924 core/models.py:1328 #: core/models.py:942 core/models.py:1346
msgid "Untitled Document" msgid "Untitled Document"
msgstr "Безымянный документ" msgstr "Безымянный документ"
#: build/lib/core/models.py:1329 core/models.py:1329 #: build/lib/core/models.py:1347 core/models.py:1347
msgid "Open" msgid "Open"
msgstr "Открыть" msgstr "Открыть"
#: build/lib/core/models.py:1364 core/models.py:1364 #: build/lib/core/models.py:1382 core/models.py:1382
#, python-brace-format #, python-brace-format
msgid "{name} shared a document with you!" msgid "{name} shared a document with you!"
msgstr "{name} делится с вами документом!" msgstr "{name} делится с вами документом!"
#: build/lib/core/models.py:1368 core/models.py:1368 #: build/lib/core/models.py:1386 core/models.py:1386
#, python-brace-format #, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:" msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} приглашает вас присоединиться к следующему документу с ролью \"{role}\":" msgstr "{name} приглашает вас присоединиться к следующему документу с ролью \"{role}\":"
#: build/lib/core/models.py:1374 core/models.py:1374 #: build/lib/core/models.py:1392 core/models.py:1392
#, python-brace-format #, python-brace-format
msgid "{name} shared a document with you: {title}" msgid "{name} shared a document with you: {title}"
msgstr "{name} делится с вами документом: {title}" msgstr "{name} делится с вами документом: {title}"
#: build/lib/core/models.py:1475 core/models.py:1475 #: build/lib/core/models.py:1493 core/models.py:1493
msgid "Document/user link trace" msgid "Document/user link trace"
msgstr "Трассировка связи документ/пользователь" msgstr "Трассировка связи документ/пользователь"
#: build/lib/core/models.py:1476 core/models.py:1476 #: build/lib/core/models.py:1494 core/models.py:1494
msgid "Document/user link traces" msgid "Document/user link traces"
msgstr "Трассировка связей документ/пользователь" msgstr "Трассировка связей документ/пользователь"
#: build/lib/core/models.py:1482 core/models.py:1482 #: build/lib/core/models.py:1500 core/models.py:1500
msgid "A link trace already exists for this document/user." msgid "A link trace already exists for this document/user."
msgstr "Для этого документа/пользователя уже существует трассировка ссылки." msgstr "Для этого документа/пользователя уже существует трассировка ссылки."
#: build/lib/core/models.py:1505 core/models.py:1505 #: build/lib/core/models.py:1523 core/models.py:1523
msgid "Document favorite" msgid "Document favorite"
msgstr "Избранный документ" msgstr "Избранный документ"
#: build/lib/core/models.py:1506 core/models.py:1506 #: build/lib/core/models.py:1524 core/models.py:1524
msgid "Document favorites" msgid "Document favorites"
msgstr "Избранные документы" msgstr "Избранные документы"
#: build/lib/core/models.py:1512 core/models.py:1512 #: build/lib/core/models.py:1530 core/models.py:1530
msgid "This document is already targeted by a favorite relation instance for the same user." msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Этот документ уже помечен как избранный для этого пользователя." msgstr "Этот документ уже помечен как избранный для этого пользователя."
#: build/lib/core/models.py:1534 core/models.py:1534 #: build/lib/core/models.py:1552 core/models.py:1552
msgid "Document/user relation" msgid "Document/user relation"
msgstr "Отношение документ/пользователь" msgstr "Отношение документ/пользователь"
#: build/lib/core/models.py:1535 core/models.py:1535 #: build/lib/core/models.py:1553 core/models.py:1553
msgid "Document/user relations" msgid "Document/user relations"
msgstr "Отношения документ/пользователь" msgstr "Отношения документ/пользователь"
#: build/lib/core/models.py:1541 core/models.py:1541 #: build/lib/core/models.py:1559 core/models.py:1559
msgid "This user is already in this document." msgid "This user is already in this document."
msgstr "Этот пользователь уже имеет доступ к этому документу." msgstr "Этот пользователь уже имеет доступ к этому документу."
#: build/lib/core/models.py:1547 core/models.py:1547 #: build/lib/core/models.py:1565 core/models.py:1565
msgid "This team is already in this document." msgid "This team is already in this document."
msgstr "Эта команда уже имеет доступ к этому документу." msgstr "Эта команда уже имеет доступ к этому документу."
#: build/lib/core/models.py:1553 core/models.py:1553 #: build/lib/core/models.py:1571 core/models.py:1571
msgid "Either user or team must be set, not both." msgid "Either user or team must be set, not both."
msgstr "Может быть выбран либо пользователь, либо команда, но не оба варианта сразу." msgstr "Может быть выбран либо пользователь, либо команда, но не оба варианта сразу."
#: build/lib/core/models.py:1704 core/models.py:1704 #: build/lib/core/models.py:1722 core/models.py:1722
msgid "Document ask for access" msgid "Document ask for access"
msgstr "Документ запрашивает доступ" msgstr "Документ запрашивает доступ"
#: build/lib/core/models.py:1705 core/models.py:1705 #: build/lib/core/models.py:1723 core/models.py:1723
msgid "Document ask for accesses" msgid "Document ask for accesses"
msgstr "Документ запрашивает доступы" msgstr "Документ запрашивает доступы"
#: build/lib/core/models.py:1711 core/models.py:1711 #: build/lib/core/models.py:1729 core/models.py:1729
msgid "This user has already asked for access to this document." msgid "This user has already asked for access to this document."
msgstr "Этот пользователь уже запросил доступ к этому документу." msgstr "Этот пользователь уже запросил доступ к этому документу."
#: build/lib/core/models.py:1768 core/models.py:1768 #: build/lib/core/models.py:1786 core/models.py:1786
#, python-brace-format #, python-brace-format
msgid "{name} would like access to a document!" msgid "{name} would like access to a document!"
msgstr "{name} хочет получить доступ к документу!" msgstr "{name} хочет получить доступ к документу!"
#: build/lib/core/models.py:1772 core/models.py:1772 #: build/lib/core/models.py:1790 core/models.py:1790
#, python-brace-format #, python-brace-format
msgid "{name} would like access to the following document:" msgid "{name} would like access to the following document:"
msgstr "{name} хочет получить доступ к следующему документу:" msgstr "{name} хочет получить доступ к следующему документу:"
#: build/lib/core/models.py:1778 core/models.py:1778 #: build/lib/core/models.py:1796 core/models.py:1796
#, python-brace-format #, python-brace-format
msgid "{name} is asking for access to the document: {title}" msgid "{name} is asking for access to the document: {title}"
msgstr "{name} запрашивает доступ к документу: {title}" msgstr "{name} запрашивает доступ к документу: {title}"
#: build/lib/core/models.py:1820 core/models.py:1820 #: build/lib/core/models.py:1838 core/models.py:1838
msgid "Thread" msgid "Thread"
msgstr "Обсуждение" msgstr "Обсуждение"
#: build/lib/core/models.py:1821 core/models.py:1821 #: build/lib/core/models.py:1839 core/models.py:1839
msgid "Threads" msgid "Threads"
msgstr "Обсуждения" msgstr "Обсуждения"
#: build/lib/core/models.py:1824 build/lib/core/models.py:1876 #: build/lib/core/models.py:1842 build/lib/core/models.py:1894
#: core/models.py:1824 core/models.py:1876 #: core/models.py:1842 core/models.py:1894
msgid "Anonymous" msgid "Anonymous"
msgstr "Аноним" msgstr "Аноним"
#: build/lib/core/models.py:1871 core/models.py:1871 #: build/lib/core/models.py:1889 core/models.py:1889
msgid "Comment" msgid "Comment"
msgstr "Комментарий" msgstr "Комментарий"
#: build/lib/core/models.py:1872 core/models.py:1872 #: build/lib/core/models.py:1890 core/models.py:1890
msgid "Comments" msgid "Comments"
msgstr "Комментарии" msgstr "Комментарии"
#: build/lib/core/models.py:1921 core/models.py:1921 #: build/lib/core/models.py:1939 core/models.py:1939
msgid "This emoji has already been reacted to this comment." msgid "This emoji has already been reacted to this comment."
msgstr "Этот эмодзи уже использован в этом комментарии." msgstr "Этот эмодзи уже использован в этом комментарии."
#: build/lib/core/models.py:1925 core/models.py:1925 #: build/lib/core/models.py:1943 core/models.py:1943
msgid "Reaction" msgid "Reaction"
msgstr "Реакция" msgstr "Реакция"
#: build/lib/core/models.py:1926 core/models.py:1926 #: build/lib/core/models.py:1944 core/models.py:1944
msgid "Reactions" msgid "Reactions"
msgstr "Реакции" msgstr "Реакции"
#: build/lib/core/models.py:1936 core/models.py:1936 #: build/lib/core/models.py:1954 core/models.py:1954
msgid "email address" msgid "email address"
msgstr "адрес электронной почты" msgstr "адрес электронной почты"
#: build/lib/core/models.py:1955 core/models.py:1955 #: build/lib/core/models.py:1973 core/models.py:1973
msgid "Document invitation" msgid "Document invitation"
msgstr "Приглашение для документа" msgstr "Приглашение для документа"
#: build/lib/core/models.py:1956 core/models.py:1956 #: build/lib/core/models.py:1974 core/models.py:1974
msgid "Document invitations" msgid "Document invitations"
msgstr "Приглашения для документов" msgstr "Приглашения для документов"
#: build/lib/core/models.py:1976 core/models.py:1976 #: build/lib/core/models.py:1994 core/models.py:1994
msgid "This email is already associated to a registered user." msgid "This email is already associated to a registered user."
msgstr "Этот адрес уже связан с зарегистрированным пользователем." msgstr "Этот адрес уже связан с зарегистрированным пользователем."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: lasuite-docs\n" "Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-06 09:37+0000\n" "POT-Creation-Date: 2026-03-12 13:31+0000\n"
"PO-Revision-Date: 2026-03-09 14:02\n" "PO-Revision-Date: 2026-03-13 16:53\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Slovenian\n" "Language-Team: Slovenian\n"
"Language: sl_SI\n" "Language: sl_SI\n"
@@ -58,24 +58,24 @@ msgstr ""
msgid "Favorite" msgid "Favorite"
msgstr "Priljubljena" msgstr "Priljubljena"
#: build/lib/core/api/serializers.py:513 core/api/serializers.py:513 #: build/lib/core/api/serializers.py:526 core/api/serializers.py:526
msgid "A new document was created on your behalf!" msgid "A new document was created on your behalf!"
msgstr "Nov dokument je bil ustvarjen v vašem imenu!" msgstr "Nov dokument je bil ustvarjen v vašem imenu!"
#: build/lib/core/api/serializers.py:517 core/api/serializers.py:517 #: build/lib/core/api/serializers.py:530 core/api/serializers.py:530
msgid "You have been granted ownership of a new document:" msgid "You have been granted ownership of a new document:"
msgstr "Dodeljeno vam je bilo lastništvo nad novim dokumentom:" msgstr "Dodeljeno vam je bilo lastništvo nad novim dokumentom:"
#: build/lib/core/api/serializers.py:553 core/api/serializers.py:553 #: build/lib/core/api/serializers.py:566 core/api/serializers.py:566
msgid "This field is required." msgid "This field is required."
msgstr "" msgstr ""
#: build/lib/core/api/serializers.py:564 core/api/serializers.py:564 #: build/lib/core/api/serializers.py:577 core/api/serializers.py:577
#, python-format #, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration." msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr "" msgstr ""
#: build/lib/core/api/viewsets.py:1279 core/api/viewsets.py:1279 #: build/lib/core/api/viewsets.py:1298 core/api/viewsets.py:1298
#, python-brace-format #, python-brace-format
msgid "copy of {title}" msgid "copy of {title}"
msgstr "" msgstr ""
@@ -231,106 +231,114 @@ msgstr "aktivni"
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts." msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Ali je treba tega uporabnika obravnavati kot aktivnega. Namesto brisanja računov počistite to izbiro." msgstr "Ali je treba tega uporabnika obravnavati kot aktivnega. Namesto brisanja računov počistite to izbiro."
#: build/lib/core/models.py:204 core/models.py:204 #: build/lib/core/models.py:197 core/models.py:197
msgid "first connection status"
msgstr ""
#: build/lib/core/models.py:199 core/models.py:199
msgid "Whether the user has completed the first connection process."
msgstr ""
#: build/lib/core/models.py:209 core/models.py:209
msgid "user" msgid "user"
msgstr "uporabnik" msgstr "uporabnik"
#: build/lib/core/models.py:205 core/models.py:205 #: build/lib/core/models.py:210 core/models.py:210
msgid "users" msgid "users"
msgstr "uporabniki" msgstr "uporabniki"
#: build/lib/core/models.py:360 core/models.py:360 #: build/lib/core/models.py:378 core/models.py:378
msgid "Active email address" msgid "Active email address"
msgstr "" msgstr ""
#: build/lib/core/models.py:361 core/models.py:361 #: build/lib/core/models.py:379 core/models.py:379
msgid "Email address to deactivate" msgid "Email address to deactivate"
msgstr "" msgstr ""
#: build/lib/core/models.py:388 core/models.py:388 #: build/lib/core/models.py:406 core/models.py:406
msgid "Unique ID in the source file" msgid "Unique ID in the source file"
msgstr "" msgstr ""
#: build/lib/core/models.py:394 build/lib/core/models.py:692 core/models.py:394 #: build/lib/core/models.py:412 build/lib/core/models.py:710 core/models.py:412
#: core/models.py:692 #: core/models.py:710
msgid "Pending" msgid "Pending"
msgstr "" msgstr ""
#: build/lib/core/models.py:395 core/models.py:395 #: build/lib/core/models.py:413 core/models.py:413
msgid "Ready" msgid "Ready"
msgstr "" msgstr ""
#: build/lib/core/models.py:396 build/lib/core/models.py:694 core/models.py:396 #: build/lib/core/models.py:414 build/lib/core/models.py:712 core/models.py:414
#: core/models.py:694 #: core/models.py:712
msgid "Done" msgid "Done"
msgstr "" msgstr ""
#: build/lib/core/models.py:397 build/lib/core/models.py:695 core/models.py:397 #: build/lib/core/models.py:415 build/lib/core/models.py:713 core/models.py:415
#: core/models.py:695 #: core/models.py:713
msgid "Error" msgid "Error"
msgstr "" msgstr ""
#: build/lib/core/models.py:405 core/models.py:405 #: build/lib/core/models.py:423 core/models.py:423
msgid "user reconciliation" msgid "user reconciliation"
msgstr "" msgstr ""
#: build/lib/core/models.py:406 core/models.py:406 #: build/lib/core/models.py:424 core/models.py:424
msgid "user reconciliations" msgid "user reconciliations"
msgstr "" msgstr ""
#: build/lib/core/models.py:644 core/models.py:644 #: build/lib/core/models.py:662 core/models.py:662
msgid "You have requested a reconciliation of your user accounts on Docs.\n" msgid "You have requested a reconciliation of your user accounts on Docs.\n"
" To confirm that you are the one who initiated the request\n" " To confirm that you are the one who initiated the request\n"
" and that this email belongs to you:" " and that this email belongs to you:"
msgstr "" msgstr ""
#: build/lib/core/models.py:650 core/models.py:650 #: build/lib/core/models.py:668 core/models.py:668
msgid "Confirm by clicking the link to start the reconciliation" msgid "Confirm by clicking the link to start the reconciliation"
msgstr "" msgstr ""
#: build/lib/core/models.py:655 build/lib/core/models.py:761 core/models.py:655 #: build/lib/core/models.py:673 build/lib/core/models.py:779 core/models.py:673
#: core/models.py:761 #: core/models.py:779
msgid "Click here" msgid "Click here"
msgstr "" msgstr ""
#: build/lib/core/models.py:656 core/models.py:656 #: build/lib/core/models.py:674 core/models.py:674
msgid "Confirm" msgid "Confirm"
msgstr "" msgstr ""
#: build/lib/core/models.py:667 core/models.py:667 #: build/lib/core/models.py:685 core/models.py:685
msgid "Your reconciliation request has been processed.\n" msgid "Your reconciliation request has been processed.\n"
" New documents are likely associated with your account:" " New documents are likely associated with your account:"
msgstr "" msgstr ""
#: build/lib/core/models.py:672 core/models.py:672 #: build/lib/core/models.py:690 core/models.py:690
msgid "Your accounts have been merged" msgid "Your accounts have been merged"
msgstr "" msgstr ""
#: build/lib/core/models.py:677 core/models.py:677 #: build/lib/core/models.py:695 core/models.py:695
msgid "Click here to see" msgid "Click here to see"
msgstr "" msgstr ""
#: build/lib/core/models.py:678 core/models.py:678 #: build/lib/core/models.py:696 core/models.py:696
msgid "See my documents" msgid "See my documents"
msgstr "" msgstr ""
#: build/lib/core/models.py:688 core/models.py:688 #: build/lib/core/models.py:706 core/models.py:706
msgid "CSV file" msgid "CSV file"
msgstr "" msgstr ""
#: build/lib/core/models.py:693 core/models.py:693 #: build/lib/core/models.py:711 core/models.py:711
msgid "Running" msgid "Running"
msgstr "" msgstr ""
#: build/lib/core/models.py:703 core/models.py:703 #: build/lib/core/models.py:721 core/models.py:721
msgid "user reconciliation CSV import" msgid "user reconciliation CSV import"
msgstr "" msgstr ""
#: build/lib/core/models.py:704 core/models.py:704 #: build/lib/core/models.py:722 core/models.py:722
msgid "user reconciliation CSV imports" msgid "user reconciliation CSV imports"
msgstr "" msgstr ""
#: build/lib/core/models.py:748 core/models.py:748 #: build/lib/core/models.py:766 core/models.py:766
#, python-brace-format #, python-brace-format
msgid "Your request for reconciliation was unsuccessful.\n" msgid "Your request for reconciliation was unsuccessful.\n"
" Reconciliation failed for the following email addresses:\n" " Reconciliation failed for the following email addresses:\n"
@@ -339,171 +347,171 @@ msgid "Your request for reconciliation was unsuccessful.\n"
" You can submit another request with the valid email addresses." " You can submit another request with the valid email addresses."
msgstr "" msgstr ""
#: build/lib/core/models.py:756 core/models.py:756 #: build/lib/core/models.py:774 core/models.py:774
msgid "Reconciliation of your Docs accounts not completed" msgid "Reconciliation of your Docs accounts not completed"
msgstr "" msgstr ""
#: build/lib/core/models.py:762 core/models.py:762 #: build/lib/core/models.py:780 core/models.py:780
msgid "Make a new request" msgid "Make a new request"
msgstr "" msgstr ""
#: build/lib/core/models.py:861 core/models.py:861 #: build/lib/core/models.py:879 core/models.py:879
msgid "title" msgid "title"
msgstr "naslov" msgstr "naslov"
#: build/lib/core/models.py:862 core/models.py:862 #: build/lib/core/models.py:880 core/models.py:880
msgid "excerpt" msgid "excerpt"
msgstr "odlomek" msgstr "odlomek"
#: build/lib/core/models.py:911 core/models.py:911 #: build/lib/core/models.py:929 core/models.py:929
msgid "Document" msgid "Document"
msgstr "Dokument" msgstr "Dokument"
#: build/lib/core/models.py:912 core/models.py:912 #: build/lib/core/models.py:930 core/models.py:930
msgid "Documents" msgid "Documents"
msgstr "Dokumenti" msgstr "Dokumenti"
#: build/lib/core/models.py:924 build/lib/core/models.py:1328 #: build/lib/core/models.py:942 build/lib/core/models.py:1346
#: core/models.py:924 core/models.py:1328 #: core/models.py:942 core/models.py:1346
msgid "Untitled Document" msgid "Untitled Document"
msgstr "Dokument brez naslova" msgstr "Dokument brez naslova"
#: build/lib/core/models.py:1329 core/models.py:1329 #: build/lib/core/models.py:1347 core/models.py:1347
msgid "Open" msgid "Open"
msgstr "Odpri" msgstr "Odpri"
#: build/lib/core/models.py:1364 core/models.py:1364 #: build/lib/core/models.py:1382 core/models.py:1382
#, python-brace-format #, python-brace-format
msgid "{name} shared a document with you!" msgid "{name} shared a document with you!"
msgstr "{name} je delil dokument z vami!" msgstr "{name} je delil dokument z vami!"
#: build/lib/core/models.py:1368 core/models.py:1368 #: build/lib/core/models.py:1386 core/models.py:1386
#, python-brace-format #, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:" msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} vas je povabil z vlogo \"{role}\" na naslednjem dokumentu:" msgstr "{name} vas je povabil z vlogo \"{role}\" na naslednjem dokumentu:"
#: build/lib/core/models.py:1374 core/models.py:1374 #: build/lib/core/models.py:1392 core/models.py:1392
#, python-brace-format #, python-brace-format
msgid "{name} shared a document with you: {title}" msgid "{name} shared a document with you: {title}"
msgstr "{name} je delil dokument z vami: {title}" msgstr "{name} je delil dokument z vami: {title}"
#: build/lib/core/models.py:1475 core/models.py:1475 #: build/lib/core/models.py:1493 core/models.py:1493
msgid "Document/user link trace" msgid "Document/user link trace"
msgstr "Dokument/sled povezave uporabnika" msgstr "Dokument/sled povezave uporabnika"
#: build/lib/core/models.py:1476 core/models.py:1476 #: build/lib/core/models.py:1494 core/models.py:1494
msgid "Document/user link traces" msgid "Document/user link traces"
msgstr "Sledi povezav dokumenta/uporabnika" msgstr "Sledi povezav dokumenta/uporabnika"
#: build/lib/core/models.py:1482 core/models.py:1482 #: build/lib/core/models.py:1500 core/models.py:1500
msgid "A link trace already exists for this document/user." msgid "A link trace already exists for this document/user."
msgstr "Za ta dokument/uporabnika že obstaja sled povezave." msgstr "Za ta dokument/uporabnika že obstaja sled povezave."
#: build/lib/core/models.py:1505 core/models.py:1505 #: build/lib/core/models.py:1523 core/models.py:1523
msgid "Document favorite" msgid "Document favorite"
msgstr "Priljubljeni dokument" msgstr "Priljubljeni dokument"
#: build/lib/core/models.py:1506 core/models.py:1506 #: build/lib/core/models.py:1524 core/models.py:1524
msgid "Document favorites" msgid "Document favorites"
msgstr "Priljubljeni dokumenti" msgstr "Priljubljeni dokumenti"
#: build/lib/core/models.py:1512 core/models.py:1512 #: build/lib/core/models.py:1530 core/models.py:1530
msgid "This document is already targeted by a favorite relation instance for the same user." 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." msgstr "Ta dokument je že ciljno usmerjen s priljubljenim primerkom relacije za istega uporabnika."
#: build/lib/core/models.py:1534 core/models.py:1534 #: build/lib/core/models.py:1552 core/models.py:1552
msgid "Document/user relation" msgid "Document/user relation"
msgstr "Odnos dokument/uporabnik" msgstr "Odnos dokument/uporabnik"
#: build/lib/core/models.py:1535 core/models.py:1535 #: build/lib/core/models.py:1553 core/models.py:1553
msgid "Document/user relations" msgid "Document/user relations"
msgstr "Odnosi dokument/uporabnik" msgstr "Odnosi dokument/uporabnik"
#: build/lib/core/models.py:1541 core/models.py:1541 #: build/lib/core/models.py:1559 core/models.py:1559
msgid "This user is already in this document." msgid "This user is already in this document."
msgstr "Ta uporabnik je že v tem dokumentu." msgstr "Ta uporabnik je že v tem dokumentu."
#: build/lib/core/models.py:1547 core/models.py:1547 #: build/lib/core/models.py:1565 core/models.py:1565
msgid "This team is already in this document." msgid "This team is already in this document."
msgstr "Ta ekipa je že v tem dokumentu." msgstr "Ta ekipa je že v tem dokumentu."
#: build/lib/core/models.py:1553 core/models.py:1553 #: build/lib/core/models.py:1571 core/models.py:1571
msgid "Either user or team must be set, not both." msgid "Either user or team must be set, not both."
msgstr "Nastaviti je treba bodisi uporabnika ali ekipo, a ne obojega." msgstr "Nastaviti je treba bodisi uporabnika ali ekipo, a ne obojega."
#: build/lib/core/models.py:1704 core/models.py:1704 #: build/lib/core/models.py:1722 core/models.py:1722
msgid "Document ask for access" msgid "Document ask for access"
msgstr "" msgstr ""
#: build/lib/core/models.py:1705 core/models.py:1705 #: build/lib/core/models.py:1723 core/models.py:1723
msgid "Document ask for accesses" msgid "Document ask for accesses"
msgstr "" msgstr ""
#: build/lib/core/models.py:1711 core/models.py:1711 #: build/lib/core/models.py:1729 core/models.py:1729
msgid "This user has already asked for access to this document." msgid "This user has already asked for access to this document."
msgstr "" msgstr ""
#: build/lib/core/models.py:1768 core/models.py:1768 #: build/lib/core/models.py:1786 core/models.py:1786
#, python-brace-format #, python-brace-format
msgid "{name} would like access to a document!" msgid "{name} would like access to a document!"
msgstr "" msgstr ""
#: build/lib/core/models.py:1772 core/models.py:1772 #: build/lib/core/models.py:1790 core/models.py:1790
#, python-brace-format #, python-brace-format
msgid "{name} would like access to the following document:" msgid "{name} would like access to the following document:"
msgstr "" msgstr ""
#: build/lib/core/models.py:1778 core/models.py:1778 #: build/lib/core/models.py:1796 core/models.py:1796
#, python-brace-format #, python-brace-format
msgid "{name} is asking for access to the document: {title}" msgid "{name} is asking for access to the document: {title}"
msgstr "" msgstr ""
#: build/lib/core/models.py:1820 core/models.py:1820 #: build/lib/core/models.py:1838 core/models.py:1838
msgid "Thread" msgid "Thread"
msgstr "" msgstr ""
#: build/lib/core/models.py:1821 core/models.py:1821 #: build/lib/core/models.py:1839 core/models.py:1839
msgid "Threads" msgid "Threads"
msgstr "" msgstr ""
#: build/lib/core/models.py:1824 build/lib/core/models.py:1876 #: build/lib/core/models.py:1842 build/lib/core/models.py:1894
#: core/models.py:1824 core/models.py:1876 #: core/models.py:1842 core/models.py:1894
msgid "Anonymous" msgid "Anonymous"
msgstr "" msgstr ""
#: build/lib/core/models.py:1871 core/models.py:1871 #: build/lib/core/models.py:1889 core/models.py:1889
msgid "Comment" msgid "Comment"
msgstr "" msgstr ""
#: build/lib/core/models.py:1872 core/models.py:1872 #: build/lib/core/models.py:1890 core/models.py:1890
msgid "Comments" msgid "Comments"
msgstr "" msgstr ""
#: build/lib/core/models.py:1921 core/models.py:1921 #: build/lib/core/models.py:1939 core/models.py:1939
msgid "This emoji has already been reacted to this comment." msgid "This emoji has already been reacted to this comment."
msgstr "" msgstr ""
#: build/lib/core/models.py:1925 core/models.py:1925 #: build/lib/core/models.py:1943 core/models.py:1943
msgid "Reaction" msgid "Reaction"
msgstr "" msgstr ""
#: build/lib/core/models.py:1926 core/models.py:1926 #: build/lib/core/models.py:1944 core/models.py:1944
msgid "Reactions" msgid "Reactions"
msgstr "" msgstr ""
#: build/lib/core/models.py:1936 core/models.py:1936 #: build/lib/core/models.py:1954 core/models.py:1954
msgid "email address" msgid "email address"
msgstr "elektronski naslov" msgstr "elektronski naslov"
#: build/lib/core/models.py:1955 core/models.py:1955 #: build/lib/core/models.py:1973 core/models.py:1973
msgid "Document invitation" msgid "Document invitation"
msgstr "Vabilo na dokument" msgstr "Vabilo na dokument"
#: build/lib/core/models.py:1956 core/models.py:1956 #: build/lib/core/models.py:1974 core/models.py:1974
msgid "Document invitations" msgid "Document invitations"
msgstr "Vabila na dokument" msgstr "Vabila na dokument"
#: build/lib/core/models.py:1976 core/models.py:1976 #: build/lib/core/models.py:1994 core/models.py:1994
msgid "This email is already associated to a registered user." msgid "This email is already associated to a registered user."
msgstr "Ta e-poštni naslov je že povezan z registriranim uporabnikom." msgstr "Ta e-poštni naslov je že povezan z registriranim uporabnikom."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: lasuite-docs\n" "Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-06 09:37+0000\n" "POT-Creation-Date: 2026-03-12 13:31+0000\n"
"PO-Revision-Date: 2026-03-09 14:02\n" "PO-Revision-Date: 2026-03-13 16:53\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Swedish\n" "Language-Team: Swedish\n"
"Language: sv_SE\n" "Language: sv_SE\n"
@@ -58,24 +58,24 @@ msgstr ""
msgid "Favorite" msgid "Favorite"
msgstr "Favoriter" msgstr "Favoriter"
#: build/lib/core/api/serializers.py:513 core/api/serializers.py:513 #: build/lib/core/api/serializers.py:526 core/api/serializers.py:526
msgid "A new document was created on your behalf!" msgid "A new document was created on your behalf!"
msgstr "Ett nytt dokument skapades åt dig!" msgstr "Ett nytt dokument skapades åt dig!"
#: build/lib/core/api/serializers.py:517 core/api/serializers.py:517 #: build/lib/core/api/serializers.py:530 core/api/serializers.py:530
msgid "You have been granted ownership of a new document:" msgid "You have been granted ownership of a new document:"
msgstr "Du har beviljats äganderätt till ett nytt dokument:" msgstr "Du har beviljats äganderätt till ett nytt dokument:"
#: build/lib/core/api/serializers.py:553 core/api/serializers.py:553 #: build/lib/core/api/serializers.py:566 core/api/serializers.py:566
msgid "This field is required." msgid "This field is required."
msgstr "" msgstr ""
#: build/lib/core/api/serializers.py:564 core/api/serializers.py:564 #: build/lib/core/api/serializers.py:577 core/api/serializers.py:577
#, python-format #, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration." msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr "" msgstr ""
#: build/lib/core/api/viewsets.py:1279 core/api/viewsets.py:1279 #: build/lib/core/api/viewsets.py:1298 core/api/viewsets.py:1298
#, python-brace-format #, python-brace-format
msgid "copy of {title}" msgid "copy of {title}"
msgstr "" msgstr ""
@@ -231,106 +231,114 @@ msgstr "aktiv"
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts." msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "" msgstr ""
#: build/lib/core/models.py:204 core/models.py:204 #: build/lib/core/models.py:197 core/models.py:197
msgid "first connection status"
msgstr ""
#: build/lib/core/models.py:199 core/models.py:199
msgid "Whether the user has completed the first connection process."
msgstr ""
#: build/lib/core/models.py:209 core/models.py:209
msgid "user" msgid "user"
msgstr "" msgstr ""
#: build/lib/core/models.py:205 core/models.py:205 #: build/lib/core/models.py:210 core/models.py:210
msgid "users" msgid "users"
msgstr "" msgstr ""
#: build/lib/core/models.py:360 core/models.py:360 #: build/lib/core/models.py:378 core/models.py:378
msgid "Active email address" msgid "Active email address"
msgstr "" msgstr ""
#: build/lib/core/models.py:361 core/models.py:361 #: build/lib/core/models.py:379 core/models.py:379
msgid "Email address to deactivate" msgid "Email address to deactivate"
msgstr "" msgstr ""
#: build/lib/core/models.py:388 core/models.py:388 #: build/lib/core/models.py:406 core/models.py:406
msgid "Unique ID in the source file" msgid "Unique ID in the source file"
msgstr "" msgstr ""
#: build/lib/core/models.py:394 build/lib/core/models.py:692 core/models.py:394 #: build/lib/core/models.py:412 build/lib/core/models.py:710 core/models.py:412
#: core/models.py:692 #: core/models.py:710
msgid "Pending" msgid "Pending"
msgstr "" msgstr ""
#: build/lib/core/models.py:395 core/models.py:395 #: build/lib/core/models.py:413 core/models.py:413
msgid "Ready" msgid "Ready"
msgstr "" msgstr ""
#: build/lib/core/models.py:396 build/lib/core/models.py:694 core/models.py:396 #: build/lib/core/models.py:414 build/lib/core/models.py:712 core/models.py:414
#: core/models.py:694 #: core/models.py:712
msgid "Done" msgid "Done"
msgstr "" msgstr ""
#: build/lib/core/models.py:397 build/lib/core/models.py:695 core/models.py:397 #: build/lib/core/models.py:415 build/lib/core/models.py:713 core/models.py:415
#: core/models.py:695 #: core/models.py:713
msgid "Error" msgid "Error"
msgstr "" msgstr ""
#: build/lib/core/models.py:405 core/models.py:405 #: build/lib/core/models.py:423 core/models.py:423
msgid "user reconciliation" msgid "user reconciliation"
msgstr "" msgstr ""
#: build/lib/core/models.py:406 core/models.py:406 #: build/lib/core/models.py:424 core/models.py:424
msgid "user reconciliations" msgid "user reconciliations"
msgstr "" msgstr ""
#: build/lib/core/models.py:644 core/models.py:644 #: build/lib/core/models.py:662 core/models.py:662
msgid "You have requested a reconciliation of your user accounts on Docs.\n" msgid "You have requested a reconciliation of your user accounts on Docs.\n"
" To confirm that you are the one who initiated the request\n" " To confirm that you are the one who initiated the request\n"
" and that this email belongs to you:" " and that this email belongs to you:"
msgstr "" msgstr ""
#: build/lib/core/models.py:650 core/models.py:650 #: build/lib/core/models.py:668 core/models.py:668
msgid "Confirm by clicking the link to start the reconciliation" msgid "Confirm by clicking the link to start the reconciliation"
msgstr "" msgstr ""
#: build/lib/core/models.py:655 build/lib/core/models.py:761 core/models.py:655 #: build/lib/core/models.py:673 build/lib/core/models.py:779 core/models.py:673
#: core/models.py:761 #: core/models.py:779
msgid "Click here" msgid "Click here"
msgstr "" msgstr ""
#: build/lib/core/models.py:656 core/models.py:656 #: build/lib/core/models.py:674 core/models.py:674
msgid "Confirm" msgid "Confirm"
msgstr "" msgstr ""
#: build/lib/core/models.py:667 core/models.py:667 #: build/lib/core/models.py:685 core/models.py:685
msgid "Your reconciliation request has been processed.\n" msgid "Your reconciliation request has been processed.\n"
" New documents are likely associated with your account:" " New documents are likely associated with your account:"
msgstr "" msgstr ""
#: build/lib/core/models.py:672 core/models.py:672 #: build/lib/core/models.py:690 core/models.py:690
msgid "Your accounts have been merged" msgid "Your accounts have been merged"
msgstr "" msgstr ""
#: build/lib/core/models.py:677 core/models.py:677 #: build/lib/core/models.py:695 core/models.py:695
msgid "Click here to see" msgid "Click here to see"
msgstr "" msgstr ""
#: build/lib/core/models.py:678 core/models.py:678 #: build/lib/core/models.py:696 core/models.py:696
msgid "See my documents" msgid "See my documents"
msgstr "" msgstr ""
#: build/lib/core/models.py:688 core/models.py:688 #: build/lib/core/models.py:706 core/models.py:706
msgid "CSV file" msgid "CSV file"
msgstr "" msgstr ""
#: build/lib/core/models.py:693 core/models.py:693 #: build/lib/core/models.py:711 core/models.py:711
msgid "Running" msgid "Running"
msgstr "" msgstr ""
#: build/lib/core/models.py:703 core/models.py:703 #: build/lib/core/models.py:721 core/models.py:721
msgid "user reconciliation CSV import" msgid "user reconciliation CSV import"
msgstr "" msgstr ""
#: build/lib/core/models.py:704 core/models.py:704 #: build/lib/core/models.py:722 core/models.py:722
msgid "user reconciliation CSV imports" msgid "user reconciliation CSV imports"
msgstr "" msgstr ""
#: build/lib/core/models.py:748 core/models.py:748 #: build/lib/core/models.py:766 core/models.py:766
#, python-brace-format #, python-brace-format
msgid "Your request for reconciliation was unsuccessful.\n" msgid "Your request for reconciliation was unsuccessful.\n"
" Reconciliation failed for the following email addresses:\n" " Reconciliation failed for the following email addresses:\n"
@@ -339,171 +347,171 @@ msgid "Your request for reconciliation was unsuccessful.\n"
" You can submit another request with the valid email addresses." " You can submit another request with the valid email addresses."
msgstr "" msgstr ""
#: build/lib/core/models.py:756 core/models.py:756 #: build/lib/core/models.py:774 core/models.py:774
msgid "Reconciliation of your Docs accounts not completed" msgid "Reconciliation of your Docs accounts not completed"
msgstr "" msgstr ""
#: build/lib/core/models.py:762 core/models.py:762 #: build/lib/core/models.py:780 core/models.py:780
msgid "Make a new request" msgid "Make a new request"
msgstr "" msgstr ""
#: build/lib/core/models.py:861 core/models.py:861 #: build/lib/core/models.py:879 core/models.py:879
msgid "title" msgid "title"
msgstr "" msgstr ""
#: build/lib/core/models.py:862 core/models.py:862 #: build/lib/core/models.py:880 core/models.py:880
msgid "excerpt" msgid "excerpt"
msgstr "" msgstr ""
#: build/lib/core/models.py:911 core/models.py:911 #: build/lib/core/models.py:929 core/models.py:929
msgid "Document" msgid "Document"
msgstr "" msgstr ""
#: build/lib/core/models.py:912 core/models.py:912 #: build/lib/core/models.py:930 core/models.py:930
msgid "Documents" msgid "Documents"
msgstr "" msgstr ""
#: build/lib/core/models.py:924 build/lib/core/models.py:1328 #: build/lib/core/models.py:942 build/lib/core/models.py:1346
#: core/models.py:924 core/models.py:1328 #: core/models.py:942 core/models.py:1346
msgid "Untitled Document" msgid "Untitled Document"
msgstr "" msgstr ""
#: build/lib/core/models.py:1329 core/models.py:1329 #: build/lib/core/models.py:1347 core/models.py:1347
msgid "Open" msgid "Open"
msgstr "Öppna" msgstr "Öppna"
#: build/lib/core/models.py:1364 core/models.py:1364 #: build/lib/core/models.py:1382 core/models.py:1382
#, python-brace-format #, python-brace-format
msgid "{name} shared a document with you!" msgid "{name} shared a document with you!"
msgstr "" msgstr ""
#: build/lib/core/models.py:1368 core/models.py:1368 #: build/lib/core/models.py:1386 core/models.py:1386
#, python-brace-format #, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:" msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "" msgstr ""
#: build/lib/core/models.py:1374 core/models.py:1374 #: build/lib/core/models.py:1392 core/models.py:1392
#, python-brace-format #, python-brace-format
msgid "{name} shared a document with you: {title}" msgid "{name} shared a document with you: {title}"
msgstr "" msgstr ""
#: build/lib/core/models.py:1475 core/models.py:1475 #: build/lib/core/models.py:1493 core/models.py:1493
msgid "Document/user link trace" msgid "Document/user link trace"
msgstr "" msgstr ""
#: build/lib/core/models.py:1476 core/models.py:1476 #: build/lib/core/models.py:1494 core/models.py:1494
msgid "Document/user link traces" msgid "Document/user link traces"
msgstr "" msgstr ""
#: build/lib/core/models.py:1482 core/models.py:1482 #: build/lib/core/models.py:1500 core/models.py:1500
msgid "A link trace already exists for this document/user." msgid "A link trace already exists for this document/user."
msgstr "" msgstr ""
#: build/lib/core/models.py:1505 core/models.py:1505 #: build/lib/core/models.py:1523 core/models.py:1523
msgid "Document favorite" msgid "Document favorite"
msgstr "" msgstr ""
#: build/lib/core/models.py:1506 core/models.py:1506 #: build/lib/core/models.py:1524 core/models.py:1524
msgid "Document favorites" msgid "Document favorites"
msgstr "" msgstr ""
#: build/lib/core/models.py:1512 core/models.py:1512 #: build/lib/core/models.py:1530 core/models.py:1530
msgid "This document is already targeted by a favorite relation instance for the same user." msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "" msgstr ""
#: build/lib/core/models.py:1534 core/models.py:1534 #: build/lib/core/models.py:1552 core/models.py:1552
msgid "Document/user relation" msgid "Document/user relation"
msgstr "" msgstr ""
#: build/lib/core/models.py:1535 core/models.py:1535 #: build/lib/core/models.py:1553 core/models.py:1553
msgid "Document/user relations" msgid "Document/user relations"
msgstr "" msgstr ""
#: build/lib/core/models.py:1541 core/models.py:1541 #: build/lib/core/models.py:1559 core/models.py:1559
msgid "This user is already in this document." msgid "This user is already in this document."
msgstr "" msgstr ""
#: build/lib/core/models.py:1547 core/models.py:1547 #: build/lib/core/models.py:1565 core/models.py:1565
msgid "This team is already in this document." msgid "This team is already in this document."
msgstr "" msgstr ""
#: build/lib/core/models.py:1553 core/models.py:1553 #: build/lib/core/models.py:1571 core/models.py:1571
msgid "Either user or team must be set, not both." msgid "Either user or team must be set, not both."
msgstr "" msgstr ""
#: build/lib/core/models.py:1704 core/models.py:1704 #: build/lib/core/models.py:1722 core/models.py:1722
msgid "Document ask for access" msgid "Document ask for access"
msgstr "" msgstr ""
#: build/lib/core/models.py:1705 core/models.py:1705 #: build/lib/core/models.py:1723 core/models.py:1723
msgid "Document ask for accesses" msgid "Document ask for accesses"
msgstr "" msgstr ""
#: build/lib/core/models.py:1711 core/models.py:1711 #: build/lib/core/models.py:1729 core/models.py:1729
msgid "This user has already asked for access to this document." msgid "This user has already asked for access to this document."
msgstr "" msgstr ""
#: build/lib/core/models.py:1768 core/models.py:1768 #: build/lib/core/models.py:1786 core/models.py:1786
#, python-brace-format #, python-brace-format
msgid "{name} would like access to a document!" msgid "{name} would like access to a document!"
msgstr "" msgstr ""
#: build/lib/core/models.py:1772 core/models.py:1772 #: build/lib/core/models.py:1790 core/models.py:1790
#, python-brace-format #, python-brace-format
msgid "{name} would like access to the following document:" msgid "{name} would like access to the following document:"
msgstr "" msgstr ""
#: build/lib/core/models.py:1778 core/models.py:1778 #: build/lib/core/models.py:1796 core/models.py:1796
#, python-brace-format #, python-brace-format
msgid "{name} is asking for access to the document: {title}" msgid "{name} is asking for access to the document: {title}"
msgstr "" msgstr ""
#: build/lib/core/models.py:1820 core/models.py:1820 #: build/lib/core/models.py:1838 core/models.py:1838
msgid "Thread" msgid "Thread"
msgstr "" msgstr ""
#: build/lib/core/models.py:1821 core/models.py:1821 #: build/lib/core/models.py:1839 core/models.py:1839
msgid "Threads" msgid "Threads"
msgstr "" msgstr ""
#: build/lib/core/models.py:1824 build/lib/core/models.py:1876 #: build/lib/core/models.py:1842 build/lib/core/models.py:1894
#: core/models.py:1824 core/models.py:1876 #: core/models.py:1842 core/models.py:1894
msgid "Anonymous" msgid "Anonymous"
msgstr "" msgstr ""
#: build/lib/core/models.py:1871 core/models.py:1871 #: build/lib/core/models.py:1889 core/models.py:1889
msgid "Comment" msgid "Comment"
msgstr "" msgstr ""
#: build/lib/core/models.py:1872 core/models.py:1872 #: build/lib/core/models.py:1890 core/models.py:1890
msgid "Comments" msgid "Comments"
msgstr "" msgstr ""
#: build/lib/core/models.py:1921 core/models.py:1921 #: build/lib/core/models.py:1939 core/models.py:1939
msgid "This emoji has already been reacted to this comment." msgid "This emoji has already been reacted to this comment."
msgstr "" msgstr ""
#: build/lib/core/models.py:1925 core/models.py:1925 #: build/lib/core/models.py:1943 core/models.py:1943
msgid "Reaction" msgid "Reaction"
msgstr "" msgstr ""
#: build/lib/core/models.py:1926 core/models.py:1926 #: build/lib/core/models.py:1944 core/models.py:1944
msgid "Reactions" msgid "Reactions"
msgstr "" msgstr ""
#: build/lib/core/models.py:1936 core/models.py:1936 #: build/lib/core/models.py:1954 core/models.py:1954
msgid "email address" msgid "email address"
msgstr "e-postadress" msgstr "e-postadress"
#: build/lib/core/models.py:1955 core/models.py:1955 #: build/lib/core/models.py:1973 core/models.py:1973
msgid "Document invitation" msgid "Document invitation"
msgstr "Bjud in dokument" msgstr "Bjud in dokument"
#: build/lib/core/models.py:1956 core/models.py:1956 #: build/lib/core/models.py:1974 core/models.py:1974
msgid "Document invitations" msgid "Document invitations"
msgstr "Inbjudningar dokument" msgstr "Inbjudningar dokument"
#: build/lib/core/models.py:1976 core/models.py:1976 #: build/lib/core/models.py:1994 core/models.py:1994
msgid "This email is already associated to a registered user." msgid "This email is already associated to a registered user."
msgstr "Denna e-postadress är redan associerad med en registrerad användare." msgstr "Denna e-postadress är redan associerad med en registrerad användare."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: lasuite-docs\n" "Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-06 09:37+0000\n" "POT-Creation-Date: 2026-03-12 13:31+0000\n"
"PO-Revision-Date: 2026-03-09 14:02\n" "PO-Revision-Date: 2026-03-13 16:53\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Turkish\n" "Language-Team: Turkish\n"
"Language: tr_TR\n" "Language: tr_TR\n"
@@ -58,24 +58,24 @@ msgstr ""
msgid "Favorite" msgid "Favorite"
msgstr "" msgstr ""
#: build/lib/core/api/serializers.py:513 core/api/serializers.py:513 #: build/lib/core/api/serializers.py:526 core/api/serializers.py:526
msgid "A new document was created on your behalf!" msgid "A new document was created on your behalf!"
msgstr "" msgstr ""
#: build/lib/core/api/serializers.py:517 core/api/serializers.py:517 #: build/lib/core/api/serializers.py:530 core/api/serializers.py:530
msgid "You have been granted ownership of a new document:" msgid "You have been granted ownership of a new document:"
msgstr "" msgstr ""
#: build/lib/core/api/serializers.py:553 core/api/serializers.py:553 #: build/lib/core/api/serializers.py:566 core/api/serializers.py:566
msgid "This field is required." msgid "This field is required."
msgstr "" msgstr ""
#: build/lib/core/api/serializers.py:564 core/api/serializers.py:564 #: build/lib/core/api/serializers.py:577 core/api/serializers.py:577
#, python-format #, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration." msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr "" msgstr ""
#: build/lib/core/api/viewsets.py:1279 core/api/viewsets.py:1279 #: build/lib/core/api/viewsets.py:1298 core/api/viewsets.py:1298
#, python-brace-format #, python-brace-format
msgid "copy of {title}" msgid "copy of {title}"
msgstr "" msgstr ""
@@ -231,106 +231,114 @@ msgstr ""
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts." msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "" msgstr ""
#: build/lib/core/models.py:204 core/models.py:204 #: build/lib/core/models.py:197 core/models.py:197
msgid "first connection status"
msgstr ""
#: build/lib/core/models.py:199 core/models.py:199
msgid "Whether the user has completed the first connection process."
msgstr ""
#: build/lib/core/models.py:209 core/models.py:209
msgid "user" msgid "user"
msgstr "" msgstr ""
#: build/lib/core/models.py:205 core/models.py:205 #: build/lib/core/models.py:210 core/models.py:210
msgid "users" msgid "users"
msgstr "" msgstr ""
#: build/lib/core/models.py:360 core/models.py:360 #: build/lib/core/models.py:378 core/models.py:378
msgid "Active email address" msgid "Active email address"
msgstr "" msgstr ""
#: build/lib/core/models.py:361 core/models.py:361 #: build/lib/core/models.py:379 core/models.py:379
msgid "Email address to deactivate" msgid "Email address to deactivate"
msgstr "" msgstr ""
#: build/lib/core/models.py:388 core/models.py:388 #: build/lib/core/models.py:406 core/models.py:406
msgid "Unique ID in the source file" msgid "Unique ID in the source file"
msgstr "" msgstr ""
#: build/lib/core/models.py:394 build/lib/core/models.py:692 core/models.py:394 #: build/lib/core/models.py:412 build/lib/core/models.py:710 core/models.py:412
#: core/models.py:692 #: core/models.py:710
msgid "Pending" msgid "Pending"
msgstr "" msgstr ""
#: build/lib/core/models.py:395 core/models.py:395 #: build/lib/core/models.py:413 core/models.py:413
msgid "Ready" msgid "Ready"
msgstr "" msgstr ""
#: build/lib/core/models.py:396 build/lib/core/models.py:694 core/models.py:396 #: build/lib/core/models.py:414 build/lib/core/models.py:712 core/models.py:414
#: core/models.py:694 #: core/models.py:712
msgid "Done" msgid "Done"
msgstr "" msgstr ""
#: build/lib/core/models.py:397 build/lib/core/models.py:695 core/models.py:397 #: build/lib/core/models.py:415 build/lib/core/models.py:713 core/models.py:415
#: core/models.py:695 #: core/models.py:713
msgid "Error" msgid "Error"
msgstr "" msgstr ""
#: build/lib/core/models.py:405 core/models.py:405 #: build/lib/core/models.py:423 core/models.py:423
msgid "user reconciliation" msgid "user reconciliation"
msgstr "" msgstr ""
#: build/lib/core/models.py:406 core/models.py:406 #: build/lib/core/models.py:424 core/models.py:424
msgid "user reconciliations" msgid "user reconciliations"
msgstr "" msgstr ""
#: build/lib/core/models.py:644 core/models.py:644 #: build/lib/core/models.py:662 core/models.py:662
msgid "You have requested a reconciliation of your user accounts on Docs.\n" msgid "You have requested a reconciliation of your user accounts on Docs.\n"
" To confirm that you are the one who initiated the request\n" " To confirm that you are the one who initiated the request\n"
" and that this email belongs to you:" " and that this email belongs to you:"
msgstr "" msgstr ""
#: build/lib/core/models.py:650 core/models.py:650 #: build/lib/core/models.py:668 core/models.py:668
msgid "Confirm by clicking the link to start the reconciliation" msgid "Confirm by clicking the link to start the reconciliation"
msgstr "" msgstr ""
#: build/lib/core/models.py:655 build/lib/core/models.py:761 core/models.py:655 #: build/lib/core/models.py:673 build/lib/core/models.py:779 core/models.py:673
#: core/models.py:761 #: core/models.py:779
msgid "Click here" msgid "Click here"
msgstr "" msgstr ""
#: build/lib/core/models.py:656 core/models.py:656 #: build/lib/core/models.py:674 core/models.py:674
msgid "Confirm" msgid "Confirm"
msgstr "" msgstr ""
#: build/lib/core/models.py:667 core/models.py:667 #: build/lib/core/models.py:685 core/models.py:685
msgid "Your reconciliation request has been processed.\n" msgid "Your reconciliation request has been processed.\n"
" New documents are likely associated with your account:" " New documents are likely associated with your account:"
msgstr "" msgstr ""
#: build/lib/core/models.py:672 core/models.py:672 #: build/lib/core/models.py:690 core/models.py:690
msgid "Your accounts have been merged" msgid "Your accounts have been merged"
msgstr "" msgstr ""
#: build/lib/core/models.py:677 core/models.py:677 #: build/lib/core/models.py:695 core/models.py:695
msgid "Click here to see" msgid "Click here to see"
msgstr "" msgstr ""
#: build/lib/core/models.py:678 core/models.py:678 #: build/lib/core/models.py:696 core/models.py:696
msgid "See my documents" msgid "See my documents"
msgstr "" msgstr ""
#: build/lib/core/models.py:688 core/models.py:688 #: build/lib/core/models.py:706 core/models.py:706
msgid "CSV file" msgid "CSV file"
msgstr "" msgstr ""
#: build/lib/core/models.py:693 core/models.py:693 #: build/lib/core/models.py:711 core/models.py:711
msgid "Running" msgid "Running"
msgstr "" msgstr ""
#: build/lib/core/models.py:703 core/models.py:703 #: build/lib/core/models.py:721 core/models.py:721
msgid "user reconciliation CSV import" msgid "user reconciliation CSV import"
msgstr "" msgstr ""
#: build/lib/core/models.py:704 core/models.py:704 #: build/lib/core/models.py:722 core/models.py:722
msgid "user reconciliation CSV imports" msgid "user reconciliation CSV imports"
msgstr "" msgstr ""
#: build/lib/core/models.py:748 core/models.py:748 #: build/lib/core/models.py:766 core/models.py:766
#, python-brace-format #, python-brace-format
msgid "Your request for reconciliation was unsuccessful.\n" msgid "Your request for reconciliation was unsuccessful.\n"
" Reconciliation failed for the following email addresses:\n" " Reconciliation failed for the following email addresses:\n"
@@ -339,171 +347,171 @@ msgid "Your request for reconciliation was unsuccessful.\n"
" You can submit another request with the valid email addresses." " You can submit another request with the valid email addresses."
msgstr "" msgstr ""
#: build/lib/core/models.py:756 core/models.py:756 #: build/lib/core/models.py:774 core/models.py:774
msgid "Reconciliation of your Docs accounts not completed" msgid "Reconciliation of your Docs accounts not completed"
msgstr "" msgstr ""
#: build/lib/core/models.py:762 core/models.py:762 #: build/lib/core/models.py:780 core/models.py:780
msgid "Make a new request" msgid "Make a new request"
msgstr "" msgstr ""
#: build/lib/core/models.py:861 core/models.py:861 #: build/lib/core/models.py:879 core/models.py:879
msgid "title" msgid "title"
msgstr "" msgstr ""
#: build/lib/core/models.py:862 core/models.py:862 #: build/lib/core/models.py:880 core/models.py:880
msgid "excerpt" msgid "excerpt"
msgstr "" msgstr ""
#: build/lib/core/models.py:911 core/models.py:911 #: build/lib/core/models.py:929 core/models.py:929
msgid "Document" msgid "Document"
msgstr "" msgstr ""
#: build/lib/core/models.py:912 core/models.py:912 #: build/lib/core/models.py:930 core/models.py:930
msgid "Documents" msgid "Documents"
msgstr "" msgstr ""
#: build/lib/core/models.py:924 build/lib/core/models.py:1328 #: build/lib/core/models.py:942 build/lib/core/models.py:1346
#: core/models.py:924 core/models.py:1328 #: core/models.py:942 core/models.py:1346
msgid "Untitled Document" msgid "Untitled Document"
msgstr "" msgstr ""
#: build/lib/core/models.py:1329 core/models.py:1329 #: build/lib/core/models.py:1347 core/models.py:1347
msgid "Open" msgid "Open"
msgstr "" msgstr ""
#: build/lib/core/models.py:1364 core/models.py:1364 #: build/lib/core/models.py:1382 core/models.py:1382
#, python-brace-format #, python-brace-format
msgid "{name} shared a document with you!" msgid "{name} shared a document with you!"
msgstr "" msgstr ""
#: build/lib/core/models.py:1368 core/models.py:1368 #: build/lib/core/models.py:1386 core/models.py:1386
#, python-brace-format #, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:" msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "" msgstr ""
#: build/lib/core/models.py:1374 core/models.py:1374 #: build/lib/core/models.py:1392 core/models.py:1392
#, python-brace-format #, python-brace-format
msgid "{name} shared a document with you: {title}" msgid "{name} shared a document with you: {title}"
msgstr "" msgstr ""
#: build/lib/core/models.py:1475 core/models.py:1475 #: build/lib/core/models.py:1493 core/models.py:1493
msgid "Document/user link trace" msgid "Document/user link trace"
msgstr "" msgstr ""
#: build/lib/core/models.py:1476 core/models.py:1476 #: build/lib/core/models.py:1494 core/models.py:1494
msgid "Document/user link traces" msgid "Document/user link traces"
msgstr "" msgstr ""
#: build/lib/core/models.py:1482 core/models.py:1482 #: build/lib/core/models.py:1500 core/models.py:1500
msgid "A link trace already exists for this document/user." msgid "A link trace already exists for this document/user."
msgstr "" msgstr ""
#: build/lib/core/models.py:1505 core/models.py:1505 #: build/lib/core/models.py:1523 core/models.py:1523
msgid "Document favorite" msgid "Document favorite"
msgstr "" msgstr ""
#: build/lib/core/models.py:1506 core/models.py:1506 #: build/lib/core/models.py:1524 core/models.py:1524
msgid "Document favorites" msgid "Document favorites"
msgstr "" msgstr ""
#: build/lib/core/models.py:1512 core/models.py:1512 #: build/lib/core/models.py:1530 core/models.py:1530
msgid "This document is already targeted by a favorite relation instance for the same user." msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "" msgstr ""
#: build/lib/core/models.py:1534 core/models.py:1534 #: build/lib/core/models.py:1552 core/models.py:1552
msgid "Document/user relation" msgid "Document/user relation"
msgstr "" msgstr ""
#: build/lib/core/models.py:1535 core/models.py:1535 #: build/lib/core/models.py:1553 core/models.py:1553
msgid "Document/user relations" msgid "Document/user relations"
msgstr "" msgstr ""
#: build/lib/core/models.py:1541 core/models.py:1541 #: build/lib/core/models.py:1559 core/models.py:1559
msgid "This user is already in this document." msgid "This user is already in this document."
msgstr "" msgstr ""
#: build/lib/core/models.py:1547 core/models.py:1547 #: build/lib/core/models.py:1565 core/models.py:1565
msgid "This team is already in this document." msgid "This team is already in this document."
msgstr "" msgstr ""
#: build/lib/core/models.py:1553 core/models.py:1553 #: build/lib/core/models.py:1571 core/models.py:1571
msgid "Either user or team must be set, not both." msgid "Either user or team must be set, not both."
msgstr "" msgstr ""
#: build/lib/core/models.py:1704 core/models.py:1704 #: build/lib/core/models.py:1722 core/models.py:1722
msgid "Document ask for access" msgid "Document ask for access"
msgstr "" msgstr ""
#: build/lib/core/models.py:1705 core/models.py:1705 #: build/lib/core/models.py:1723 core/models.py:1723
msgid "Document ask for accesses" msgid "Document ask for accesses"
msgstr "" msgstr ""
#: build/lib/core/models.py:1711 core/models.py:1711 #: build/lib/core/models.py:1729 core/models.py:1729
msgid "This user has already asked for access to this document." msgid "This user has already asked for access to this document."
msgstr "" msgstr ""
#: build/lib/core/models.py:1768 core/models.py:1768 #: build/lib/core/models.py:1786 core/models.py:1786
#, python-brace-format #, python-brace-format
msgid "{name} would like access to a document!" msgid "{name} would like access to a document!"
msgstr "" msgstr ""
#: build/lib/core/models.py:1772 core/models.py:1772 #: build/lib/core/models.py:1790 core/models.py:1790
#, python-brace-format #, python-brace-format
msgid "{name} would like access to the following document:" msgid "{name} would like access to the following document:"
msgstr "" msgstr ""
#: build/lib/core/models.py:1778 core/models.py:1778 #: build/lib/core/models.py:1796 core/models.py:1796
#, python-brace-format #, python-brace-format
msgid "{name} is asking for access to the document: {title}" msgid "{name} is asking for access to the document: {title}"
msgstr "" msgstr ""
#: build/lib/core/models.py:1820 core/models.py:1820 #: build/lib/core/models.py:1838 core/models.py:1838
msgid "Thread" msgid "Thread"
msgstr "" msgstr ""
#: build/lib/core/models.py:1821 core/models.py:1821 #: build/lib/core/models.py:1839 core/models.py:1839
msgid "Threads" msgid "Threads"
msgstr "" msgstr ""
#: build/lib/core/models.py:1824 build/lib/core/models.py:1876 #: build/lib/core/models.py:1842 build/lib/core/models.py:1894
#: core/models.py:1824 core/models.py:1876 #: core/models.py:1842 core/models.py:1894
msgid "Anonymous" msgid "Anonymous"
msgstr "" msgstr ""
#: build/lib/core/models.py:1871 core/models.py:1871 #: build/lib/core/models.py:1889 core/models.py:1889
msgid "Comment" msgid "Comment"
msgstr "" msgstr ""
#: build/lib/core/models.py:1872 core/models.py:1872 #: build/lib/core/models.py:1890 core/models.py:1890
msgid "Comments" msgid "Comments"
msgstr "" msgstr ""
#: build/lib/core/models.py:1921 core/models.py:1921 #: build/lib/core/models.py:1939 core/models.py:1939
msgid "This emoji has already been reacted to this comment." msgid "This emoji has already been reacted to this comment."
msgstr "" msgstr ""
#: build/lib/core/models.py:1925 core/models.py:1925 #: build/lib/core/models.py:1943 core/models.py:1943
msgid "Reaction" msgid "Reaction"
msgstr "" msgstr ""
#: build/lib/core/models.py:1926 core/models.py:1926 #: build/lib/core/models.py:1944 core/models.py:1944
msgid "Reactions" msgid "Reactions"
msgstr "" msgstr ""
#: build/lib/core/models.py:1936 core/models.py:1936 #: build/lib/core/models.py:1954 core/models.py:1954
msgid "email address" msgid "email address"
msgstr "" msgstr ""
#: build/lib/core/models.py:1955 core/models.py:1955 #: build/lib/core/models.py:1973 core/models.py:1973
msgid "Document invitation" msgid "Document invitation"
msgstr "" msgstr ""
#: build/lib/core/models.py:1956 core/models.py:1956 #: build/lib/core/models.py:1974 core/models.py:1974
msgid "Document invitations" msgid "Document invitations"
msgstr "" msgstr ""
#: build/lib/core/models.py:1976 core/models.py:1976 #: build/lib/core/models.py:1994 core/models.py:1994
msgid "This email is already associated to a registered user." msgid "This email is already associated to a registered user."
msgstr "" msgstr ""

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: lasuite-docs\n" "Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-06 09:37+0000\n" "POT-Creation-Date: 2026-03-12 13:31+0000\n"
"PO-Revision-Date: 2026-03-09 14:02\n" "PO-Revision-Date: 2026-03-13 16:53\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Ukrainian\n" "Language-Team: Ukrainian\n"
"Language: uk_UA\n" "Language: uk_UA\n"
@@ -58,24 +58,24 @@ msgstr "Приховано"
msgid "Favorite" msgid "Favorite"
msgstr "Обране" msgstr "Обране"
#: build/lib/core/api/serializers.py:513 core/api/serializers.py:513 #: build/lib/core/api/serializers.py:526 core/api/serializers.py:526
msgid "A new document was created on your behalf!" msgid "A new document was created on your behalf!"
msgstr "Новий документ був створений від вашого імені!" msgstr "Новий документ був створений від вашого імені!"
#: build/lib/core/api/serializers.py:517 core/api/serializers.py:517 #: build/lib/core/api/serializers.py:530 core/api/serializers.py:530
msgid "You have been granted ownership of a new document:" msgid "You have been granted ownership of a new document:"
msgstr "Ви тепер є власником нового документа:" msgstr "Ви тепер є власником нового документа:"
#: build/lib/core/api/serializers.py:553 core/api/serializers.py:553 #: build/lib/core/api/serializers.py:566 core/api/serializers.py:566
msgid "This field is required." msgid "This field is required."
msgstr "Це поле є обов’язковим." msgstr "Це поле є обов’язковим."
#: build/lib/core/api/serializers.py:564 core/api/serializers.py:564 #: build/lib/core/api/serializers.py:577 core/api/serializers.py:577
#, python-format #, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration." msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr "Доступ до посилання '%(link_reach)s' заборонено на основі конфігурації батьківського документа." msgstr "Доступ до посилання '%(link_reach)s' заборонено на основі конфігурації батьківського документа."
#: build/lib/core/api/viewsets.py:1279 core/api/viewsets.py:1279 #: build/lib/core/api/viewsets.py:1298 core/api/viewsets.py:1298
#, python-brace-format #, python-brace-format
msgid "copy of {title}" msgid "copy of {title}"
msgstr "копія {title}" msgstr "копія {title}"
@@ -231,54 +231,62 @@ msgstr "активний"
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts." msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Чи слід ставитися до цього користувача як до активного. Зніміть вибір замість видалення облікового запису." msgstr "Чи слід ставитися до цього користувача як до активного. Зніміть вибір замість видалення облікового запису."
#: build/lib/core/models.py:204 core/models.py:204 #: build/lib/core/models.py:197 core/models.py:197
msgid "first connection status"
msgstr "стан першого з'єднання"
#: build/lib/core/models.py:199 core/models.py:199
msgid "Whether the user has completed the first connection process."
msgstr "Чи завершив користувач перший процес з'єднання."
#: build/lib/core/models.py:209 core/models.py:209
msgid "user" msgid "user"
msgstr "користувач" msgstr "користувач"
#: build/lib/core/models.py:205 core/models.py:205 #: build/lib/core/models.py:210 core/models.py:210
msgid "users" msgid "users"
msgstr "користувачі" msgstr "користувачі"
#: build/lib/core/models.py:360 core/models.py:360 #: build/lib/core/models.py:378 core/models.py:378
msgid "Active email address" msgid "Active email address"
msgstr "Активна електронна адреса" msgstr "Активна електронна адреса"
#: build/lib/core/models.py:361 core/models.py:361 #: build/lib/core/models.py:379 core/models.py:379
msgid "Email address to deactivate" msgid "Email address to deactivate"
msgstr "Електронна адреса, що буде деактивована" msgstr "Електронна адреса, що буде деактивована"
#: build/lib/core/models.py:388 core/models.py:388 #: build/lib/core/models.py:406 core/models.py:406
msgid "Unique ID in the source file" msgid "Unique ID in the source file"
msgstr "Унікальний ідентифікатор у вихідному файлі" msgstr "Унікальний ідентифікатор у вихідному файлі"
#: build/lib/core/models.py:394 build/lib/core/models.py:692 core/models.py:394 #: build/lib/core/models.py:412 build/lib/core/models.py:710 core/models.py:412
#: core/models.py:692 #: core/models.py:710
msgid "Pending" msgid "Pending"
msgstr "В очікуванні" msgstr "В очікуванні"
#: build/lib/core/models.py:395 core/models.py:395 #: build/lib/core/models.py:413 core/models.py:413
msgid "Ready" msgid "Ready"
msgstr "Готово" msgstr "Готово"
#: build/lib/core/models.py:396 build/lib/core/models.py:694 core/models.py:396 #: build/lib/core/models.py:414 build/lib/core/models.py:712 core/models.py:414
#: core/models.py:694 #: core/models.py:712
msgid "Done" msgid "Done"
msgstr "Виконано" msgstr "Виконано"
#: build/lib/core/models.py:397 build/lib/core/models.py:695 core/models.py:397 #: build/lib/core/models.py:415 build/lib/core/models.py:713 core/models.py:415
#: core/models.py:695 #: core/models.py:713
msgid "Error" msgid "Error"
msgstr "Помилка" msgstr "Помилка"
#: build/lib/core/models.py:405 core/models.py:405 #: build/lib/core/models.py:423 core/models.py:423
msgid "user reconciliation" msgid "user reconciliation"
msgstr "узгодження користувачів" msgstr "узгодження користувачів"
#: build/lib/core/models.py:406 core/models.py:406 #: build/lib/core/models.py:424 core/models.py:424
msgid "user reconciliations" msgid "user reconciliations"
msgstr "узгодження користувачів" msgstr "узгодження користувачів"
#: build/lib/core/models.py:644 core/models.py:644 #: build/lib/core/models.py:662 core/models.py:662
msgid "You have requested a reconciliation of your user accounts on Docs.\n" msgid "You have requested a reconciliation of your user accounts on Docs.\n"
" To confirm that you are the one who initiated the request\n" " To confirm that you are the one who initiated the request\n"
" and that this email belongs to you:" " and that this email belongs to you:"
@@ -286,54 +294,54 @@ msgstr "Ви запросили узгодження своїх облікови
" Щоб підтвердити, що саме ви ініціювали запит\n" " Щоб підтвердити, що саме ви ініціювали запит\n"
" і що ця електронна адреса належить вам:" " і що ця електронна адреса належить вам:"
#: build/lib/core/models.py:650 core/models.py:650 #: build/lib/core/models.py:668 core/models.py:668
msgid "Confirm by clicking the link to start the reconciliation" msgid "Confirm by clicking the link to start the reconciliation"
msgstr "Підтвердіть, натиснувши на посилання, щоб почати узгодження" msgstr "Підтвердіть, натиснувши на посилання, щоб почати узгодження"
#: build/lib/core/models.py:655 build/lib/core/models.py:761 core/models.py:655 #: build/lib/core/models.py:673 build/lib/core/models.py:779 core/models.py:673
#: core/models.py:761 #: core/models.py:779
msgid "Click here" msgid "Click here"
msgstr "Натисніть тут" msgstr "Натисніть тут"
#: build/lib/core/models.py:656 core/models.py:656 #: build/lib/core/models.py:674 core/models.py:674
msgid "Confirm" msgid "Confirm"
msgstr "Підтвердження" msgstr "Підтвердження"
#: build/lib/core/models.py:667 core/models.py:667 #: build/lib/core/models.py:685 core/models.py:685
msgid "Your reconciliation request has been processed.\n" msgid "Your reconciliation request has been processed.\n"
" New documents are likely associated with your account:" " New documents are likely associated with your account:"
msgstr "Ваш запит на узгодження оброблено.\n" msgstr "Ваш запит на узгодження оброблено.\n"
" Нові документи, ймовірно, пов'язані з вашим обліковим записом:" " Нові документи, ймовірно, пов'язані з вашим обліковим записом:"
#: build/lib/core/models.py:672 core/models.py:672 #: build/lib/core/models.py:690 core/models.py:690
msgid "Your accounts have been merged" msgid "Your accounts have been merged"
msgstr "Ваші облікові записи були об'єднані" msgstr "Ваші облікові записи були об'єднані"
#: build/lib/core/models.py:677 core/models.py:677 #: build/lib/core/models.py:695 core/models.py:695
msgid "Click here to see" msgid "Click here to see"
msgstr "Натисніть тут, щоб переглянути" msgstr "Натисніть тут, щоб переглянути"
#: build/lib/core/models.py:678 core/models.py:678 #: build/lib/core/models.py:696 core/models.py:696
msgid "See my documents" msgid "See my documents"
msgstr "Переглянути мої документи" msgstr "Переглянути мої документи"
#: build/lib/core/models.py:688 core/models.py:688 #: build/lib/core/models.py:706 core/models.py:706
msgid "CSV file" msgid "CSV file"
msgstr "CSV-файл" msgstr "CSV-файл"
#: build/lib/core/models.py:693 core/models.py:693 #: build/lib/core/models.py:711 core/models.py:711
msgid "Running" msgid "Running"
msgstr "Виконується" msgstr "Виконується"
#: build/lib/core/models.py:703 core/models.py:703 #: build/lib/core/models.py:721 core/models.py:721
msgid "user reconciliation CSV import" msgid "user reconciliation CSV import"
msgstr "імпорт CSV для узгодження користувачів" msgstr "імпорт CSV для узгодження користувачів"
#: build/lib/core/models.py:704 core/models.py:704 #: build/lib/core/models.py:722 core/models.py:722
msgid "user reconciliation CSV imports" msgid "user reconciliation CSV imports"
msgstr "імпорт CSV для узгодження користувачів" msgstr "імпорт CSV для узгодження користувачів"
#: build/lib/core/models.py:748 core/models.py:748 #: build/lib/core/models.py:766 core/models.py:766
#, python-brace-format #, python-brace-format
msgid "Your request for reconciliation was unsuccessful.\n" msgid "Your request for reconciliation was unsuccessful.\n"
" Reconciliation failed for the following email addresses:\n" " Reconciliation failed for the following email addresses:\n"
@@ -346,171 +354,171 @@ msgstr "Ваш запит на узгодження не був виконани
" Перевірте, чи немає помилок.\n" " Перевірте, чи немає помилок.\n"
" Ви можете надіслати інший запит із дійсними адресами електронної пошти." " Ви можете надіслати інший запит із дійсними адресами електронної пошти."
#: build/lib/core/models.py:756 core/models.py:756 #: build/lib/core/models.py:774 core/models.py:774
msgid "Reconciliation of your Docs accounts not completed" msgid "Reconciliation of your Docs accounts not completed"
msgstr "Узгодження ваших облікових записів не завершено" msgstr "Узгодження ваших облікових записів не завершено"
#: build/lib/core/models.py:762 core/models.py:762 #: build/lib/core/models.py:780 core/models.py:780
msgid "Make a new request" msgid "Make a new request"
msgstr "Зробити новий запит" msgstr "Зробити новий запит"
#: build/lib/core/models.py:861 core/models.py:861 #: build/lib/core/models.py:879 core/models.py:879
msgid "title" msgid "title"
msgstr "заголовок" msgstr "заголовок"
#: build/lib/core/models.py:862 core/models.py:862 #: build/lib/core/models.py:880 core/models.py:880
msgid "excerpt" msgid "excerpt"
msgstr "уривок" msgstr "уривок"
#: build/lib/core/models.py:911 core/models.py:911 #: build/lib/core/models.py:929 core/models.py:929
msgid "Document" msgid "Document"
msgstr "Документ" msgstr "Документ"
#: build/lib/core/models.py:912 core/models.py:912 #: build/lib/core/models.py:930 core/models.py:930
msgid "Documents" msgid "Documents"
msgstr "Документи" msgstr "Документи"
#: build/lib/core/models.py:924 build/lib/core/models.py:1328 #: build/lib/core/models.py:942 build/lib/core/models.py:1346
#: core/models.py:924 core/models.py:1328 #: core/models.py:942 core/models.py:1346
msgid "Untitled Document" msgid "Untitled Document"
msgstr "Документ без назви" msgstr "Документ без назви"
#: build/lib/core/models.py:1329 core/models.py:1329 #: build/lib/core/models.py:1347 core/models.py:1347
msgid "Open" msgid "Open"
msgstr "Відкрити" msgstr "Відкрити"
#: build/lib/core/models.py:1364 core/models.py:1364 #: build/lib/core/models.py:1382 core/models.py:1382
#, python-brace-format #, python-brace-format
msgid "{name} shared a document with you!" msgid "{name} shared a document with you!"
msgstr "{name} ділиться з вами документом!" msgstr "{name} ділиться з вами документом!"
#: build/lib/core/models.py:1368 core/models.py:1368 #: build/lib/core/models.py:1386 core/models.py:1386
#, python-brace-format #, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:" msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} запрошує вас для роботи з документом із роллю \"{role}\":" msgstr "{name} запрошує вас для роботи з документом із роллю \"{role}\":"
#: build/lib/core/models.py:1374 core/models.py:1374 #: build/lib/core/models.py:1392 core/models.py:1392
#, python-brace-format #, python-brace-format
msgid "{name} shared a document with you: {title}" msgid "{name} shared a document with you: {title}"
msgstr "{name} ділиться з вами документом: {title}" msgstr "{name} ділиться з вами документом: {title}"
#: build/lib/core/models.py:1475 core/models.py:1475 #: build/lib/core/models.py:1493 core/models.py:1493
msgid "Document/user link trace" msgid "Document/user link trace"
msgstr "Трасування посилання Документ/користувач" msgstr "Трасування посилання Документ/користувач"
#: build/lib/core/models.py:1476 core/models.py:1476 #: build/lib/core/models.py:1494 core/models.py:1494
msgid "Document/user link traces" msgid "Document/user link traces"
msgstr "Трасування посилань Документ/користувач" msgstr "Трасування посилань Документ/користувач"
#: build/lib/core/models.py:1482 core/models.py:1482 #: build/lib/core/models.py:1500 core/models.py:1500
msgid "A link trace already exists for this document/user." msgid "A link trace already exists for this document/user."
msgstr "Відстеження вже існуючих посилань для цього документа/користувача." msgstr "Відстеження вже існуючих посилань для цього документа/користувача."
#: build/lib/core/models.py:1505 core/models.py:1505 #: build/lib/core/models.py:1523 core/models.py:1523
msgid "Document favorite" msgid "Document favorite"
msgstr "Обраний документ" msgstr "Обраний документ"
#: build/lib/core/models.py:1506 core/models.py:1506 #: build/lib/core/models.py:1524 core/models.py:1524
msgid "Document favorites" msgid "Document favorites"
msgstr "Обрані документи" msgstr "Обрані документи"
#: build/lib/core/models.py:1512 core/models.py:1512 #: build/lib/core/models.py:1530 core/models.py:1530
msgid "This document is already targeted by a favorite relation instance for the same user." msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Цей документ вже вказаний як обраний для одного користувача." msgstr "Цей документ вже вказаний як обраний для одного користувача."
#: build/lib/core/models.py:1534 core/models.py:1534 #: build/lib/core/models.py:1552 core/models.py:1552
msgid "Document/user relation" msgid "Document/user relation"
msgstr "Відносини документ/користувач" msgstr "Відносини документ/користувач"
#: build/lib/core/models.py:1535 core/models.py:1535 #: build/lib/core/models.py:1553 core/models.py:1553
msgid "Document/user relations" msgid "Document/user relations"
msgstr "Відносини документ/користувач" msgstr "Відносини документ/користувач"
#: build/lib/core/models.py:1541 core/models.py:1541 #: build/lib/core/models.py:1559 core/models.py:1559
msgid "This user is already in this document." msgid "This user is already in this document."
msgstr "Цей користувач вже має доступ до цього документу." msgstr "Цей користувач вже має доступ до цього документу."
#: build/lib/core/models.py:1547 core/models.py:1547 #: build/lib/core/models.py:1565 core/models.py:1565
msgid "This team is already in this document." msgid "This team is already in this document."
msgstr "Ця команда вже має доступ до цього документа." msgstr "Ця команда вже має доступ до цього документа."
#: build/lib/core/models.py:1553 core/models.py:1553 #: build/lib/core/models.py:1571 core/models.py:1571
msgid "Either user or team must be set, not both." msgid "Either user or team must be set, not both."
msgstr "Вкажіть користувача або команду, а не обох." msgstr "Вкажіть користувача або команду, а не обох."
#: build/lib/core/models.py:1704 core/models.py:1704 #: build/lib/core/models.py:1722 core/models.py:1722
msgid "Document ask for access" msgid "Document ask for access"
msgstr "Запит доступу до документа" msgstr "Запит доступу до документа"
#: build/lib/core/models.py:1705 core/models.py:1705 #: build/lib/core/models.py:1723 core/models.py:1723
msgid "Document ask for accesses" msgid "Document ask for accesses"
msgstr "Запит доступу для документа" msgstr "Запит доступу для документа"
#: build/lib/core/models.py:1711 core/models.py:1711 #: build/lib/core/models.py:1729 core/models.py:1729
msgid "This user has already asked for access to this document." msgid "This user has already asked for access to this document."
msgstr "Цей користувач вже попросив доступ до цього документа." msgstr "Цей користувач вже попросив доступ до цього документа."
#: build/lib/core/models.py:1768 core/models.py:1768 #: build/lib/core/models.py:1786 core/models.py:1786
#, python-brace-format #, python-brace-format
msgid "{name} would like access to a document!" msgid "{name} would like access to a document!"
msgstr "{name} хоче отримати доступ до документа!" msgstr "{name} хоче отримати доступ до документа!"
#: build/lib/core/models.py:1772 core/models.py:1772 #: build/lib/core/models.py:1790 core/models.py:1790
#, python-brace-format #, python-brace-format
msgid "{name} would like access to the following document:" msgid "{name} would like access to the following document:"
msgstr "{name} бажає отримати доступ до наступного документа:" msgstr "{name} бажає отримати доступ до наступного документа:"
#: build/lib/core/models.py:1778 core/models.py:1778 #: build/lib/core/models.py:1796 core/models.py:1796
#, python-brace-format #, python-brace-format
msgid "{name} is asking for access to the document: {title}" msgid "{name} is asking for access to the document: {title}"
msgstr "{name} запитує доступ до документа: {title}" msgstr "{name} запитує доступ до документа: {title}"
#: build/lib/core/models.py:1820 core/models.py:1820 #: build/lib/core/models.py:1838 core/models.py:1838
msgid "Thread" msgid "Thread"
msgstr "Обговорення" msgstr "Обговорення"
#: build/lib/core/models.py:1821 core/models.py:1821 #: build/lib/core/models.py:1839 core/models.py:1839
msgid "Threads" msgid "Threads"
msgstr "Обговорення" msgstr "Обговорення"
#: build/lib/core/models.py:1824 build/lib/core/models.py:1876 #: build/lib/core/models.py:1842 build/lib/core/models.py:1894
#: core/models.py:1824 core/models.py:1876 #: core/models.py:1842 core/models.py:1894
msgid "Anonymous" msgid "Anonymous"
msgstr "Анонім" msgstr "Анонім"
#: build/lib/core/models.py:1871 core/models.py:1871 #: build/lib/core/models.py:1889 core/models.py:1889
msgid "Comment" msgid "Comment"
msgstr "Коментар" msgstr "Коментар"
#: build/lib/core/models.py:1872 core/models.py:1872 #: build/lib/core/models.py:1890 core/models.py:1890
msgid "Comments" msgid "Comments"
msgstr "Коментарі" msgstr "Коментарі"
#: build/lib/core/models.py:1921 core/models.py:1921 #: build/lib/core/models.py:1939 core/models.py:1939
msgid "This emoji has already been reacted to this comment." msgid "This emoji has already been reacted to this comment."
msgstr "Цим емодзі вже відреагували на цей коментар." msgstr "Цим емодзі вже відреагували на цей коментар."
#: build/lib/core/models.py:1925 core/models.py:1925 #: build/lib/core/models.py:1943 core/models.py:1943
msgid "Reaction" msgid "Reaction"
msgstr "Реакція" msgstr "Реакція"
#: build/lib/core/models.py:1926 core/models.py:1926 #: build/lib/core/models.py:1944 core/models.py:1944
msgid "Reactions" msgid "Reactions"
msgstr "Реакції" msgstr "Реакції"
#: build/lib/core/models.py:1936 core/models.py:1936 #: build/lib/core/models.py:1954 core/models.py:1954
msgid "email address" msgid "email address"
msgstr "електронна адреса" msgstr "електронна адреса"
#: build/lib/core/models.py:1955 core/models.py:1955 #: build/lib/core/models.py:1973 core/models.py:1973
msgid "Document invitation" msgid "Document invitation"
msgstr "Запрошення до редагування документа" msgstr "Запрошення до редагування документа"
#: build/lib/core/models.py:1956 core/models.py:1956 #: build/lib/core/models.py:1974 core/models.py:1974
msgid "Document invitations" msgid "Document invitations"
msgstr "Запрошення до редагування документів" msgstr "Запрошення до редагування документів"
#: build/lib/core/models.py:1976 core/models.py:1976 #: build/lib/core/models.py:1994 core/models.py:1994
msgid "This email is already associated to a registered user." msgid "This email is already associated to a registered user."
msgstr "Ця електронна пошта вже пов'язана з зареєстрованим користувачем." msgstr "Ця електронна пошта вже пов'язана з зареєстрованим користувачем."

View File

@@ -2,8 +2,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: lasuite-docs\n" "Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-06 09:37+0000\n" "POT-Creation-Date: 2026-03-12 13:31+0000\n"
"PO-Revision-Date: 2026-03-09 14:02\n" "PO-Revision-Date: 2026-03-13 16:53\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Chinese Simplified\n" "Language-Team: Chinese Simplified\n"
"Language: zh_CN\n" "Language: zh_CN\n"
@@ -58,24 +58,24 @@ msgstr "已隱藏"
msgid "Favorite" msgid "Favorite"
msgstr "我的最愛" msgstr "我的最愛"
#: build/lib/core/api/serializers.py:513 core/api/serializers.py:513 #: build/lib/core/api/serializers.py:526 core/api/serializers.py:526
msgid "A new document was created on your behalf!" msgid "A new document was created on your behalf!"
msgstr "已代表您建立新文件!" msgstr "已代表您建立新文件!"
#: build/lib/core/api/serializers.py:517 core/api/serializers.py:517 #: build/lib/core/api/serializers.py:530 core/api/serializers.py:530
msgid "You have been granted ownership of a new document:" msgid "You have been granted ownership of a new document:"
msgstr "您已獲得新文件的所有權:" msgstr "您已獲得新文件的所有權:"
#: build/lib/core/api/serializers.py:553 core/api/serializers.py:553 #: build/lib/core/api/serializers.py:566 core/api/serializers.py:566
msgid "This field is required." msgid "This field is required."
msgstr "此欄位為必填。" msgstr "此欄位為必填。"
#: build/lib/core/api/serializers.py:564 core/api/serializers.py:564 #: build/lib/core/api/serializers.py:577 core/api/serializers.py:577
#, python-format #, python-format
msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration." msgid "Link reach '%(link_reach)s' is not allowed based on parent document configuration."
msgstr "根據父文件設定,不允許連結範圍「%(link_reach)s」。" msgstr "根據父文件設定,不允許連結範圍「%(link_reach)s」。"
#: build/lib/core/api/viewsets.py:1279 core/api/viewsets.py:1279 #: build/lib/core/api/viewsets.py:1298 core/api/viewsets.py:1298
#, python-brace-format #, python-brace-format
msgid "copy of {title}" msgid "copy of {title}"
msgstr "{title} 的副本" msgstr "{title} 的副本"
@@ -231,106 +231,114 @@ msgstr "啟用"
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts." msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "此使用者是否應被視為處於啟用狀態。請取消勾選此項而非刪除帳號。" msgstr "此使用者是否應被視為處於啟用狀態。請取消勾選此項而非刪除帳號。"
#: build/lib/core/models.py:204 core/models.py:204 #: build/lib/core/models.py:197 core/models.py:197
msgid "first connection status"
msgstr ""
#: build/lib/core/models.py:199 core/models.py:199
msgid "Whether the user has completed the first connection process."
msgstr ""
#: build/lib/core/models.py:209 core/models.py:209
msgid "user" msgid "user"
msgstr "使用者" msgstr "使用者"
#: build/lib/core/models.py:205 core/models.py:205 #: build/lib/core/models.py:210 core/models.py:210
msgid "users" msgid "users"
msgstr "使用者" msgstr "使用者"
#: build/lib/core/models.py:360 core/models.py:360 #: build/lib/core/models.py:378 core/models.py:378
msgid "Active email address" msgid "Active email address"
msgstr "" msgstr ""
#: build/lib/core/models.py:361 core/models.py:361 #: build/lib/core/models.py:379 core/models.py:379
msgid "Email address to deactivate" msgid "Email address to deactivate"
msgstr "" msgstr ""
#: build/lib/core/models.py:388 core/models.py:388 #: build/lib/core/models.py:406 core/models.py:406
msgid "Unique ID in the source file" msgid "Unique ID in the source file"
msgstr "" msgstr ""
#: build/lib/core/models.py:394 build/lib/core/models.py:692 core/models.py:394 #: build/lib/core/models.py:412 build/lib/core/models.py:710 core/models.py:412
#: core/models.py:692 #: core/models.py:710
msgid "Pending" msgid "Pending"
msgstr "" msgstr ""
#: build/lib/core/models.py:395 core/models.py:395 #: build/lib/core/models.py:413 core/models.py:413
msgid "Ready" msgid "Ready"
msgstr "" msgstr ""
#: build/lib/core/models.py:396 build/lib/core/models.py:694 core/models.py:396 #: build/lib/core/models.py:414 build/lib/core/models.py:712 core/models.py:414
#: core/models.py:694 #: core/models.py:712
msgid "Done" msgid "Done"
msgstr "" msgstr ""
#: build/lib/core/models.py:397 build/lib/core/models.py:695 core/models.py:397 #: build/lib/core/models.py:415 build/lib/core/models.py:713 core/models.py:415
#: core/models.py:695 #: core/models.py:713
msgid "Error" msgid "Error"
msgstr "" msgstr ""
#: build/lib/core/models.py:405 core/models.py:405 #: build/lib/core/models.py:423 core/models.py:423
msgid "user reconciliation" msgid "user reconciliation"
msgstr "" msgstr ""
#: build/lib/core/models.py:406 core/models.py:406 #: build/lib/core/models.py:424 core/models.py:424
msgid "user reconciliations" msgid "user reconciliations"
msgstr "" msgstr ""
#: build/lib/core/models.py:644 core/models.py:644 #: build/lib/core/models.py:662 core/models.py:662
msgid "You have requested a reconciliation of your user accounts on Docs.\n" msgid "You have requested a reconciliation of your user accounts on Docs.\n"
" To confirm that you are the one who initiated the request\n" " To confirm that you are the one who initiated the request\n"
" and that this email belongs to you:" " and that this email belongs to you:"
msgstr "" msgstr ""
#: build/lib/core/models.py:650 core/models.py:650 #: build/lib/core/models.py:668 core/models.py:668
msgid "Confirm by clicking the link to start the reconciliation" msgid "Confirm by clicking the link to start the reconciliation"
msgstr "" msgstr ""
#: build/lib/core/models.py:655 build/lib/core/models.py:761 core/models.py:655 #: build/lib/core/models.py:673 build/lib/core/models.py:779 core/models.py:673
#: core/models.py:761 #: core/models.py:779
msgid "Click here" msgid "Click here"
msgstr "" msgstr ""
#: build/lib/core/models.py:656 core/models.py:656 #: build/lib/core/models.py:674 core/models.py:674
msgid "Confirm" msgid "Confirm"
msgstr "" msgstr ""
#: build/lib/core/models.py:667 core/models.py:667 #: build/lib/core/models.py:685 core/models.py:685
msgid "Your reconciliation request has been processed.\n" msgid "Your reconciliation request has been processed.\n"
" New documents are likely associated with your account:" " New documents are likely associated with your account:"
msgstr "" msgstr ""
#: build/lib/core/models.py:672 core/models.py:672 #: build/lib/core/models.py:690 core/models.py:690
msgid "Your accounts have been merged" msgid "Your accounts have been merged"
msgstr "" msgstr ""
#: build/lib/core/models.py:677 core/models.py:677 #: build/lib/core/models.py:695 core/models.py:695
msgid "Click here to see" msgid "Click here to see"
msgstr "" msgstr ""
#: build/lib/core/models.py:678 core/models.py:678 #: build/lib/core/models.py:696 core/models.py:696
msgid "See my documents" msgid "See my documents"
msgstr "" msgstr ""
#: build/lib/core/models.py:688 core/models.py:688 #: build/lib/core/models.py:706 core/models.py:706
msgid "CSV file" msgid "CSV file"
msgstr "" msgstr ""
#: build/lib/core/models.py:693 core/models.py:693 #: build/lib/core/models.py:711 core/models.py:711
msgid "Running" msgid "Running"
msgstr "" msgstr ""
#: build/lib/core/models.py:703 core/models.py:703 #: build/lib/core/models.py:721 core/models.py:721
msgid "user reconciliation CSV import" msgid "user reconciliation CSV import"
msgstr "" msgstr ""
#: build/lib/core/models.py:704 core/models.py:704 #: build/lib/core/models.py:722 core/models.py:722
msgid "user reconciliation CSV imports" msgid "user reconciliation CSV imports"
msgstr "" msgstr ""
#: build/lib/core/models.py:748 core/models.py:748 #: build/lib/core/models.py:766 core/models.py:766
#, python-brace-format #, python-brace-format
msgid "Your request for reconciliation was unsuccessful.\n" msgid "Your request for reconciliation was unsuccessful.\n"
" Reconciliation failed for the following email addresses:\n" " Reconciliation failed for the following email addresses:\n"
@@ -339,171 +347,171 @@ msgid "Your request for reconciliation was unsuccessful.\n"
" You can submit another request with the valid email addresses." " You can submit another request with the valid email addresses."
msgstr "" msgstr ""
#: build/lib/core/models.py:756 core/models.py:756 #: build/lib/core/models.py:774 core/models.py:774
msgid "Reconciliation of your Docs accounts not completed" msgid "Reconciliation of your Docs accounts not completed"
msgstr "" msgstr ""
#: build/lib/core/models.py:762 core/models.py:762 #: build/lib/core/models.py:780 core/models.py:780
msgid "Make a new request" msgid "Make a new request"
msgstr "" msgstr ""
#: build/lib/core/models.py:861 core/models.py:861 #: build/lib/core/models.py:879 core/models.py:879
msgid "title" msgid "title"
msgstr "標題" msgstr "標題"
#: build/lib/core/models.py:862 core/models.py:862 #: build/lib/core/models.py:880 core/models.py:880
msgid "excerpt" msgid "excerpt"
msgstr "摘要" msgstr "摘要"
#: build/lib/core/models.py:911 core/models.py:911 #: build/lib/core/models.py:929 core/models.py:929
msgid "Document" msgid "Document"
msgstr "文件" msgstr "文件"
#: build/lib/core/models.py:912 core/models.py:912 #: build/lib/core/models.py:930 core/models.py:930
msgid "Documents" msgid "Documents"
msgstr "文件" msgstr "文件"
#: build/lib/core/models.py:924 build/lib/core/models.py:1328 #: build/lib/core/models.py:942 build/lib/core/models.py:1346
#: core/models.py:924 core/models.py:1328 #: core/models.py:942 core/models.py:1346
msgid "Untitled Document" msgid "Untitled Document"
msgstr "未命名文件" msgstr "未命名文件"
#: build/lib/core/models.py:1329 core/models.py:1329 #: build/lib/core/models.py:1347 core/models.py:1347
msgid "Open" msgid "Open"
msgstr "開啟" msgstr "開啟"
#: build/lib/core/models.py:1364 core/models.py:1364 #: build/lib/core/models.py:1382 core/models.py:1382
#, python-brace-format #, python-brace-format
msgid "{name} shared a document with you!" msgid "{name} shared a document with you!"
msgstr "{name} 與您分享了一份文件!" msgstr "{name} 與您分享了一份文件!"
#: build/lib/core/models.py:1368 core/models.py:1368 #: build/lib/core/models.py:1386 core/models.py:1386
#, python-brace-format #, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:" msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} 邀請您以「{role}」角色參與以下文件:" msgstr "{name} 邀請您以「{role}」角色參與以下文件:"
#: build/lib/core/models.py:1374 core/models.py:1374 #: build/lib/core/models.py:1392 core/models.py:1392
#, python-brace-format #, python-brace-format
msgid "{name} shared a document with you: {title}" msgid "{name} shared a document with you: {title}"
msgstr "{name} 與您分享了一份文件:{title}" msgstr "{name} 與您分享了一份文件:{title}"
#: build/lib/core/models.py:1475 core/models.py:1475 #: build/lib/core/models.py:1493 core/models.py:1493
msgid "Document/user link trace" msgid "Document/user link trace"
msgstr "文件/使用者連結追蹤" msgstr "文件/使用者連結追蹤"
#: build/lib/core/models.py:1476 core/models.py:1476 #: build/lib/core/models.py:1494 core/models.py:1494
msgid "Document/user link traces" msgid "Document/user link traces"
msgstr "文件/使用者連結追蹤" msgstr "文件/使用者連結追蹤"
#: build/lib/core/models.py:1482 core/models.py:1482 #: build/lib/core/models.py:1500 core/models.py:1500
msgid "A link trace already exists for this document/user." msgid "A link trace already exists for this document/user."
msgstr "此文件/使用者已存在連結追蹤。" msgstr "此文件/使用者已存在連結追蹤。"
#: build/lib/core/models.py:1505 core/models.py:1505 #: build/lib/core/models.py:1523 core/models.py:1523
msgid "Document favorite" msgid "Document favorite"
msgstr "文件收藏" msgstr "文件收藏"
#: build/lib/core/models.py:1506 core/models.py:1506 #: build/lib/core/models.py:1524 core/models.py:1524
msgid "Document favorites" msgid "Document favorites"
msgstr "文件收藏" msgstr "文件收藏"
#: build/lib/core/models.py:1512 core/models.py:1512 #: build/lib/core/models.py:1530 core/models.py:1530
msgid "This document is already targeted by a favorite relation instance for the same user." msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "此使用者已將此文件加入收藏。" msgstr "此使用者已將此文件加入收藏。"
#: build/lib/core/models.py:1534 core/models.py:1534 #: build/lib/core/models.py:1552 core/models.py:1552
msgid "Document/user relation" msgid "Document/user relation"
msgstr "文件/使用者關聯" msgstr "文件/使用者關聯"
#: build/lib/core/models.py:1535 core/models.py:1535 #: build/lib/core/models.py:1553 core/models.py:1553
msgid "Document/user relations" msgid "Document/user relations"
msgstr "文件/使用者關聯" msgstr "文件/使用者關聯"
#: build/lib/core/models.py:1541 core/models.py:1541 #: build/lib/core/models.py:1559 core/models.py:1559
msgid "This user is already in this document." msgid "This user is already in this document."
msgstr "此使用者已在此文件中。" msgstr "此使用者已在此文件中。"
#: build/lib/core/models.py:1547 core/models.py:1547 #: build/lib/core/models.py:1565 core/models.py:1565
msgid "This team is already in this document." msgid "This team is already in this document."
msgstr "此團隊已在此文件中。" msgstr "此團隊已在此文件中。"
#: build/lib/core/models.py:1553 core/models.py:1553 #: build/lib/core/models.py:1571 core/models.py:1571
msgid "Either user or team must be set, not both." msgid "Either user or team must be set, not both."
msgstr "必須設定使用者或團隊其中之一,不能同時設定兩者。" msgstr "必須設定使用者或團隊其中之一,不能同時設定兩者。"
#: build/lib/core/models.py:1704 core/models.py:1704 #: build/lib/core/models.py:1722 core/models.py:1722
msgid "Document ask for access" msgid "Document ask for access"
msgstr "要求文件存取權" msgstr "要求文件存取權"
#: build/lib/core/models.py:1705 core/models.py:1705 #: build/lib/core/models.py:1723 core/models.py:1723
msgid "Document ask for accesses" msgid "Document ask for accesses"
msgstr "要求文件存取權" msgstr "要求文件存取權"
#: build/lib/core/models.py:1711 core/models.py:1711 #: build/lib/core/models.py:1729 core/models.py:1729
msgid "This user has already asked for access to this document." msgid "This user has already asked for access to this document."
msgstr "此使用者已要求過存取此文件的權限。" msgstr "此使用者已要求過存取此文件的權限。"
#: build/lib/core/models.py:1768 core/models.py:1768 #: build/lib/core/models.py:1786 core/models.py:1786
#, python-brace-format #, python-brace-format
msgid "{name} would like access to a document!" msgid "{name} would like access to a document!"
msgstr "{name} 想要存取文件!" msgstr "{name} 想要存取文件!"
#: build/lib/core/models.py:1772 core/models.py:1772 #: build/lib/core/models.py:1790 core/models.py:1790
#, python-brace-format #, python-brace-format
msgid "{name} would like access to the following document:" msgid "{name} would like access to the following document:"
msgstr "{name} 想要存取以下文件:" msgstr "{name} 想要存取以下文件:"
#: build/lib/core/models.py:1778 core/models.py:1778 #: build/lib/core/models.py:1796 core/models.py:1796
#, python-brace-format #, python-brace-format
msgid "{name} is asking for access to the document: {title}" msgid "{name} is asking for access to the document: {title}"
msgstr "{name} 正要求存取文件:{title}" msgstr "{name} 正要求存取文件:{title}"
#: build/lib/core/models.py:1820 core/models.py:1820 #: build/lib/core/models.py:1838 core/models.py:1838
msgid "Thread" msgid "Thread"
msgstr "對話串" msgstr "對話串"
#: build/lib/core/models.py:1821 core/models.py:1821 #: build/lib/core/models.py:1839 core/models.py:1839
msgid "Threads" msgid "Threads"
msgstr "對話串" msgstr "對話串"
#: build/lib/core/models.py:1824 build/lib/core/models.py:1876 #: build/lib/core/models.py:1842 build/lib/core/models.py:1894
#: core/models.py:1824 core/models.py:1876 #: core/models.py:1842 core/models.py:1894
msgid "Anonymous" msgid "Anonymous"
msgstr "匿名" msgstr "匿名"
#: build/lib/core/models.py:1871 core/models.py:1871 #: build/lib/core/models.py:1889 core/models.py:1889
msgid "Comment" msgid "Comment"
msgstr "評論" msgstr "評論"
#: build/lib/core/models.py:1872 core/models.py:1872 #: build/lib/core/models.py:1890 core/models.py:1890
msgid "Comments" msgid "Comments"
msgstr "評論" msgstr "評論"
#: build/lib/core/models.py:1921 core/models.py:1921 #: build/lib/core/models.py:1939 core/models.py:1939
msgid "This emoji has already been reacted to this comment." msgid "This emoji has already been reacted to this comment."
msgstr "此評論已標記過此表情符號。" msgstr "此評論已標記過此表情符號。"
#: build/lib/core/models.py:1925 core/models.py:1925 #: build/lib/core/models.py:1943 core/models.py:1943
msgid "Reaction" msgid "Reaction"
msgstr "回應" msgstr "回應"
#: build/lib/core/models.py:1926 core/models.py:1926 #: build/lib/core/models.py:1944 core/models.py:1944
msgid "Reactions" msgid "Reactions"
msgstr "回應" msgstr "回應"
#: build/lib/core/models.py:1936 core/models.py:1936 #: build/lib/core/models.py:1954 core/models.py:1954
msgid "email address" msgid "email address"
msgstr "電子郵件地址" msgstr "電子郵件地址"
#: build/lib/core/models.py:1955 core/models.py:1955 #: build/lib/core/models.py:1973 core/models.py:1973
msgid "Document invitation" msgid "Document invitation"
msgstr "文件邀請" msgstr "文件邀請"
#: build/lib/core/models.py:1956 core/models.py:1956 #: build/lib/core/models.py:1974 core/models.py:1974
msgid "Document invitations" msgid "Document invitations"
msgstr "文件邀請" msgstr "文件邀請"
#: build/lib/core/models.py:1976 core/models.py:1976 #: build/lib/core/models.py:1994 core/models.py:1994
msgid "This email is already associated to a registered user." msgid "This email is already associated to a registered user."
msgstr "此電子郵件地址已與已註冊使用者關聯。" msgstr "此電子郵件地址已與已註冊使用者關聯。"

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "impress" name = "impress"
version = "4.7.0" version = "4.8.3"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }] authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [ classifiers = [
"Development Status :: 5 - Production/Stable", "Development Status :: 5 - Production/Stable",
@@ -40,21 +40,22 @@ dependencies = [
"django-storages[s3]==1.14.6", "django-storages[s3]==1.14.6",
"django-timezone-field>=5.1", "django-timezone-field>=5.1",
"django<6.0.0", "django<6.0.0",
"django-treebeard==5.0.5", "django-treebeard<5.0.0",
"djangorestframework==3.16.1", "djangorestframework==3.16.1",
"django-waffle==5.0.0",
"drf_spectacular==0.29.0", "drf_spectacular==0.29.0",
"dockerflow==2026.1.26", "dockerflow==2026.1.26",
"easy_thumbnails==2.10.1", "easy_thumbnails==2.10.1",
"factory_boy==3.3.3", "factory_boy==3.3.3",
"gunicorn==25.1.0", "gunicorn==25.1.0",
"jsonschema==4.26.0", "jsonschema==4.26.0",
"langfuse==3.14.5", "langfuse==3.11.2",
"lxml==6.0.2", "lxml==6.0.2",
"markdown==3.10.2", "markdown==3.10.2",
"mozilla-django-oidc==5.0.2", "mozilla-django-oidc==5.0.2",
"nested-multipart-parser==1.6.0", "nested-multipart-parser==1.6.0",
"openai==2.24.0", "openai==2.24.0",
"psycopg[binary]==3.3.3", "psycopg[binary,pool]==3.3.3",
"pycrdt==0.12.47", "pycrdt==0.12.47",
"pydantic==2.12.5", "pydantic==2.12.5",
"pydantic-ai-slim[openai,logfire,web]==1.58.0", "pydantic-ai-slim[openai,logfire,web]==1.58.0",

View File

@@ -46,9 +46,9 @@ test.describe('Doc AI feature', () => {
await page.locator('.bn-block-outer').last().fill('Anything'); await page.locator('.bn-block-outer').last().fill('Anything');
await page.getByText('Anything').selectText(); await page.getByText('Anything').selectText();
expect( await expect(
await page.locator('button[data-test="convertMarkdown"]').count(), page.locator('button[data-test="convertMarkdown"]'),
).toBe(1); ).toHaveCount(1);
await expect( await expect(
page.getByRole('button', { name: config.selector, exact: true }), page.getByRole('button', { name: config.selector, exact: true }),
).toBeHidden(); ).toBeHidden();

View File

@@ -130,12 +130,13 @@ test.describe('Doc Comments', () => {
await thread.getByRole('paragraph').first().fill('This is a comment'); await thread.getByRole('paragraph').first().fill('This is a comment');
await thread.locator('[data-test="save"]').click(); await thread.locator('[data-test="save"]').click();
await expect(thread.getByText('This is a comment').first()).toBeHidden(); await expect(thread.getByText('This is a comment').first()).toBeHidden();
await expect(editor.getByText('Hello')).toHaveClass('bn-thread-mark');
// Check background color changed
await expect(editor.getByText('Hello')).toHaveCSS( await expect(editor.getByText('Hello')).toHaveCSS(
'background-color', 'background-color',
'rgba(237, 180, 0, 0.4)', 'color(srgb 0.882353 0.831373 0.717647 / 0.4)',
); );
await editor.first().click(); await editor.first().click();
await editor.getByText('Hello').click(); await editor.getByText('Hello').click();
@@ -184,6 +185,7 @@ test.describe('Doc Comments', () => {
await thread.getByText('This is an edited comment').first().hover(); await thread.getByText('This is an edited comment').first().hover();
await thread.locator('[data-test="resolve"]').click(); await thread.locator('[data-test="resolve"]').click();
await expect(thread).toBeHidden(); await expect(thread).toBeHidden();
await expect(editor.getByText('Hello')).toHaveCSS( await expect(editor.getByText('Hello')).toHaveCSS(
'background-color', 'background-color',
'rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, 0)',
@@ -195,11 +197,13 @@ test.describe('Doc Comments', () => {
await thread.getByRole('paragraph').first().fill('This is a new comment'); await thread.getByRole('paragraph').first().fill('This is a new comment');
await thread.locator('[data-test="save"]').click(); await thread.locator('[data-test="save"]').click();
await expect(editor.getByText('Hello')).toHaveClass('bn-thread-mark');
await expect(editor.getByText('Hello')).toHaveCSS( await expect(editor.getByText('Hello')).toHaveCSS(
'background-color', 'background-color',
'rgba(237, 180, 0, 0.4)', 'color(srgb 0.882353 0.831373 0.717647 / 0.4)',
); );
await editor.first().click(); await editor.first().click();
await editor.getByText('Hello').click(); await editor.getByText('Hello').click();
@@ -207,6 +211,7 @@ test.describe('Doc Comments', () => {
await thread.locator('[data-test="moreactions"]').first().click(); await thread.locator('[data-test="moreactions"]').first().click();
await thread.getByRole('menuitem', { name: 'Delete comment' }).click(); await thread.getByRole('menuitem', { name: 'Delete comment' }).click();
await expect(editor.getByText('Hello')).not.toHaveClass('bn-thread-mark');
await expect(editor.getByText('Hello')).toHaveCSS( await expect(editor.getByText('Hello')).toHaveCSS(
'background-color', 'background-color',
'rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, 0)',
@@ -262,7 +267,7 @@ test.describe('Doc Comments', () => {
await expect(otherEditor.getByText('Hello')).toHaveCSS( await expect(otherEditor.getByText('Hello')).toHaveCSS(
'background-color', 'background-color',
'rgba(237, 180, 0, 0.4)', 'color(srgb 0.882353 0.831373 0.717647 / 0.4)',
); );
// We change the role of the second user to reader // We change the role of the second user to reader
@@ -297,7 +302,7 @@ test.describe('Doc Comments', () => {
await expect(otherEditor.getByText('Hello')).toHaveCSS( await expect(otherEditor.getByText('Hello')).toHaveCSS(
'background-color', 'background-color',
'rgba(237, 180, 0, 0.4)', 'color(srgb 0.882353 0.831373 0.717647 / 0.4)',
); );
await otherEditor.getByText('Hello').click(); await otherEditor.getByText('Hello').click();
await expect( await expect(
@@ -343,7 +348,7 @@ test.describe('Doc Comments', () => {
await expect(editor1.getByText('Document One')).toHaveCSS( await expect(editor1.getByText('Document One')).toHaveCSS(
'background-color', 'background-color',
'rgba(237, 180, 0, 0.4)', 'color(srgb 0.882353 0.831373 0.717647 / 0.4)',
); );
await editor1.getByText('Document One').click(); await editor1.getByText('Document One').click();

View File

@@ -147,20 +147,18 @@ test.describe('Doc Editor', () => {
const wsClosePromise = webSocket.waitForEvent('close'); const wsClosePromise = webSocket.waitForEvent('close');
await selectVisibility.click(); await selectVisibility.click();
await page.getByRole('menuitem', { name: 'Connected' }).click(); await page.getByRole('menuitemradio', { name: 'Connected' }).click();
// Assert that the doc reconnects to the ws // Assert that the doc reconnects to the ws
const wsClose = await wsClosePromise; const wsClose = await wsClosePromise;
expect(wsClose.isClosed()).toBeTruthy(); expect(wsClose.isClosed()).toBeTruthy();
// Check the ws is connected again // Check the ws is connected again
webSocketPromise = page.waitForEvent('websocket', (webSocket) => { webSocket = await page.waitForEvent('websocket', (webSocket) => {
return webSocket return webSocket
.url() .url()
.includes('ws://localhost:4444/collaboration/ws/?room='); .includes('ws://localhost:4444/collaboration/ws/?room=');
}); });
webSocket = await webSocketPromise;
framesentPromise = webSocket.waitForEvent('framesent'); framesentPromise = webSocket.waitForEvent('framesent');
framesent = await framesentPromise; framesent = await framesentPromise;
expect(framesent.payload).not.toBeNull(); expect(framesent.payload).not.toBeNull();
@@ -577,12 +575,10 @@ test.describe('Doc Editor', () => {
await page.reload(); await page.reload();
responseCanEditPromise = page.waitForResponse( responseCanEdit = await page.waitForResponse(
(response) => (response) =>
response.url().includes(`/can-edit/`) && response.status() === 200, response.url().includes(`/can-edit/`) && response.status() === 200,
); );
responseCanEdit = await responseCanEditPromise;
expect(responseCanEdit.ok()).toBeTruthy(); expect(responseCanEdit.ok()).toBeTruthy();
jsonCanEdit = (await responseCanEdit.json()) as { can_edit: boolean }; jsonCanEdit = (await responseCanEdit.json()) as { can_edit: boolean };
@@ -608,7 +604,7 @@ test.describe('Doc Editor', () => {
await page.getByRole('button', { name: 'Share' }).click(); await page.getByRole('button', { name: 'Share' }).click();
await page.getByTestId('doc-access-mode').click(); await page.getByTestId('doc-access-mode').click();
await page.getByRole('menuitem', { name: 'Reading' }).click(); await page.getByRole('menuitemradio', { name: 'Reading' }).click();
// Close the modal // Close the modal
await page.getByRole('button', { name: 'close' }).first().click(); await page.getByRole('button', { name: 'close' }).first().click();

View File

@@ -341,7 +341,9 @@ test.describe('Doc grid move', () => {
`doc-share-access-request-row-${emailRequest}`, `doc-share-access-request-row-${emailRequest}`,
); );
await container.getByTestId('doc-role-dropdown').click(); await container.getByTestId('doc-role-dropdown').click();
await otherPage.getByRole('menuitem', { name: 'Administrator' }).click(); await otherPage
.getByRole('menuitemradio', { name: 'Administrator' })
.click();
await container.getByRole('button', { name: 'Approve' }).click(); await container.getByRole('button', { name: 'Approve' }).click();
await expect(otherPage.getByText('Access Requests')).toBeHidden(); await expect(otherPage.getByText('Access Requests')).toBeHidden();

View File

@@ -78,11 +78,7 @@ test.describe('Doc Header', () => {
await page.getByTestId('doc-visibility').click(); await page.getByTestId('doc-visibility').click();
await page await page.getByRole('menuitemradio', { name: 'Public' }).click();
.getByRole('menuitem', {
name: 'Public',
})
.click();
await page.getByRole('button', { name: 'close' }).first().click(); await page.getByRole('button', { name: 'close' }).first().click();
@@ -241,7 +237,7 @@ test.describe('Doc Header', () => {
hasText: randomDoc, hasText: randomDoc,
}); });
expect(await row.count()).toBe(0); await expect(row).toHaveCount(0);
}); });
test('it checks the options available if administrator', async ({ page }) => { test('it checks the options available if administrator', async ({ page }) => {
@@ -280,12 +276,12 @@ test.describe('Doc Header', () => {
).toBeDisabled(); ).toBeDisabled();
// Click somewhere else to close the options // Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } }); await page.locator('body').click({ position: { x: 0, y: 0 } });
await page.getByRole('button', { name: 'Share' }).click(); await page.getByRole('button', { name: 'Share' }).click();
const shareModal = page.getByRole('dialog', { const shareModal = page.getByRole('dialog', {
name: 'Share modal content', name: 'Share the document',
}); });
await expect(shareModal).toBeVisible(); await expect(shareModal).toBeVisible();
await expect(page.getByText('Share the document')).toBeVisible(); await expect(page.getByText('Share the document')).toBeVisible();
@@ -300,7 +296,7 @@ test.describe('Doc Header', () => {
await invitationRole.click(); await invitationRole.click();
await page.getByRole('menuitem', { name: 'Remove access' }).click(); await page.getByRole('menuitemradio', { name: 'Remove access' }).click();
await expect(invitationCard).toBeHidden(); await expect(invitationCard).toBeHidden();
const memberCard = shareModal.getByLabel('List members card'); const memberCard = shareModal.getByLabel('List members card');
@@ -313,7 +309,7 @@ test.describe('Doc Header', () => {
await roles.click(); await roles.click();
await expect( await expect(
page.getByRole('menuitem', { name: 'Remove access' }), page.getByRole('menuitemradio', { name: 'Remove access' }),
).toBeEnabled(); ).toBeEnabled();
}); });
@@ -359,12 +355,12 @@ test.describe('Doc Header', () => {
).toBeDisabled(); ).toBeDisabled();
// Click somewhere else to close the options // Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } }); await page.locator('body').click({ position: { x: 0, y: 0 } });
await page.getByRole('button', { name: 'Share' }).click(); await page.getByRole('button', { name: 'Share' }).click();
const shareModal = page.getByRole('dialog', { const shareModal = page.getByRole('dialog', {
name: 'Share modal content', name: 'Share the document',
}); });
await expect(shareModal).toBeVisible(); await expect(shareModal).toBeVisible();
await expect(page.getByText('Share the document')).toBeVisible(); await expect(page.getByText('Share the document')).toBeVisible();
@@ -431,11 +427,13 @@ test.describe('Doc Header', () => {
).toBeDisabled(); ).toBeDisabled();
// Click somewhere else to close the options // Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } }); await page.locator('body').click({ position: { x: 0, y: 0 } });
await page.getByRole('button', { name: 'Share' }).click(); await page.getByRole('button', { name: 'Share' }).click();
const shareModal = page.getByLabel('Share modal'); const shareModal = page.getByRole('dialog', {
name: 'Share the document',
});
await expect(page.getByText('Share the document')).toBeVisible(); await expect(page.getByText('Share the document')).toBeVisible();
await expect(page.getByPlaceholder('Type a name or email')).toBeHidden(); await expect(page.getByPlaceholder('Type a name or email')).toBeHidden();
@@ -485,7 +483,9 @@ test.describe('Doc Header', () => {
// Copy content to clipboard // Copy content to clipboard
await page.getByLabel('Open the document options').click(); await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Copy as Markdown' }).click(); await page.getByRole('menuitem', { name: 'Copy as Markdown' }).click();
await expect(page.getByText('Copied to clipboard')).toBeVisible(); await expect(
page.getByText('Copied as Markdown to clipboard'),
).toBeVisible();
// Test that clipboard is in Markdown format // Test that clipboard is in Markdown format
const handle = await page.evaluateHandle(() => const handle = await page.evaluateHandle(() =>
@@ -705,10 +705,12 @@ test.describe('Documents Header mobile', () => {
await page.getByRole('menuitem', { name: 'Share' }).click(); await page.getByRole('menuitem', { name: 'Share' }).click();
const shareModal = page.getByRole('dialog', { const shareModal = page.getByRole('dialog', {
name: 'Share modal content', name: 'Share the document',
}); });
await expect(shareModal).toBeVisible(); await expect(shareModal).toBeVisible();
await page.getByRole('button', { name: 'close' }).click(); await page.getByRole('button', { name: 'close' }).click();
await expect(page.getByLabel('Share modal')).toBeHidden(); await expect(
page.getByRole('dialog', { name: 'Share the document' }),
).toBeHidden();
}); });
}); });

View File

@@ -177,5 +177,5 @@ const dragAndDropFiles = async (
return dt; return dt;
}, filesData); }, filesData);
await page.dispatchEvent(selector, 'drop', { dataTransfer }); await page.locator(selector).dispatchEvent('drop', { dataTransfer });
}; };

View File

@@ -53,7 +53,7 @@ test.describe('Inherited share accesses', () => {
await expect(docVisibilityCard.getByText('Reading')).toBeVisible(); await expect(docVisibilityCard.getByText('Reading')).toBeVisible();
await docVisibilityCard.getByText('Reading').click(); await docVisibilityCard.getByText('Reading').click();
await page.getByRole('menuitem', { name: 'Editing' }).click(); await page.getByRole('menuitemradio', { name: 'Editing' }).click();
await expect(docVisibilityCard.getByText('Reading')).toBeHidden(); await expect(docVisibilityCard.getByText('Reading')).toBeHidden();
await expect(docVisibilityCard.getByText('Editing')).toBeVisible(); await expect(docVisibilityCard.getByText('Editing')).toBeVisible();
@@ -61,11 +61,11 @@ test.describe('Inherited share accesses', () => {
// Verify inherited link // Verify inherited link
await docVisibilityCard.getByText('Connected').click(); await docVisibilityCard.getByText('Connected').click();
await expect( await expect(
page.getByRole('menuitem', { name: 'Private' }), page.getByRole('menuitemradio', { name: 'Private' }),
).toBeDisabled(); ).toBeDisabled();
// Update child link // Update child link
await page.getByRole('menuitem', { name: 'Public' }).click(); await page.getByRole('menuitemradio', { name: 'Public' }).click();
await expect(docVisibilityCard.getByText('Connected')).toBeHidden(); await expect(docVisibilityCard.getByText('Connected')).toBeHidden();
await expect( await expect(

View File

@@ -16,6 +16,41 @@ test.describe('Document create member', () => {
await page.goto('/'); await page.goto('/');
}); });
test('it checks search hints', async ({ page, browserName }) => {
await createDoc(page, 'select-multi-users', browserName, 1);
await page.getByRole('button', { name: 'Share' }).click();
const shareModal = page.getByLabel('Share the document');
await expect(shareModal.getByText('Document owner')).toBeVisible();
const inputSearch = page.getByTestId('quick-search-input');
await inputSearch.fill('u');
await expect(shareModal.getByText('Document owner')).toBeHidden();
await expect(
shareModal.getByText('Type at least 3 characters to display user names'),
).toBeVisible();
await inputSearch.fill('user');
await expect(
shareModal.getByText('Type at least 3 characters to display user names'),
).toBeHidden();
await expect(shareModal.getByText('Choose a user')).toBeVisible();
await inputSearch.fill('anything');
await expect(shareModal.getByText('Choose a user')).toBeHidden();
await expect(
shareModal.getByText(
'No results. Type a full email address to invite someone.',
),
).toBeVisible();
await inputSearch.fill('anything@test.com');
await expect(
shareModal.getByText(
'No results. Type a full email address to invite someone.',
),
).toBeHidden();
await expect(shareModal.getByText('Choose the email')).toBeVisible();
});
test('it selects 2 users and 1 invitation', async ({ page, browserName }) => { test('it selects 2 users and 1 invitation', async ({ page, browserName }) => {
const inputFill = 'user.test'; const inputFill = 'user.test';
const responsePromise = page.waitForResponse( const responsePromise = page.waitForResponse(
@@ -75,15 +110,21 @@ test.describe('Document create member', () => {
// Check roles are displayed // Check roles are displayed
await list.getByTestId('doc-role-dropdown').click(); await list.getByTestId('doc-role-dropdown').click();
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( await expect(
page.getByRole('menuitem', { name: 'Administrator' }), page.getByRole('menuitemradio', { name: 'Reader' }),
).toBeVisible();
await expect(
page.getByRole('menuitemradio', { name: 'Editor' }),
).toBeVisible();
await expect(
page.getByRole('menuitemradio', { name: 'Owner' }),
).toBeVisible();
await expect(
page.getByRole('menuitemradio', { name: 'Administrator' }),
).toBeVisible(); ).toBeVisible();
// Validate // Validate
await page.getByRole('menuitem', { name: 'Administrator' }).click(); await page.getByRole('menuitemradio', { name: 'Administrator' }).click();
await page.getByTestId('doc-share-invite-button').click(); await page.getByTestId('doc-share-invite-button').click();
// Check invitation added // Check invitation added
@@ -129,7 +170,7 @@ test.describe('Document create member', () => {
// Choose a role // Choose a role
const container = page.getByTestId('doc-share-add-member-list'); const container = page.getByTestId('doc-share-add-member-list');
await container.getByTestId('doc-role-dropdown').click(); await container.getByTestId('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Owner' }).click(); await page.getByRole('menuitemradio', { name: 'Owner' }).click();
const responsePromiseCreateInvitation = page.waitForResponse( const responsePromiseCreateInvitation = page.waitForResponse(
(response) => (response) =>
@@ -147,7 +188,7 @@ test.describe('Document create member', () => {
// Choose a role // Choose a role
await container.getByTestId('doc-role-dropdown').click(); await container.getByTestId('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Owner' }).click(); await page.getByRole('menuitemradio', { name: 'Owner' }).click();
const responsePromiseCreateInvitationFail = page.waitForResponse( const responsePromiseCreateInvitationFail = page.waitForResponse(
(response) => (response) =>
@@ -184,7 +225,7 @@ test.describe('Document create member', () => {
// Choose a role // Choose a role
const container = page.getByTestId('doc-share-add-member-list'); const container = page.getByTestId('doc-share-add-member-list');
await container.getByTestId('doc-role-dropdown').click(); await container.getByTestId('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Administrator' }).click(); await page.getByRole('menuitemradio', { name: 'Administrator' }).click();
const responsePromiseCreateInvitation = page.waitForResponse( const responsePromiseCreateInvitation = page.waitForResponse(
(response) => (response) =>
@@ -211,13 +252,13 @@ test.describe('Document create member', () => {
); );
await userInvitation.getByTestId('doc-role-dropdown').click(); await userInvitation.getByTestId('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Reader' }).click(); await page.getByRole('menuitemradio', { name: 'Reader' }).click();
const responsePatchInvitation = await responsePromisePatchInvitation; const responsePatchInvitation = await responsePromisePatchInvitation;
expect(responsePatchInvitation.ok()).toBeTruthy(); expect(responsePatchInvitation.ok()).toBeTruthy();
await userInvitation.getByTestId('doc-role-dropdown').click(); await userInvitation.getByTestId('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Remove access' }).click(); await page.getByRole('menuitemradio', { name: 'Remove access' }).click();
await expect(userInvitation).toBeHidden(); await expect(userInvitation).toBeHidden();
}); });
@@ -269,7 +310,7 @@ test.describe('Document create member', () => {
`doc-share-access-request-row-${emailRequest}`, `doc-share-access-request-row-${emailRequest}`,
); );
await container.getByTestId('doc-role-dropdown').click(); await container.getByTestId('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Administrator' }).click(); await page.getByRole('menuitemradio', { name: 'Administrator' }).click();
await container.getByRole('button', { name: 'Approve' }).click(); await container.getByRole('button', { name: 'Approve' }).click();
await expect(page.getByText('Access Requests')).toBeHidden(); await expect(page.getByText('Access Requests')).toBeHidden();

View File

@@ -161,7 +161,7 @@ test.describe('Document list members', () => {
); );
await expect(soloOwner).toBeVisible(); await expect(soloOwner).toBeVisible();
await expect( await expect(
page.getByRole('menuitem', { name: 'Administrator' }), page.getByRole('menuitemradio', { name: 'Administrator' }),
).toBeDisabled(); ).toBeDisabled();
await list.click({ await list.click({
@@ -185,18 +185,20 @@ test.describe('Document list members', () => {
}); });
await currentUserRole.click(); await currentUserRole.click();
await page.getByRole('menuitem', { name: 'Administrator' }).click(); await page.getByRole('menuitemradio', { name: 'Administrator' }).click();
await list.click(); await list.click();
await expect(currentUserRole).toBeVisible(); await expect(currentUserRole).toBeVisible();
await newUserRoles.click(); await newUserRoles.click();
await expect(page.getByRole('menuitem', { name: 'Owner' })).toBeDisabled(); await expect(
page.getByRole('menuitemradio', { name: 'Owner' }),
).toBeDisabled();
await list.click({ await list.click({
force: true, // Force click to close the dropdown force: true, // Force click to close the dropdown
}); });
await currentUserRole.click(); await currentUserRole.click();
await page.getByRole('menuitem', { name: 'Reader' }).click(); await page.getByRole('menuitemradio', { name: 'Reader' }).click();
await list.click({ await list.click({
force: true, // Force click to close the dropdown force: true, // Force click to close the dropdown
}); });
@@ -236,11 +238,11 @@ test.describe('Document list members', () => {
await expect(userReader).toBeVisible(); await expect(userReader).toBeVisible();
await userReaderRole.click(); await userReaderRole.click();
await page.getByRole('menuitem', { name: 'Remove access' }).click(); await page.getByRole('menuitemradio', { name: 'Remove access' }).click();
await expect(userReader).toBeHidden(); await expect(userReader).toBeHidden();
await mySelfRole.click(); await mySelfRole.click();
await page.getByRole('menuitem', { name: 'Remove access' }).click(); await page.getByRole('menuitemradio', { name: 'Remove access' }).click();
await expect( await expect(
page.getByText('Insufficient access rights to view the document.'), page.getByText('Insufficient access rights to view the document.'),
).toBeVisible(); ).toBeVisible();

View File

@@ -29,7 +29,7 @@ test.describe('Document search', () => {
await page.getByTestId('search-docs-button').click(); await page.getByTestId('search-docs-button').click();
await expect( await expect(
page.getByRole('img', { name: 'No active search' }), page.getByLabel('Search modal').locator('img[alt=""]'),
).toBeVisible(); ).toBeVisible();
await expect( await expect(
@@ -107,7 +107,7 @@ test.describe('Document search', () => {
await searchButton.click(); await searchButton.click();
await expect( await expect(
page.getByRole('combobox', { name: 'Quick search input' }), page.getByRole('combobox', { name: 'Search documents' }),
).toBeVisible(); ).toBeVisible();
await expect(filters).toBeHidden(); await expect(filters).toBeHidden();
@@ -120,7 +120,7 @@ test.describe('Document search', () => {
await searchButton.click(); await searchButton.click();
await expect( await expect(
page.getByRole('combobox', { name: 'Quick search input' }), page.getByRole('combobox', { name: 'Search documents' }),
).toBeVisible(); ).toBeVisible();
await expect(filters).toBeHidden(); await expect(filters).toBeHidden();
@@ -137,12 +137,12 @@ test.describe('Document search', () => {
await filters.click(); await filters.click();
await filters.getByRole('button', { name: 'Current doc' }).click(); await filters.getByRole('button', { name: 'Current doc' }).click();
await expect( await expect(
page.getByRole('menuitem', { name: 'All docs' }), page.getByRole('menuitemcheckbox', { name: 'All docs' }),
).toBeVisible(); ).toBeVisible();
await expect( await expect(
page.getByRole('menuitem', { name: 'Current doc' }), page.getByRole('menuitemcheckbox', { name: 'Current doc' }),
).toBeVisible(); ).toBeVisible();
await page.getByRole('menuitem', { name: 'All docs' }).click(); await page.getByRole('menuitemcheckbox', { name: 'All docs' }).click();
await expect(page.getByRole('button', { name: 'Reset' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Reset' })).toBeVisible();
}); });
@@ -168,9 +168,9 @@ test.describe('Document search', () => {
const searchButton = page.getByTestId('search-docs-button'); const searchButton = page.getByTestId('search-docs-button');
await searchButton.click(); await searchButton.click();
await page.getByRole('combobox', { name: 'Quick search input' }).click(); await page.getByRole('combobox', { name: 'Search documents' }).click();
await page await page
.getByRole('combobox', { name: 'Quick search input' }) .getByRole('combobox', { name: 'Search documents' })
.fill('sub page search'); .fill('sub page search');
// Expect to find the first and second docs in the results list // Expect to find the first and second docs in the results list
@@ -192,7 +192,7 @@ test.describe('Document search', () => {
); );
await searchButton.click(); await searchButton.click();
await page await page
.getByRole('combobox', { name: 'Quick search input' }) .getByRole('combobox', { name: 'Search documents' })
.fill('second'); .fill('second');
// Now there is a sub page - expect to have the focus on the current doc // Now there is a sub page - expect to have the focus on the current doc

View File

@@ -19,7 +19,9 @@ test.describe('Doc Table Content', () => {
await page.locator('.ProseMirror').click(); await page.locator('.ProseMirror').click();
await expect(page.getByRole('button', { name: 'Summary' })).toBeHidden(); await expect(
page.getByRole('button', { name: 'Show the table of contents' }),
).toBeHidden();
await page.keyboard.type('# Level 1\n## Level 2\n### Level 3'); await page.keyboard.type('# Level 1\n## Level 2\n### Level 3');

View File

@@ -42,15 +42,12 @@ test.describe('Doc Tree', () => {
await expect(secondSubPageItem).toBeVisible(); await expect(secondSubPageItem).toBeVisible();
// Check the position of the sub pages // Check the position of the sub pages
const allSubPageItems = await docTree const allSubPageItems = docTree.getByTestId(/^doc-sub-page-item/);
.getByTestId(/^doc-sub-page-item/) await expect(allSubPageItems).toHaveCount(2);
.all();
expect(allSubPageItems.length).toBe(2);
// Check that elements are in the correct order // Check that elements are in the correct order
await expect(allSubPageItems[0].getByText('first move')).toBeVisible(); await expect(allSubPageItems.nth(0).getByText('first move')).toBeVisible();
await expect(allSubPageItems[1].getByText('second move')).toBeVisible(); await expect(allSubPageItems.nth(1).getByText('second move')).toBeVisible();
// Will move the first sub page to the second position // Will move the first sub page to the second position
const firstSubPageBoundingBox = await firstSubPageItem.boundingBox(); const firstSubPageBoundingBox = await firstSubPageItem.boundingBox();
@@ -90,17 +87,15 @@ test.describe('Doc Tree', () => {
await expect(secondSubPageItem).toBeVisible(); await expect(secondSubPageItem).toBeVisible();
// Check that elements are in the correct order // Check that elements are in the correct order
const allSubPageItemsAfterReload = await docTree const allSubPageItemsAfterReload =
.getByTestId(/^doc-sub-page-item/) docTree.getByTestId(/^doc-sub-page-item/);
.all(); await expect(allSubPageItemsAfterReload).toHaveCount(2);
expect(allSubPageItemsAfterReload.length).toBe(2);
await expect( await expect(
allSubPageItemsAfterReload[0].getByText('second move'), allSubPageItemsAfterReload.nth(0).getByText('second move'),
).toBeVisible(); ).toBeVisible();
await expect( await expect(
allSubPageItemsAfterReload[1].getByText('first move'), allSubPageItemsAfterReload.nth(1).getByText('first move'),
).toBeVisible(); ).toBeVisible();
}); });
@@ -162,7 +157,7 @@ test.describe('Doc Tree', () => {
); );
const currentUserRole = currentUser.getByTestId('doc-role-dropdown'); const currentUserRole = currentUser.getByTestId('doc-role-dropdown');
await currentUserRole.click(); await currentUserRole.click();
await page.getByRole('menuitem', { name: 'Administrator' }).click(); await page.getByRole('menuitemradio', { name: 'Administrator' }).click();
await list.click(); await list.click();
await page.getByRole('button', { name: 'Ok' }).click(); await page.getByRole('button', { name: 'Ok' }).click();
@@ -271,6 +266,49 @@ test.describe('Doc Tree', () => {
await expect(rootMoreOptionsButton).toBeFocused(); await expect(rootMoreOptionsButton).toBeFocused();
}); });
test('Shift+Tab from resize handle returns focus to selected sub-doc', async ({
page,
browserName,
}) => {
const [docParent] = await createDoc(
page,
'doc-tree-shift-tab',
browserName,
1,
);
await verifyDocName(page, docParent);
const { name: docChild } = await createRootSubPage(
page,
browserName,
'doc-tree-shift-tab-child',
);
const selectedSubDoc = await getTreeRow(page, docChild);
await expect(selectedSubDoc).toHaveAttribute('aria-selected', 'true');
await selectedSubDoc.focus();
await expect(selectedSubDoc).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByLabel('Open help menu')).toBeFocused();
await page.keyboard.press('Tab');
await expect(
page.locator('[data-panel-resize-handle-id]').first(),
).toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(page.getByLabel('Open help menu')).toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(selectedSubDoc).toBeFocused();
});
test('it updates the child icon from the tree', async ({ test('it updates the child icon from the tree', async ({
page, page,
browserName, browserName,
@@ -347,11 +385,7 @@ test.describe('Doc Tree: Inheritance', () => {
const selectVisibility = page.getByTestId('doc-visibility'); const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click(); await selectVisibility.click();
await page await page.getByRole('menuitemradio', { name: 'Public' }).click();
.getByRole('menuitem', {
name: 'Public',
})
.click();
await expect( await expect(
page.getByText('The document visibility has been updated.'), page.getByText('The document visibility has been updated.'),

View File

@@ -23,8 +23,8 @@ test.describe('Doc Version', () => {
await page.getByRole('menuitem', { name: 'Version history' }).click(); await page.getByRole('menuitem', { name: 'Version history' }).click();
await expect(page.getByText('History', { exact: true })).toBeVisible(); await expect(page.getByText('History', { exact: true })).toBeVisible();
const modal = page.getByLabel('version history modal'); const modal = page.getByRole('dialog', { name: 'Version history' });
const panel = modal.getByLabel('version list'); const panel = modal.getByLabel('Version list');
await expect(panel).toBeVisible(); await expect(panel).toBeVisible();
await expect(modal.getByText('No versions')).toBeVisible(); await expect(modal.getByText('No versions')).toBeVisible();
@@ -79,9 +79,9 @@ test.describe('Doc Version', () => {
await expect(panel).toBeVisible(); await expect(panel).toBeVisible();
await expect(page.getByText('History', { exact: true })).toBeVisible(); await expect(page.getByText('History', { exact: true })).toBeVisible();
await expect(page.getByRole('status')).toBeHidden(); await expect(page.getByRole('status')).toBeHidden();
const items = await panel.locator('.version-item').all(); const items = panel.locator('.version-item');
expect(items.length).toBe(2); await expect(items).toHaveCount(2);
await items[1].click(); await items.nth(1).click();
await expect(modal.getByText('Hello World')).toBeVisible(); await expect(modal.getByText('Hello World')).toBeVisible();
await expect(modal.getByText('It will create a version')).toBeHidden(); await expect(modal.getByText('It will create a version')).toBeHidden();
@@ -89,7 +89,7 @@ test.describe('Doc Version', () => {
modal.locator('div[data-content-type="callout"]').first(), modal.locator('div[data-content-type="callout"]').first(),
).toBeHidden(); ).toBeHidden();
await items[0].click(); await items.nth(0).click();
await expect(modal.getByText('Hello World')).toBeVisible(); await expect(modal.getByText('Hello World')).toBeVisible();
await expect(modal.getByText('It will create a version')).toBeVisible(); await expect(modal.getByText('It will create a version')).toBeVisible();
@@ -100,7 +100,7 @@ test.describe('Doc Version', () => {
modal.getByText('It will create a second version'), modal.getByText('It will create a second version'),
).toBeHidden(); ).toBeHidden();
await items[1].click(); await items.nth(1).click();
await expect(modal.getByText('Hello World')).toBeVisible(); await expect(modal.getByText('Hello World')).toBeVisible();
await expect(modal.getByText('It will create a version')).toBeHidden(); await expect(modal.getByText('It will create a version')).toBeHidden();
@@ -155,21 +155,26 @@ test.describe('Doc Version', () => {
await page.getByLabel('Open the document options').click(); await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Version history' }).click(); await page.getByRole('menuitem', { name: 'Version history' }).click();
const modal = page.getByLabel('version history modal'); const modal = page.getByRole('dialog', { name: 'Version history' });
const panel = modal.getByLabel('version list'); const panel = modal.getByLabel('Version list');
await expect(panel).toBeVisible(); await expect(panel).toBeVisible();
await expect(page.getByText('History', { exact: true })).toBeVisible(); await expect(page.getByText('History', { exact: true })).toBeVisible();
await panel.getByRole('button', { name: 'version item' }).click(); await panel.locator('.version-item').first().click();
await expect(modal.getByText('World')).toBeHidden(); await expect(modal.getByText('World')).toBeHidden();
await page.getByRole('button', { name: 'Restore' }).click(); await page.getByRole('button', { name: 'Restore', exact: true }).click();
await expect(page.getByText('Your current document will')).toBeVisible(); await expect(
await page.getByText('If a member is editing, his').click(); page.getByText(
"The current document will be replaced, but you'll still find it in the version history.",
),
).toBeVisible();
await page.getByLabel('Restore', { exact: true }).click(); await page.getByLabel('Restore', { exact: true }).click();
await page.waitForTimeout(500);
await expect(page.getByText('Hello')).toBeVisible(); await expect(page.getByText('Hello')).toBeVisible();
await expect(page.getByText('World')).toBeHidden(); await expect(page.getByText('World')).toBeHidden();
}); });

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