Compare commits

...

129 Commits

Author SHA1 Message Date
Cyril 012b06f3b1 (e2e) adds an end to end test for heading accessibility in doc editor
adds an end to end test to verify progressive heading suggestions

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-07-31 17:49:23 +02:00
Cyril 0c8bf4013a ️(frontend) fix heading insertion via useHeadingAccessibilityFilter
enforces contiguous heading levels to ensure accessibility compliance

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-07-31 17:47:34 +02:00
Anthony LC f5f9d8a877 (frontend) interlinking export
Create interlinking link mapping for docx and pdf export.
2025-07-31 13:26:09 +02:00
Anthony LC e7709badbb (frontend) create editor shortcuts hook
We created the editor shortcuts hook to handle
the shortcuts for the editor.
We implemented the following shortcuts:
- "@" to open the interlinking inline content
2025-07-31 13:26:09 +02:00
Anthony LC 2a7c0ef800 (frontend) create page from dropdown search
We are now able to create a new page from
the dropdown search.
2025-07-31 13:26:09 +02:00
Anthony LC 155e7dfe22 (frontend) interlinking custom inline content
We want to be able to interlink documents in the editor.
We created a custom inline content that allows
users to interlink documents.
2025-07-31 13:00:11 +02:00
Anthony LC afa48b6675 (frontend) create page from slash menu
We are now able to create a new page from
the slash menu.
2025-07-31 12:57:25 +02:00
Anthony LC f12d30cffa 🚚(frontend) reduce features coupling
Move some components and assets to `doc-management`
to reduce coupling between features:
- SimpleDocItem from `doc-grid` to `doc-management`
- useCreateChildDoc from `doc-tree` to `doc-management`
- isOwnerOrAdmin from `doc-tree` to `doc-management`
2025-07-30 15:11:37 +02:00
Anthony LC 30dfea744a 🐛(frontend) include root parent in search
When searching for documents, the root parent
document is now included in the search
results if it matches the search query.
2025-07-30 14:56:30 +02:00
Anthony LC 2cbe363a5f 🛂(frontend) block drag n drop when not desktop
Scrolling on mobile devices was causing issues
with drag and drop functionality, documents were
being moved unintentionally.
This commit disables drag and drop on mobile devices
to prevent this issue.
2025-07-30 14:06:39 +02:00
Anthony LC 7f450e8aa8 ⬆️(frontend) Bump linkifyjs from 4.3.1 to 4.3.2
Bumps linkifyjs from 4.3.1 to 4.3.2.

---
updated-dependencies:
- dependency-name: linkifyjs
  dependency-version: 4.3.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-30 13:21:02 +02:00
Cyril 7021c0f849 (changelog) add accessibility note to CHANGELOG (#1232)
updating Changelog.md with accessibility improvement
2025-07-28 18:07:44 +02:00
Cyril e8d18d85e9 ️(frontend) improve contrast for links
Updated anchor link color from greyscale-500 to greyscale-600
2025-07-28 17:55:02 +02:00
AlexB 67a195f89c (helm) add serviceAccountName parameter for services
Add support for specifying custom service accounts
in all Kubernetes resources in our Helm charts
to enable workload identity federation with managed
cloud services (PostgreSQL, Redis, etc.).
This allows deployments to authenticate to cloud
resources without embedding credentials in secrets.
2025-07-28 09:18:12 +02:00
renovate[bot] 09b6fef63f ⬆️(dependencies) update js dependencies 2025-07-25 13:59:03 +00:00
Anthony LC 11d0bafc94 (frontend) add multi columns support for editor
We add multi columns support for editor,
now you can add columns to your document.
Works with export.
📄AGPL feature.
2025-07-25 15:27:01 +02:00
Anthony LC 1ae831cabd ♻️(frontend) search on all docs if no children
When searching for documents, if no children are
found, the search will now include all documents
instead of just those with children.
2025-07-25 14:30:18 +02:00
Manuel Raynaud f1c2219270 🔧(chore) replace old repo url to suitenumerique org
The old repo url on the numerique-gouv orga was still present in the
repo. This commit replaces them to the current repo url.
2025-07-25 12:15:17 +00:00
Anthony LC 8c9380c356 🐛(frontend) fix empty left panel after deleting root doc
When we were deleting a root document, the left panel
was getting empty. It was because the panel thought that
it was a child document and was trying clear
dynamically the panel.
Now, we are checking if the document is a root or not,
if it is a root we just redirect to the homepage.
2025-07-25 12:55:29 +02:00
Anthony LC 3ff6d2541c ♻️(frontend) use more reliable properties in useTreeUtils
Using the treeContext was causing issues with
the current parent detection, in many places
the context is not available.
"depth" property is more reliable than
"nb_accesses_ancestors".
2025-07-25 12:22:48 +02:00
Anthony LC 34ce276222 (frontend) subdocs can manage link reach
The subdocs can now have their own link reach
properties, dissociated from the parent document.
2025-07-25 12:22:47 +02:00
Anthony LC 04273c3b3e 🐛(frontend) redirection 401 overridden
To capture a 401 we were using "onError" in the
queryClient default mutation options. The problem
is this way does not capture globally the onError,
if a mutation uses as well is own "onError", it will
override the default one, causing the 401 to
not be captured anymore.
We now use MutationCache, which allows us to
capture globally the onError, even if a mutation
has its own "onError" defined, this global one will
still be called.
2025-07-25 12:03:43 +02:00
Samuel Paccoud - DINUM 0b301b95c8 (backend) allow masking documents from the list view
Once users have visited a document to which they have access,
they can't remove it from their list view anymore. Several
users reported that this is annoying because a document that
gets a lot of updates keeps popping up at the top of their list
view.

They want to be able to mask the document in a click. We propose
to add a "masked documents" section in the left side bar where the
masked documents can still be found.
2025-07-24 18:39:56 +02:00
Samuel Paccoud - DINUM 228bdf733e (backend) fix wrong docstrings in tests for favorite documents
This was most likely due to copy pasta fail.
2025-07-24 18:39:56 +02:00
Anthony LC bbf48f088f ️(frontend) improve tree stability
Improve tree stability by limiting the requests,
we now only load the tree request one time then
we let the treeContext handle the state without
mutating it directly.
We do not do the doc subpage request anymore,
the treeContext has already the data we need,
we just need to update the tree node when needed.
2025-07-24 13:29:26 +02:00
Anthony LC b28ff8f632 🚨(frontend) fix lint warning about unnecessary assertion
- Recent upgrade highlighted a lint warning about
an unnecessary assertion in the BlockNoteToolbar component.
This commit removes the assertion to resolve the warning.
- Fix a test - upgrade causes an error on a selector click
2025-07-24 12:38:31 +02:00
Anthony LC 14b7cdf561 ⬆️(dependencies) update js dependencies 2025-07-23 16:32:07 +02:00
Krzysztof Cybulski c534fed196 📝(typo) fix link to kubernetes.md in compose.md
Link to kubernetes.md in compose.md was incorrect,
it was pointing to a non-existing file.

Signed-off-by: Krzysztof Cybulski <k.cybulski.dev@tuta.io>
2025-07-23 13:27:14 +02:00
Anthony LC c1a740b7d4 ⬆️(dependency) Bump form-data from 4.0.2 to 4.0.4
Bumps [form-data](https://github.com/form-data/form-data) from 4.0.2 to 4.0.4.
- [Release notes](https://github.com/form-data/form-data/releases)
- [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md)
- [Commits](https://github.com/form-data/form-data/compare/v4.0.2...v4.0.4)

---
updated-dependencies:
- dependency-name: form-data
  dependency-version: 4.0.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-23 12:20:08 +02:00
Anthony LC 83f2b3886e (frontend) add duplicate action to doc tree
We added a duplicate action to the document tree.
2025-07-21 19:48:58 +02:00
Anthony LC 966e514c5a ♻️(frontend) redirect to doc after duplicate
When we duplicate a document from a document page,
we now redirect the user to the newly created
document.
2025-07-21 19:48:57 +02:00
Anthony LC ef6d6c6a59 🏗️(e2e) cleaning and more consistant naming
Clean up e2e tests by removing unused utils
and renaming some files for consistency.
2025-07-21 18:07:10 +02:00
Anthony LC e79f3281b1 🐛(frontend) fix unfold subdocs not clickable at the bottom
At the bottom of the tree panel, the subdocs
were not clickable due to a CSS issue.
This commit adjusts the CSS to ensure that
the subdocs can be unfolded properly.
2025-07-21 17:34:44 +02:00
Anthony LC b78550b513 💄(frontend) visibility icon near title
It was decided to add a visibility icon near the
title of the document in the grid view.
2025-07-21 16:28:17 +02:00
Anthony LC 5a23c97681 🐛(service-worker) Fix useOffline Maximum update depth exceeded
Sentry was reporting a "Maximum update depth exceeded" error
comming from the `useOffline` hook. We updated the hook to
avoid mutation. Seems to impact mainly edge browsers.
2025-07-21 16:05:34 +02:00
Anthony LC 040eddbe6b 🔧(project) change env.d system by using local files
We had lot of problems with the previous env.d system.
Users were often confused by the need to change
the env.d files manually, leading to issues
when using the project locally.
This commit introduces a new system that uses
.env.local files, which are automatically created
and can be modified by users without affecting
the original env.d files. This should simplify
the development process and reduce confusion by
removing the need to manually edit env.d files.
2025-07-21 15:44:52 +02:00
Richard Jones f2e54308d2 🐛(helm) charts generate invalid YAML for collaboration API / WS
Closes #890

Remove the service blocks outside the conditionals in the collaboration
API and WS templates.

Signed-off-by: Richard Jones <rich@linuxplicable.org>
2025-07-18 14:22:03 +02:00
Anthony LC cd6e0ef9e1 🔖(release) patch release 3.4.2
Changed:
- ️(docker) Optimize Dockerfile to use apk with --no-cache

Fixed:
- 🐛(backend) improve prompt to not use code blocks delimiter
2025-07-18 12:30:01 +02:00
AntoLC 02acc7233f 🌐(i18n) update translated strings
Update translated files with new translations
2025-07-18 12:30:01 +02:00
Anthony LC 1c71e830a2 🐛(backend) improve prompt to not use code blocks delimiter
The AI answer was activating the code block feature
in the editor, which was not desired.
The prompt for AI actions has been updated to
instruct the AI to return content directly
without wrapping it in code blocks or markdown
delimiters.
2025-07-18 12:03:48 +02:00
Peter Dave Hello ac0c16a44a ️(docker) Optimize Dockerfile to use apk with --no-cache
Using the `apk` commands with the `--no-cache` parameter for package
installation and upgrade will prevent the package index from being
cached and reduce the built image size.
2025-07-18 10:08:13 +02:00
virgile-dev ca09f9a158 📝(contributing) update contributing policy (#1192)
## Purpose
Update the issue template to help contributors

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

---------

Signed-off-by: virgile-dev <virgile.deville@beta.gouv.fr>
2025-07-17 09:58:31 +00:00
Anthony LC d12b608db9 🔖(patch) release 3.4.1
Fixed:
- 🌐(frontend) keep simple tag during export
- 🐛(back) manage can-edit endpoint without created room in the ws
- 🐛(frontend) fix action buttons not clickable
- 🐛(frontend) fix crash share modal on grid options
2025-07-15 16:14:43 +02:00
Anthony LC 08a0eb59c8 🐛(frontend) fix crash share modal on grid options
The share modal in the DocsGridItem component
was crashing when opened due to a provider not
initialized.
2025-07-15 11:36:44 +02:00
renovate[bot] 0afc50fb93 ⬆️(dependencies) update js dependencies 2025-07-15 03:15:51 +00:00
renovate[bot] c48a4309c1 ⬆️(dependencies) update python dependencies 2025-07-11 06:14:43 +00:00
Anthony LC a212417fb8 🐛(frontend) fix action buttons not clickable (#1162)
If the title was too long, or the children deepness too deep, the action
buttons in the doc tree were not clickable.
This commit fixes the issue by ensuring that the action buttons are
always clickable, regardless of the title length or children depth.
2025-07-11 08:13:01 +02:00
Manuel Raynaud 500d4ea5ac 🐛(back) manage can-edit endpoint without created room in the ws (#1152)
In a scenario where the first user is editing a docs without websocket
and nobody has reached the websocket server first, the y-provider
service will return a 404 and we don't handle this case in the can-edit
endpoint leading to a server error.
2025-07-10 12:24:38 +00:00
Anthony LC 8a057b9c39 🌐(i18n) update translated strings
Update translated files with new translations
2025-07-10 12:48:52 +02:00
Anthony LC 6a12ac560e 🌐(frontend) keep simple tag during export
When we export translations, we want to keep the
simple tags like `<strong>` instead of converting
it to `<1>` and `</1>`.
2025-07-10 12:38:28 +02:00
Anthony LC 2e6cb109ef 🔖(minor) release 3.4.0
Added:
- (frontend) multi-pages
- (frontend) Duplicate a doc
- Ask for access
- (frontend) add customization for translations
- (backend) add ancestors links definitions to document abilities
- (backend) include ancestors accesses on document accesses list view
- (backend) add ancestors links reach and role to document API
- 📝(project) add troubleshoot doc
- 📝(project) add system-requirement doc
- 🔧(front) configure x-frame-options to DENY in nginx conf
- (backend) allow to disable checking unsafe mimetype on attachment upload
- (doc) add documentation to install with compose
-  Give priority to users connected to collaboration server
  (aka no websocket feature)

Changed:
- ♻️(backend) stop requiring owner for non-root documents
- ♻️(backend) simplify roles by ranking them and return only the max role
- 📌(yjs) stop pinning node to minor version on yjs docker image
- 🧑‍💻(docker) add .next to .dockerignore
- 🧑‍💻(docker) handle frontend development images with docker compose
- 🧑‍💻(docker) add y-provider config to development environment
- ️(frontend) optimize document fetch error handling

Fixed:
- 🐛(backend) fix link definition select options linked to ancestors
- 🐛(frontend) table of content disappearing
- 🐛(frontend) fix multiple EmojiPicker
- 🐛(frontend) fix meta title
- 🔧(git) set LF line endings for all text files
- 📝(docs) minor fixes to docs/env.md
- support `_FILE` environment variables for secrets

Removed:
- 🔥(frontend) remove Beta from logo
2025-07-09 17:26:02 +02:00
Manuel Raynaud 70635136cb 🐛(back) duplicating a child should not create accesses
Children does not have accesses created for now, they inherit from their
parent for now. We have to ignore access creation while owrk on the
children accesses has not been made.
2025-07-09 17:26:02 +02:00
Anthony LC 52a8dd0b5c 🩹(frontend) refresh tree after duplicate
After duplicating a document, the tree is now
refreshed to reflect the new structure.
This ensures that the user sees the updated
document tree immediately after the duplication
action.
2025-07-09 17:26:02 +02:00
Anthony LC 8a3dfe0252 🛂(frontend) blocked edition if multiple ancestors
With child documents we need to check the parent
documents to know if the parent doc are collaborative
or not.
2025-07-09 17:26:02 +02:00
Anthony LC 1110ec92d5 (backend) fix test access create
Importing the french translation broke a test
because the subject was not in english anymore.
We change the admin user language to english
to keep the subject in english.
2025-07-09 17:26:02 +02:00
AntoLC 1d01f6512e 🌐(i18n) update translated strings
Update translated files with new translations
2025-07-09 17:11:57 +02:00
Anthony LC cd366213ca 🛂(frontend) only owner can make a child doc a main doc
We get some side effects when an admin
tries to make a child doc a main doc.
We ensure that only the owner can do this.
2025-07-09 17:11:57 +02:00
Anthony LC d15285d385 ✏️(frontend) remove key from Trans component
We remove the key from the Trans componant
to make it easier to translate.
2025-07-09 11:44:31 +02:00
Anthony LC 377d4e8971 💄(frontend) change icon duplicate feature
We change the icon for the duplicate feature
in the document toolbox and the documents grid
actions from 'call_split' to 'content_copy'
to better reflect the action of duplicating a
document.
2025-07-08 17:43:16 +02:00
Anthony LC 70f0c7052c 🚩(frontend) remove "Available soon" tag about multipage
Multipage is available now, so we remove the
"Available soon" tag from the home page.
2025-07-08 17:43:16 +02:00
renovate[bot] ca2e02806a ⬆️(dependencies) update js dependencies 2025-07-08 15:18:27 +00:00
Anthony LC 33bd5ef116 ️(frontend) remove flickering when connecting to collab
The blocking edition modal could be flickring, because
the connection to the collaborative server can take
a bit of time.
We set a timeout to ensure the loading state
is cleared after a reasonable time.
2025-07-08 17:00:39 +02:00
Anthony LC 7abe1c9eb4 🛂(frontend) button request access only on parent
The children reflect the parent access. So we can
request access only on the parent document.
2025-07-08 17:00:38 +02:00
Manuel Raynaud 95838e332c 🛂(back) restrict ask for access to root documents
In a first version we want to restrict the ask for access feature only
to root document. We will work on opening to all documents when iherited
permissions will be implemented.
2025-07-08 17:00:38 +02:00
Nathan Panchout 82f2cb59e6 (frontend) enhance tests
- Removed 'feature/doc-dnd' branch from the Docker Hub workflow to
streamline deployment processes.
- Updated document creation tests to replace 'New page' button
references with 'New doc' for consistency.
- Enhanced test cases to improve clarity and ensure accurate
verification of document functionalities.
- Added new utility function for creating root subpages, improving test
maintainability.
2025-07-08 17:00:38 +02:00
Nathan Panchout 44909faa67 (frontend) add AlertModal and enhance document sharing features
- Introduced a new `AlertModal` component for confirmation dialogs.
- Updated `DocToolBoxLicenceAGPL` and `DocToolBoxLicenceMIT` to include
`isRootDoc` prop for better document management.
- Enhanced `DocShareModal` to conditionally render content based on the
root document status.
- Improved `DocInheritedShareContent` to display inherited access
information more effectively.
- Refactored `DocRoleDropdown` to handle access removal actions and
improve role management.
- Updated `DocShareMemberItem` to accommodate new access management
features.
2025-07-08 16:31:58 +02:00
Nathan Panchout 1c5270e301 (frontend) enhance dropdown menu and quick search components
- Added a horizontal separator to the dropdown menu for better visual
distinction between options.
- Updated padding in the quick search input for improved layout
consistency.
- Adjusted margin in the quick search group for better spacing.
- Increased vertical padding in quick search item content for enhanced
readability.
- Modified the horizontal separator to accept custom padding for more
flexible styling.
- Improved left panel styling to manage overflow behavior effectively.
- Removed unused skeleton loading styles from globals.css to clean up
the codebase.
2025-07-08 16:31:58 +02:00
Manuel Raynaud 6af8d78ede (back) fix backend code related to multipage dev
During the multipage dev, the code base has changed a lot and rebase
after rebase it has come difficult to manage fixup commits. This commits
fix modification made that can be fixup in previous commits. The
persmission AccessPermission has been renamed in
ResourceWithAccessPermission and should be used in the
DocumentAskForAccessViewSet. A migration with the same dependency
exists, the last one is fixed. And a test didn't have removed an
abilitites.
2025-07-08 16:31:58 +02:00
Nathan Panchout 304b3be273 (frontend) update test descriptions for clarity and consistency
- update tests description
- Corrected minor typos in test descriptions to enhance readability.
- Ensured that all test cases clearly convey their purpose and expected
outcomes.
2025-07-08 16:31:58 +02:00
Nathan Panchout 17ece3b715 (frontend) enhance document sharing and access management
- Introduced new utility functions for managing document sharing,
including `searchUserToInviteToDoc`, `addMemberToDoc`, and
`updateShareLink`.
- Updated existing tests to verify inherited share access and link
visibility features.
- Refactored document access handling in tests to improve clarity and
maintainability.
- Added comprehensive tests for inherited share functionalities,
ensuring proper role and access management for subpages.
2025-07-08 16:31:57 +02:00
Nathan Panchout 510d6c3ff1 (frontend) enhance document sharing and visibility features
- Added a new component `DocInheritedShareContent` to display inherited
access information for documents.
- Updated `DocShareModal` to include inherited share content when
applicable.
- Refactored `DocRoleDropdown` to improve role selection messaging based
on inherited roles.
- Enhanced `DocVisibility` to manage link reach and role updates more
effectively, including handling desynchronization scenarios.
- Improved `DocShareMemberItem` to accommodate inherited access logic
and ensure proper role management.
2025-07-08 16:31:57 +02:00
Nathan Panchout cab7771b82 (frontend) refactor document access API and remove infinite query
- Simplified the `getDocAccesses` function by removing pagination
parameters.
- Updated the `useDocAccesses` hook to reflect changes in the API
response type.
- Removed the `useDocAccessesInfinite` function to streamline document
access management.
2025-07-08 16:31:57 +02:00
Nathan Panchout 93d9dec068 (frontend) enhance document management types and utilities
- Updated the `Access` and `Doc` interfaces to include new properties
for role management and document link reach.
- Introduced utility functions to handle document link reach and role,
improving the logic for determining access levels.
- Refactored the `isOwnerOrAdmin` function to simplify role checks for
document ownership and admin status.
2025-07-08 16:31:57 +02:00
Anthony LC adb15dedb8 ♻️(frontend) reduce props drilling
- Reduce proprs drilling
- Improve state rerendering with useIsCollaborativeEditable
2025-07-08 16:31:56 +02:00
Anthony LC 6ece3264d6 🔥(frontend) silent next.js error
The error modal since next.js 15 are quite intrusive.
We decided to hide them.
2025-07-08 16:31:56 +02:00
Nathan Panchout 2a3b31fcff (frontend) added new features for document management
- Created new files for managing subdocuments and detaching documents.
- Refactored API request configuration to use an improved configuration
type.
- Removed unnecessary logs from the ModalConfirmDownloadUnsafe
component.
2025-07-08 16:31:56 +02:00
Nathan Panchout 9a64ebc1e9 (frontend) added subpage management and document tree features
New components were created to manage subpages in the document tree,
including the ability to add, reorder, and view subpages. Tests were
added to verify the functionality of these features. Additionally, API
changes were made to manage the creation and retrieval of document
children.
2025-07-08 16:31:56 +02:00
Nathan Panchout cb2ecfcea3 (frontend) Added drag-and-drop functionality for document management
Added a new feature for moving documents within the user interface via
drag-and-drop. This includes the creation of Draggable and Droppable
components, as well as tests to verify document creation and movement
behavior. Changes have also been made to document types to include user
roles and child management capabilities.
2025-07-08 16:31:56 +02:00
Nathan Panchout 13696ffbd7 (frontend) updated dependencies and added new packages
Added several new dependencies to the `package.json` file, including
`@dnd-kit/core`, `@dnd-kit/modifiers`, `@fontsource/material-icons`, and
`@gouvfr-lasuite/ui-kit`.
2025-07-08 13:58:43 +02:00
Nathan Panchout 40ed2d2e22 🐛(back) keep info if document has deleted children
With the soft delete feature, relying on the is_leaf method from the
treebeard is not accurate anymore. To determine if a node is a leaf, it
checks if the number of numchild is equal to 0. But a node can have soft
deleted children, then numchild is equal to 0, but it is not a leaf
because if we want to add a child we have to look for the last child to
compute a correct path. Otherwise we will have an error saying that the
path already exists.
2025-07-08 13:58:43 +02:00
Samuel Paccoud - DINUM ecb20f6f77 (backend) add ancestors links definitions to document abilities
The frontend needs to display inherited link accesses when it displays
possible selection options. We need to return this information to the
client.
2025-07-08 13:58:43 +02:00
Samuel Paccoud - DINUM 7bc060988d ♻️(backend) simplify roles by returning only the max role
We were returning the list of roles a user has on a document (direct
and inherited). Now that we introduced priority on roles, we are able
to determine what is the max role and return only this one.

This commit also changes the role that is returned for the restricted
reach: we now return None because the role is not relevant in this
case.
2025-07-08 13:53:16 +02:00
Samuel Paccoud - DINUM 122e510ff4 (backend) add ancestors links definitions to document abilities
The frontend needs to display inherited link accesses when it displays
possible selection options. We need to return this information to the
client.
2025-07-08 13:53:16 +02:00
Samuel Paccoud - DINUM f717a39109 🐛(backend) fix link definition select options linked to ancestors
We were returning too many select options for the restricted link reach:
- when the "restricted" reach is an option (key present in the returned
  dictionary), the possible values for link roles are now always None to
  make it clearer that they don't matter and no select box should be
  shown for roles.
- Never propose "restricted" as option for link reach when the ancestors
  already offer a public access. Indeed, restricted/editor was shown when
  the ancestors had public/read access. The logic was to propose editor
  role on more restricted reaches... but this does not make sense for
  restricted since the role does is not taken into account for this reach.
  Roles are set by each access line assign to users/teams.
2025-07-08 13:53:16 +02:00
Samuel Paccoud - DINUM 04b8400766 (backend) add max_role field to the document access API endpoint
The frontend needs to know what to display on an access. The maximum
role between the access role and the role equivalent to all accesses
on the document's ancestors should be computed on the backend.
2025-07-08 13:53:16 +02:00
Samuel Paccoud - DINUM d232654c55 ♻️(backend) simplify further select options on link reach/role
We reduce the number of options even more by treating link reach
and link role independently: link reach must be higher than its
ancestors' equivalent link reach and link role must be higher than
its ancestors' link role.

This reduces the number of possibilities but we decided to start
with the most restrictive and simple offer and extend it if we
realize it faces too many criticism instead of risking to offer
too many options that are too complex and must be reduced afterwards.
2025-07-08 13:53:16 +02:00
Samuel Paccoud - DINUM d0eb2275e5 🐛(backend) fix creating/updating document accesses for teams
This use case was forgotten when the support for team accesses
was added. We add tests to stabilize the feature and its security.
2025-07-08 13:53:16 +02:00
Samuel Paccoud - DINUM 50faf766c8 (backend) add document path and depth to accesses endpoint
The frontend requires this information about the ancestor document
to which each access is related. We make sure it does not generate
more db queries and does not fetch useless and heavy fields from
the document like "excerpt".
2025-07-08 13:53:15 +02:00
Samuel Paccoud - DINUM 433cead0ac 🐛(backend) allow creating accesses when privileged by heritage
We took the opportunity of this bug to refactor serializers and
permissions as advised one day by @qbey: no permission checks in
serializers.
2025-07-08 13:53:15 +02:00
Samuel Paccoud - DINUM d12c637dad (backend) fix randomly failing test due to delay before check
There is a delay between the time the signature is issued and the
time it is checked. Although this delay is minimal, if the signature
is issued at the end of a second, both timestamps can differ of 1s.

> assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
AssertionError: assert equals failed '20250504T175307Z'  '20250504T175308Z'
2025-07-08 13:53:15 +02:00
Samuel Paccoud - DINUM 184b5c015b ♻️(backend) stop requiring owner for non-root documents
If root documents are guaranteed to have a owner, non-root documents
will automatically have them as owner by inheritance. We should not
require non-root documents to have their own direct owner because
this will make it difficult to manage access rights when we move
documents around or when we want to remove access rights for someone
on a document subtree... There should be as few overrides as possible.
2025-07-08 13:53:15 +02:00
Samuel Paccoud - DINUM 1ab237af3b (backend) add max ancestors role field to document access endpoint
This field is set only on the list view when all accesses for a given
document and all its ancestors are listed. It gives the highest role
among all accesses related to each document.
2025-07-08 13:52:35 +02:00
Samuel Paccoud - DINUM f782a0236b ♻️(backend) optimize refactoring access abilities and fix inheritance
The latest refactoring in a445278 kept some factorizations that are
not legit anymore after the refactoring.

It is also cleaner to not make serializer choice in the list view if
the reason for this choice is related to something else b/c other
views would then use the wrong serializer and that would be a
security leak.

This commit also fixes a bug in the access rights inheritance: if a
user is allowed to see accesses on a document, he should see all
acesses related to ancestors, even the ancestors that he can not
read. This is because the access that was granted on all ancestors
also apply on the current document... so it must be displayed.

Lastly, we optimize database queries because the number of accesses
we fetch is going up with multi-pages and we were generating a lot
of useless queries.
2025-07-08 13:52:35 +02:00
Samuel Paccoud - DINUM c1fc1bd52f (backend) add computed link reach and role to document API
On a document, we need to display the status of the link (reach and
role) taking into account the ancestors link reach/role as well as
the current document.
2025-07-08 13:52:35 +02:00
Samuel Paccoud - DINUM 1c34305393 (backend) add ancestors link reach and role to document API
On a document, we need to display the status of the link (reach and
role) as inherited from its ancestors.
2025-07-08 13:52:34 +02:00
Samuel Paccoud - DINUM 611ba496d2 ♻️(backend) simplify roles by returning only the max role
We were returning the list of roles a user has on a document (direct
and inherited). Now that we introduced priority on roles, we are able
to determine what is the max role and return only this one.

This commit also changes the role that is returned for the restricted
reach: we now return None because the role is not relevant in this
case.
2025-07-08 13:51:26 +02:00
Samuel Paccoud - DINUM 0a9a583a67 (backend) fix randomly failing test on user search
The user account created to query the API had a random email
that could randomly interfere with our search results.
2025-07-08 13:49:32 +02:00
Samuel Paccoud - DINUM 8f67e382ba ♻️(backend) refactor get_select_options to take definitions dict
This will allow us to simplify the get_abilities method. It is also
more efficient because we have computed this definitions dict and
the the get_select_options method was doing the conversion again.
2025-07-08 13:49:32 +02:00
Samuel Paccoud - DINUM 18d46acd75 (backend) give an order to choices
We are going to need to compare choices to materialize the fact that
choices are ordered. For example an admin role is higer than an
editor role but lower than an owner role.

We will need this to compute the reach and role resulting from all
the document accesses (resp. link accesses) assigned on a document's
ancestors.
2025-07-08 13:49:31 +02:00
Samuel Paccoud - DINUM fae024229e (backend) we want to display ancestors accesses on a document share
The document accesses a user have on a document's ancestors also apply
to this document. The frontend needs to list them as "inherited" so we
need to add them to the list.
Adding a "document_id" field on the output will allow the frontend to
differentiate between inherited and direct accesses on a document.
2025-07-08 13:49:31 +02:00
Samuel Paccoud - DINUM df2b953e53 ♻️(backend) factorize document query set annotation
The methods to annotate a document queryset were factorized on the
viewset but the correct place is the custom queryset itself now that
we have one.
2025-07-08 13:47:39 +02:00
Samuel Paccoud - DINUM a7c91f9443 ♻️(backend) refactor resource access viewset
The document viewset was overriding the get_queryset method from its
own mixin. This was a sign that the mixin was not optimal anymore.
In the next commit I will need to complexify it further so it's time
to refactor the mixin.
2025-07-08 13:47:39 +02:00
Samuel Paccoud - DINUM 0a5887c162 ♻️(backend) remove different reach for authenticated and anonymous
If anonymous users have reader access on a parent, we were considering
that an edge use case was interesting: allowing an authenticated user
to still be editor on the child.

Although this use case could be interesting, we consider, as a first
approach, that the value it carries is not big enough to justify the
complexity for the user to understand this complex access right heritage.
2025-07-08 13:47:39 +02:00
Samuel Paccoud - DINUM 26c7af0dbf (backend) add ancestors links definitions to document abilities
The frontend needs to display inherited link accesses when it displays
possible selection options. We need to return this information to the
client.
2025-07-08 13:47:39 +02:00
Samuel Paccoud - DINUM 0499aec624 🐛(backend) fix link definition select options linked to ancestors
We were returning too many select options for the restricted link reach:
- when the "restricted" reach is an option (key present in the returned
  dictionary), the possible values for link roles are now always None to
  make it clearer that they don't matter and no select box should be
  shown for roles.
- Never propose "restricted" as option for link reach when the ancestors
  already offer a public access. Indeed, restricted/editor was shown when
  the ancestors had public/read access. The logic was to propose editor
  role on more restricted reaches... but this does not make sense for
  restricted since the role does is not taken into account for this reach.
  Roles are set by each access line assign to users/teams.
2025-07-08 13:46:38 +02:00
renovate[bot] 21624e9224 ⬆️(dependencies) update js dependencies 2025-07-07 19:27:46 +00:00
Manuel Raynaud b0a9ce0938 📝(readme) clean README (#1129)
## Purpose

Clean readme
2025-07-07 16:17:05 +00:00
renovate[bot] e256017628 ⬆️(dependencies) update python dependencies 2025-07-07 08:42:36 +00:00
Anthony LC 50ce604ade 🐛(frontend) fix circular dependencies
Seems to have some circular dependencies appearing.
We will import what we need directly from the
feature instead of the parent docs index file.
2025-07-07 10:21:10 +02:00
Anthony LC 55979e4370 🛂(frontend) block edition only when not alone
We added a system to know if a user is alone
on a document or not. We adapt the
frontend to block the edition only
when the user is not alone on the document.
2025-07-07 10:21:09 +02:00
Manuel Raynaud 9a8f952210 🚩(back) use existing no websocket feature flag
An already existing feature flag
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY was used bu the frontend
application to disable or not the edition for a user not connected to
the websocket. We want to reuse it in the backend application to disable
or not the no websocket feature.
2025-07-07 10:21:09 +02:00
Manuel Raynaud 118804e810 (back) new endpoint document can_edit
The endpoint can_edit is added to the DocumentViewset, it will give the
information to the frontend application id the current user can edit the
Docs based on the no-websocket rules.
2025-07-07 10:20:12 +02:00
Manuel Raynaud 651f2d1d75 (back) check on document update if user can save it
When a document is updated, users not connected to the collaboration
server can override work made by other people connected to the
collaboration server. To avoid this, the priority is given to user
connected to the collaboration server. If the websocket property in the
request payload is missing or set to False, the backend fetch the
collaboration server to now if the user can save or not. If users are
already connected, the user can't save. Also, only one user without
websocket can save a connect, the first user saving acquire a lock and
all other users can't save.
To implement this behavior, we need to track all users, connected and
not, so a session is created for every user in the
ForceSessionMiddleware.
2025-07-07 10:15:22 +02:00
Manuel Raynaud b96de36382 (y-provider) add endpoint returning document connection state
We need a new endpoint in the y-provider server allowing the backend to
retrieve the number of active connections on a document and if a session
key exists.
2025-07-07 10:15:20 +02:00
Stephan Meijer 65b6701708 ♻️(backend) pass API token to Yprovider with scheme Bearer
Signed-off-by: Stephan Meijer <me@stephanmeijer.com>
2025-07-04 17:11:20 +02:00
Stephan Meijer 0be366b7b6 ♻️(frontend) support Bearer in servers/y-provider
Support passing API Token as Bearer in the Authorization-header.

Signed-off-by: Stephan Meijer <me@stephanmeijer.com>
2025-07-04 17:11:19 +02:00
Stephan Meijer 78a6772bab ♻️(backend) raw payloads on convert endpoint
Handle the raw payloads in requests and responses to convert-endpoint.

This change replaces Base64-encoded I/O with direct binary streaming,
yielding several benefits:
- **Network efficiency**: Eliminates the ~33% size inflation of Base64,
cutting bandwidth and latency.
- **Memory savings**: Enables piping DOCX (already compressed) buffers
straight to DocSpec API without holding, encoding and decoding multi-MB
payload in RAM.

Signed-off-by: Stephan Meijer <me@stephanmeijer.com>
2025-07-04 17:11:15 +02:00
Stephan Meijer fde520a6f3 ♻️(frontend) raw payloads on convert endpoint
Accept raw payload on convert-endpoint and respond with raw Yjs payload

This change replaces Base64-encoded I/O with direct binary streaming,
yielding several benefits:
- **Network efficiency**: Eliminates the ~33% size inflation of Base64,
cutting bandwidth and latency.
- **Memory savings**: Enables piping DOCX (already compressed) buffers
straight to DocSpec API without holding, encoding and decoding multi-MB
payload in RAM.

Signed-off-by: Stephan Meijer <me@stephanmeijer.com>
2025-07-04 17:10:47 +02:00
Stephan Meijer cef2d274fc ♻️(frontend) following HTTP standards on auth
Return 401 Unauthorized for missing/invalid API keys (per RFC 7235);
403 is reserved for valid-but-forbidden credentials.

Signed-off-by: Stephan Meijer <me@stephanmeijer.com>
2025-07-04 17:05:13 +02:00
Stephan Meijer a9db392a61 ♻️(frontend) simplify Express middleware
Simplify the use of middleware in Express

Signed-off-by: Stephan Meijer <me@stephanmeijer.com>
2025-07-04 17:04:58 +02:00
Stephan Meijer 186ae952f5 (frontend) test successful conversion
Signed-off-by: Stephan Meijer <me@stephanmeijer.com>
2025-07-04 17:04:55 +02:00
Stephan Meijer f3c9c41b86 (frontend) switch to vitest and enhance testability
Migrated from jest to vitest for server/y-provider, gaining faster runs,
esm-native support and cleaner mocking.

Signed-off-by: Stephan Meijer <me@stephanmeijer.com>
2025-07-04 17:04:28 +02:00
Stephan Meijer 58bf5071c2 ♻️(backend) rename convert_markdown to convert (#1114)
Renamed the `convert_markdown` method to `convert` to prepare for an
all-purpose conversion endpoint, enabling support for multiple formats
and simplifying future extension.

Signed-off-by: Stephan Meijer <me@stephanmeijer.com>
2025-07-04 13:30:32 +00:00
Manuel Raynaud e148c237f1 🛂(back) restrict duplicate with accesses to admin or owner
Only admin or owner should be able to duplicate a document with existing
accesses.
2025-07-03 11:23:56 +02:00
Manuel Raynaud e82e6a1fcf 🛂(back) restrict document's duplicate action to authenticated users
The duplicate was also able for anonynous user if they can read it. We
have to restrict it to at least reader authenticated otherwise no access
will be created on the duplicated document.
2025-07-03 11:23:56 +02:00
Anthony LC fc1678d0c2 (frontend) Duplicate a doc
We can duplicate a document from the
tool options.
2025-07-03 11:23:55 +02:00
Anthony LC 2b2e81f042 ♻️(frontend) Simplify AGPL export pattern
We were maintaining two separate components
for AGPL and MIT license exports.
This commit consolidates the functionality into
a single component that handles both licenses,
simplifying the codebase and reducing duplication.
2025-07-02 15:06:37 +02:00
Stephan Meijer c8ae2f6549 ♻️(backend) rename convert-markdown endpoint
Renamed the `convert-markdown` endpoint to `convert` as a
general-purpose conversion endpoint for integration with DocSpec
conversion (DOCX import), without altering its existing functionality.

In a future contribution, this endpoint will not only support conversion
from Markdown -> BlockNote -> Yjs but also directly BlockNote -> Yjs.

Signed-off-by: Stephan Meijer <me@stephanmeijer.com>
2025-07-02 14:49:02 +02:00
Manuel Raynaud 1d741871d7 (helm) allow to configure cronjobs using backend image
We want to configure cronjobs. Instead of declaring them one by one, we
use a CronJobList, the will all have the same pattern, mostly the
command and the schedule will change.
2025-07-01 14:51:29 +02:00
278 changed files with 30620 additions and 21499 deletions
-6
View File
@@ -1,6 +0,0 @@
<!---
Thanks for filing an issue 😄 ! Before you submit, please read the following:
Check the other issue templates if you are trying to submit a bug report, feature request, or question
Search open/closed issues before submitting since someone might have asked the same thing before!
-->
+4
View File
@@ -6,6 +6,10 @@ labels: ["bug", "triage"]
## Bug Report
**Before you file your issue**
- Check the other [issues](https://github.com/suitenumerique/docs/issues) before filing your own
- If your report is related to the ([BlockNote](https://github.com/TypeCellOS/BlockNote)) text editor, [file it on their repo](https://github.com/TypeCellOS/BlockNote/issues). If you're not sure whether your issue is with BlockNote or Docs, file it on our repo: if we support it, we'll backport it upstream ourselves 😊, otherwise we'll ask you to do so.
**Problematic behavior**
A clear and concise description of the behavior.
+2 -2
View File
@@ -80,7 +80,7 @@ jobs:
fail-on-cache-miss: true
- name: Set e2e env variables
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
run: cat env.d/development/common.e2e >> env.d/development/common.local
- name: Install Playwright Browsers
run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright chromium
@@ -119,7 +119,7 @@ jobs:
fail-on-cache-miss: true
- name: Set e2e env variables
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
run: cat env.d/development/common.e2e >> env.d/development/common.local
- name: Install Playwright Browsers
run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright firefox webkit chromium
+1 -2
View File
@@ -40,8 +40,7 @@ venv/
ENV/
env.bak/
venv.bak/
env.d/development/*
!env.d/development/*.dist
env.d/development/*.local
env.d/terraform
# npm
+108 -50
View File
@@ -10,16 +10,77 @@ and this project adheres to
### Added
- ✨(frontend) add customization for translations #857
- 📝(project) add troubleshoot doc #1066
- 📝(project) add system-requirement doc #1066
- 🔧(front) configure x-frame-options to DENY in nginx conf #1084
- (doc) add documentation to install with compose #855
- ✨(backend) allow to disable checking unsafe mimetype on attachment upload
- ✨Ask for access #1081
- ✨(helm) Service Account support for K8s Resources in Helm Charts #778
- ✨(backend) allow masking documents from the list view #1171
- ✨(frontend) subdocs can manage link reach #1190
- (frontend) add duplicate action to doc tree #1175
- ✨(frontend) Interlinking doc #904
- ✨(frontend) add multi columns support for editor #1219
### Changed
- ♻️(frontend) search on all docs if no children #1184
- ♻️(frontend) redirect to doc after duplicate #1175
- 🔧(project) change env.d system by using local files #1200
- ⚡️(frontend) improve tree stability #1207
- ⚡️(frontend) improve accessibility
- #1232
- #1251
- 🛂(frontend) block drag n drop when not desktop #1239
### Fixed
- 🐛(service-worker) Fix useOffline Maximum update depth exceeded #1196
- 🐛(frontend) fix empty left panel after deleting root doc #1197
- 🐛(helm) charts generate invalid YAML for collaboration API / WS #890
- 🐛(frontend) 401 redirection overridden #1214
- 🐛(frontend) include root parent in search #1243
## [3.4.2] - 2025-07-18
### Changed
- ⚡️(docker) Optimize Dockerfile to use apk with --no-cache #743
### Fixed
- 🐛(backend) improve prompt to not use code blocks delimiter #1188
## [3.4.1] - 2025-07-15
### Fixed
- 🌐(frontend) keep simple tag during export #1154
- 🐛(back) manage can-edit endpoint without created room
in the ws #1152
- 🐛(frontend) fix action buttons not clickable #1162
- 🐛(frontend) fix crash share modal on grid options #1174
- 🐛(frontend) fix unfold subdocs not clickable at the bottom #1179
## [3.4.0] - 2025-07-09
### Added
- ✨(frontend) multi-pages #701
- ✨(frontend) Duplicate a doc #1078
- ✨Ask for access #1081
- ✨(frontend) add customization for translations #857
- ✨(backend) add ancestors links definitions to document abilities #846
- ✨(backend) include ancestors accesses on document accesses list view #846
- ✨(backend) add ancestors links reach and role to document API #846
- 📝(project) add troubleshoot doc #1066
- 📝(project) add system-requirement doc #1066
- 🔧(frontend) configure x-frame-options to DENY in nginx conf #1084
- ✨(backend) allow to disable checking unsafe mimetype on
attachment upload #1099
- ✨(doc) add documentation to install with compose #855
- ✨ Give priority to users connected to collaboration server
(aka no websocket feature) #1093
### Changed
- ♻️(backend) stop requiring owner for non-root documents #846
- ♻️(backend) simplify roles by ranking them and return only the max role #846
- 📌(yjs) stop pinning node to minor version on yjs docker image #1005
- 🧑‍💻(docker) add .next to .dockerignore #1055
- 🧑‍💻(docker) handle frontend development images with docker compose #1033
@@ -28,19 +89,18 @@ and this project adheres to
### Fixed
- 🐛(backend) fix link definition select options linked to ancestors #846
- 🐛(frontend) table of content disappearing #982
- 🐛(frontend) fix multiple EmojiPicker #1012
- 🐛(frontend) fix meta title #1017
- 🔧(git) set LF line endings for all text files #1032
- 📝(docs) minor fixes to docs/env.md
-(backend) support `_FILE` environment variables for secrets #912
- ✨(frontend) support `_FILE` environment variables for secrets #912
- ✨support `_FILE` environment variables for secrets #912
### Removed
- 🔥(frontend) remove Beta from logo #1095
## [3.3.0] - 2025-05-06
### Added
@@ -66,13 +126,13 @@ and this project adheres to
- ⬆️(docker) upgrade node images to alpine 3.21 #973
### Fixed
- 🐛(y-provider) increase JSON size limits for transcription conversion #989
### Removed
- 🔥(back) remove footer endpoint #948
## [3.2.1] - 2025-05-06
## Fixed
@@ -80,7 +140,6 @@ and this project adheres to
- 🐛(frontend) fix list copy paste #943
- 📝(doc) update contributing policy (commit signatures are now mandatory) #895
## [3.2.0] - 2025-05-05
## Added
@@ -91,7 +150,7 @@ and this project adheres to
- ✨(settings) Allow configuring PKCE for the SSO #886
- 🌐(i18n) activate chinese and spanish languages #884
- 🔧(backend) allow overwriting the data directory #893
- (backend) add `django-lasuite` dependency #839
- (backend) add `django-lasuite` dependency #839
- ✨(frontend) advanced table features #908
## Changed
@@ -142,7 +201,6 @@ and this project adheres to
- 🐛(backend) compute ancestor_links in get_abilities if needed #725
- 🔒️(back) restrict access to document accesses #801
## [2.6.0] - 2025-03-21
## Added
@@ -161,7 +219,6 @@ and this project adheres to
- 🔒️(back) throttle user list endpoint #636
- 🔒️(back) remove pagination and limit to 5 for user list endpoint #636
## [2.5.0] - 2025-03-18
## Added
@@ -184,15 +241,14 @@ and this project adheres to
## Fixed
- 🐛(frontend) SVG export #706
- 🐛(frontend) remove scroll listener table content #688
- 🐛(frontend) remove scroll listener table content #688
- 🔒️(back) restrict access to favorite_list endpoint #690
- 🐛(backend) refactor to fix filtering on children
and descendants views #695
- 🐛(backend) refactor to fix filtering on children
and descendants views #695
- 🐛(action) fix notify-argocd workflow #713
- 🚨(helm) fix helmfile lint #736
- 🚚(frontend) redirect to 401 page when 401 error #759
## [2.4.0] - 2025-03-06
## Added
@@ -207,7 +263,6 @@ and this project adheres to
- 🐛(frontend) fix collaboration error #684
## [2.3.0] - 2025-03-03
## Added
@@ -623,33 +678,36 @@ and this project adheres to
- ✨(frontend) Coming Soon page (#67)
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/numerique-gouv/impress/compare/v3.3.0...main
[v3.3.0]: https://github.com/numerique-gouv/impress/releases/v3.3.0
[v3.2.1]: https://github.com/numerique-gouv/impress/releases/v3.2.1
[v3.2.0]: https://github.com/numerique-gouv/impress/releases/v3.2.0
[v3.1.0]: https://github.com/numerique-gouv/impress/releases/v3.1.0
[v3.0.0]: https://github.com/numerique-gouv/impress/releases/v3.0.0
[v2.6.0]: https://github.com/numerique-gouv/impress/releases/v2.6.0
[v2.5.0]: https://github.com/numerique-gouv/impress/releases/v2.5.0
[v2.4.0]: https://github.com/numerique-gouv/impress/releases/v2.4.0
[v2.3.0]: https://github.com/numerique-gouv/impress/releases/v2.3.0
[v2.2.0]: https://github.com/numerique-gouv/impress/releases/v2.2.0
[v2.1.0]: https://github.com/numerique-gouv/impress/releases/v2.1.0
[v2.0.1]: https://github.com/numerique-gouv/impress/releases/v2.0.1
[v2.0.0]: https://github.com/numerique-gouv/impress/releases/v2.0.0
[v1.10.0]: https://github.com/numerique-gouv/impress/releases/v1.10.0
[v1.9.0]: https://github.com/numerique-gouv/impress/releases/v1.9.0
[v1.8.2]: https://github.com/numerique-gouv/impress/releases/v1.8.2
[v1.8.1]: https://github.com/numerique-gouv/impress/releases/v1.8.1
[v1.8.0]: https://github.com/numerique-gouv/impress/releases/v1.8.0
[v1.7.0]: https://github.com/numerique-gouv/impress/releases/v1.7.0
[v1.6.0]: https://github.com/numerique-gouv/impress/releases/v1.6.0
[1.5.1]: https://github.com/numerique-gouv/impress/releases/v1.5.1
[1.5.0]: https://github.com/numerique-gouv/impress/releases/v1.5.0
[1.4.0]: https://github.com/numerique-gouv/impress/releases/v1.4.0
[1.3.0]: https://github.com/numerique-gouv/impress/releases/v1.3.0
[1.2.1]: https://github.com/numerique-gouv/impress/releases/v1.2.1
[1.2.0]: https://github.com/numerique-gouv/impress/releases/v1.2.0
[1.1.0]: https://github.com/numerique-gouv/impress/releases/v1.1.0
[1.0.0]: https://github.com/numerique-gouv/impress/releases/v1.0.0
[0.1.0]: https://github.com/numerique-gouv/impress/releases/v0.1.0
[unreleased]: https://github.com/suitenumerique/docs/compare/v3.4.2...main
[v3.4.2]: https://github.com/suitenumerique/docs/releases/v3.4.2
[v3.4.1]: https://github.com/suitenumerique/docs/releases/v3.4.1
[v3.4.0]: https://github.com/suitenumerique/docs/releases/v3.4.0
[v3.3.0]: https://github.com/suitenumerique/docs/releases/v3.3.0
[v3.2.1]: https://github.com/suitenumerique/docs/releases/v3.2.1
[v3.2.0]: https://github.com/suitenumerique/docs/releases/v3.2.0
[v3.1.0]: https://github.com/suitenumerique/docs/releases/v3.1.0
[v3.0.0]: https://github.com/suitenumerique/docs/releases/v3.0.0
[v2.6.0]: https://github.com/suitenumerique/docs/releases/v2.6.0
[v2.5.0]: https://github.com/suitenumerique/docs/releases/v2.5.0
[v2.4.0]: https://github.com/suitenumerique/docs/releases/v2.4.0
[v2.3.0]: https://github.com/suitenumerique/docs/releases/v2.3.0
[v2.2.0]: https://github.com/suitenumerique/docs/releases/v2.2.0
[v2.1.0]: https://github.com/suitenumerique/docs/releases/v2.1.0
[v2.0.1]: https://github.com/suitenumerique/docs/releases/v2.0.1
[v2.0.0]: https://github.com/suitenumerique/docs/releases/v2.0.0
[v1.10.0]: https://github.com/suitenumerique/docs/releases/v1.10.0
[v1.9.0]: https://github.com/suitenumerique/docs/releases/v1.9.0
[v1.8.2]: https://github.com/suitenumerique/docs/releases/v1.8.2
[v1.8.1]: https://github.com/suitenumerique/docs/releases/v1.8.1
[v1.8.0]: https://github.com/suitenumerique/docs/releases/v1.8.0
[v1.7.0]: https://github.com/suitenumerique/docs/releases/v1.7.0
[v1.6.0]: https://github.com/suitenumerique/docs/releases/v1.6.0
[1.5.1]: https://github.com/suitenumerique/docs/releases/v1.5.1
[1.5.0]: https://github.com/suitenumerique/docs/releases/v1.5.0
[1.4.0]: https://github.com/suitenumerique/docs/releases/v1.4.0
[1.3.0]: https://github.com/suitenumerique/docs/releases/v1.3.0
[1.2.1]: https://github.com/suitenumerique/docs/releases/v1.2.1
[1.2.0]: https://github.com/suitenumerique/docs/releases/v1.2.0
[1.1.0]: https://github.com/suitenumerique/docs/releases/v1.1.0
[1.0.0]: https://github.com/suitenumerique/docs/releases/v1.0.0
[0.1.0]: https://github.com/suitenumerique/docs/releases/v0.1.0
+4 -5
View File
@@ -7,8 +7,7 @@ FROM python:3.13.3-alpine AS base
RUN python -m pip install --upgrade pip setuptools
# Upgrade system packages to install security updates
RUN apk update && \
apk upgrade
RUN apk update && apk upgrade --no-cache
# ---- Back-end builder image ----
FROM base AS back-builder
@@ -45,7 +44,7 @@ FROM base AS link-collector
ARG IMPRESS_STATIC_ROOT=/data/static
# Install pango & rdfind
RUN apk add \
RUN apk add --no-cache \
pango \
rdfind
@@ -71,7 +70,7 @@ FROM base AS core
ENV PYTHONUNBUFFERED=1
# Install required system libs
RUN apk add \
RUN apk add --no-cache \
cairo \
file \
font-noto \
@@ -117,7 +116,7 @@ FROM core AS backend-development
USER root:root
# Install psql
RUN apk add postgresql-client
RUN apk add --no-cache postgresql-client
# Uninstall impress and re-install it in editable mode along with development
# dependencies
+8 -22
View File
@@ -67,18 +67,18 @@ data/static:
# -- Project
create-env-files: ## Copy the dist env files to env files
create-env-files: \
env.d/development/common \
env.d/development/crowdin \
env.d/development/postgresql \
env.d/development/kc_postgresql
.PHONY: create-env-files
create-env-local-files: ## create env.local files in env.d/development
create-env-local-files:
@touch env.d/development/crowdin.local
@touch env.d/development/common.local
@touch env.d/development/postgresql.local
@touch env.d/development/kc_postgresql.local
.PHONY: create-env-local-files
pre-bootstrap: \
data/media \
data/static \
create-env-files
create-env-local-files
.PHONY: pre-bootstrap
post-bootstrap: \
@@ -258,20 +258,6 @@ resetdb: ## flush database and create a superuser "admin"
@${MAKE} superuser
.PHONY: resetdb
env.d/development/common:
cp -n env.d/development/common.dist env.d/development/common
env.d/development/postgresql:
cp -n env.d/development/postgresql.dist env.d/development/postgresql
env.d/development/kc_postgresql:
cp -n env.d/development/kc_postgresql.dist env.d/development/kc_postgresql
# -- Internationalization
env.d/development/crowdin:
cp -n env.d/development/crowdin.dist env.d/development/crowdin
crowdin-download: ## Download translated message from crowdin
@$(COMPOSE_RUN_CROWDIN) download -c crowdin/config.yml
.PHONY: crowdin-download
-2
View File
@@ -34,8 +34,6 @@ Docs, where your notes can become knowledge through live collaboration.
## Why use Docs ❓
Docs is a collaborative text editor designed to address common challenges in knowledge building and sharing.
It offers a scalable and secure alternative to tools such as Google Docs, Notion (without the dbs), Outline, or Confluence.
### Write
* 😌 Get simple, accessible online editing for your team.
* 💅 Create clean documents with beautiful formatting options.
+1
View File
@@ -24,5 +24,6 @@ services:
restart: unless-stopped
env_file:
- env.d/development/common
- env.d/development/common.local
ports:
- "4444:4444"
+8
View File
@@ -10,6 +10,7 @@ services:
retries: 300
env_file:
- env.d/development/postgresql
- env.d/development/postgresql.local
ports:
- "15432:5432"
@@ -66,7 +67,9 @@ services:
- DJANGO_CONFIGURATION=Development
env_file:
- env.d/development/common
- env.d/development/common.local
- env.d/development/postgresql
- env.d/development/postgresql.local
ports:
- "8071:8000"
volumes:
@@ -91,7 +94,9 @@ services:
- DJANGO_CONFIGURATION=Development
env_file:
- env.d/development/common
- env.d/development/common.local
- env.d/development/postgresql
- env.d/development/postgresql.local
volumes:
- ./src/backend:/app
- ./data/static:/data/static
@@ -135,6 +140,7 @@ services:
- ".:/app"
env_file:
- env.d/development/crowdin
- env.d/development/crowdin.local
user: "${DOCKER_USER:-1000}"
working_dir: /app
@@ -156,6 +162,7 @@ services:
restart: unless-stopped
env_file:
- env.d/development/common
- env.d/development/common.local
ports:
- "4444:4444"
volumes:
@@ -174,6 +181,7 @@ services:
- "5433:5432"
env_file:
- env.d/development/kc_postgresql
- env.d/development/kc_postgresql.local
keycloak:
image: quay.io/keycloak/keycloak:20.0.1
+101 -100
View File
@@ -6,104 +6,104 @@ Here we describe all environment variables that can be set for the docs applicat
These are the environment variables you can set for the `impress-backend` container.
| Option | Description | default |
| ----------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| AI_ALLOW_REACH_FROM | Users that can use AI must be this level. options are "public", "authenticated", "restricted" | authenticated |
| AI_API_KEY | AI key to be used for AI Base url | |
| AI_BASE_URL | OpenAI compatible AI base url | |
| AI_FEATURE_ENABLED | Enable AI options | false |
| AI_MODEL | AI Model to use | |
| ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true |
| API_USERS_LIST_LIMIT | Limit on API users | 5 |
| API_USERS_LIST_THROTTLE_RATE_BURST | throttle rate for api on burst | 30/minute |
| API_USERS_LIST_THROTTLE_RATE_SUSTAINED | throttle rate for api | 180/hour |
| AWS_S3_ACCESS_KEY_ID | access id for s3 endpoint | |
| AWS_S3_ENDPOINT_URL | S3 endpoint | |
| AWS_S3_REGION_NAME | region name for s3 endpoint | |
| AWS_S3_SECRET_ACCESS_KEY | access key for s3 endpoint | |
| AWS_STORAGE_BUCKET_NAME | bucket name for s3 endpoint | impress-media-storage |
| CACHES_DEFAULT_TIMEOUT | cache default timeout | 30 |
| CACHES_KEY_PREFIX | The prefix used to every cache keys. | docs |
| COLLABORATION_API_URL | collaboration api host | |
| COLLABORATION_SERVER_SECRET | collaboration api secret | |
| COLLABORATION_WS_NOT_CONNECTED_READY_ONLY | Users not connected to the collaboration server cannot edit | false |
| COLLABORATION_WS_URL | collaboration websocket url | |
| CONVERSION_API_CONTENT_FIELD | Conversion api content field | content |
| CONVERSION_API_ENDPOINT | Conversion API endpoint | convert-markdown |
| CONVERSION_API_SECURE | Require secure conversion api | false |
| CONVERSION_API_TIMEOUT | Conversion api timeout | 30 |
| CONTENT_SECURITY_POLICY_DIRECTIVES | A dict of directives set in the Content-Security-Policy header | All directives are set to 'none' |
| CONTENT_SECURITY_POLICY_EXCLUDE_URL_PREFIXES | Url with this prefix will not have the header Content-Security-Policy included | |
| CRISP_WEBSITE_ID | crisp website id for support | |
| DB_ENGINE | engine to use for database connections | django.db.backends.postgresql_psycopg2 |
| DB_HOST | host of the database | localhost |
| DB_NAME | name of the database | impress |
| DB_PASSWORD | password to authenticate with | pass |
| DB_PORT | port of the database | 5432 |
| DB_USER | user to authenticate with | dinum |
| DJANGO_ALLOWED_HOSTS | allowed hosts | [] |
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | celery broker transport options | {} |
| DJANGO_CELERY_BROKER_URL | celery broker url | redis://redis:6379/0 |
| DJANGO_CORS_ALLOW_ALL_ORIGINS | allow all CORS origins | false |
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | list of origins allowed for CORS using regulair expressions | [] |
| DJANGO_CORS_ALLOWED_ORIGINS | list of origins allowed for CORS | [] |
| DJANGO_CSRF_TRUSTED_ORIGINS | CSRF trusted origins | [] |
| DJANGO_EMAIL_BACKEND | email backend library | django.core.mail.backends.smtp.EmailBackend |
| DJANGO_EMAIL_BRAND_NAME | brand name for email | |
| DJANGO_EMAIL_FROM | email address used as sender | from@example.com |
| DJANGO_EMAIL_HOST | host name of email | |
| DJANGO_EMAIL_HOST_PASSWORD | password to authenticate with on the email host | |
| DJANGO_EMAIL_HOST_USER | user to authenticate with on the email host | |
| DJANGO_EMAIL_LOGO_IMG | logo for the email | |
| DJANGO_EMAIL_PORT | port used to connect to email host | |
| DJANGO_EMAIL_USE_SSL | use sstl for email host connection | false |
| DJANGO_EMAIL_USE_TLS | use tls for email host connection | false |
| DJANGO_SECRET_KEY | secret key | |
| DJANGO_SERVER_TO_SERVER_API_TOKENS | | [] |
| DOCUMENT_IMAGE_MAX_SIZE | maximum size of document in bytes | 10485760 |
| FRONTEND_CSS_URL | To add a external css file to the app | |
| FRONTEND_HOMEPAGE_FEATURE_ENABLED | frontend feature flag to display the homepage | false |
| FRONTEND_THEME | frontend theme to use | |
| LANGUAGE_CODE | default language | en-us |
| LOGGING_LEVEL_LOGGERS_APP | application logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
| LOGGING_LEVEL_LOGGERS_ROOT | default logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
| LOGIN_REDIRECT_URL | login redirect url | |
| LOGIN_REDIRECT_URL_FAILURE | login redirect url on failure | |
| LOGOUT_REDIRECT_URL | logout redirect url | |
| MALWARE_DETECTION_BACKEND | The malware detection backend use from the django-lasuite package | lasuite.malware_detection.backends.dummy.DummyBackend |
| MALWARE_DETECTION_PARAMETERS | A dict containing all the parameters to initiate the malware detection backend | {"callback_path": "core.malware_detection.malware_detection_callback",} |
| MEDIA_BASE_URL | | |
| OIDC_ALLOW_DUPLICATE_EMAILS | Allow duplicate emails | false |
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth parameters | {} |
| OIDC_CREATE_USER | create used on OIDC | false |
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | faillback to email for identification | true |
| OIDC_OP_AUTHORIZATION_ENDPOINT | Authorization endpoint for OIDC | |
| OIDC_OP_JWKS_ENDPOINT | JWKS endpoint for OIDC | |
| OIDC_OP_LOGOUT_ENDPOINT | Logout endpoint for OIDC | |
| OIDC_OP_TOKEN_ENDPOINT | Token endpoint for OIDC | |
| OIDC_OP_USER_ENDPOINT | User endpoint for OIDC | |
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed hosts for OIDC redirect url | [] |
| OIDC_REDIRECT_REQUIRE_HTTPS | Require https for OIDC redirect url | false |
| OIDC_RP_CLIENT_ID | client id used for OIDC | impress |
| OIDC_RP_CLIENT_SECRET | client secret used for OIDC | |
| OIDC_RP_SCOPES | scopes requested for OIDC | openid email |
| OIDC_RP_SIGN_ALGO | verification algorithm used OIDC tokens | RS256 |
| OIDC_STORE_ID_TOKEN | Store OIDC token | true |
| OIDC_USE_NONCE | use nonce for OIDC | true |
| 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 |
| POSTHOG_KEY | posthog key for analytics | |
| REDIS_URL | cache url | redis://redis:6379/1 |
| SENTRY_DSN | sentry host | |
| SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 |
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | | false |
| STORAGES_STATICFILES_BACKEND | | whitenoise.storage.CompressedManifestStaticFilesStorage |
| THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 |
| THEME_CUSTOMIZATION_FILE_PATH | full path to the file customizing the theme. An example is provided in src/backend/impress/configuration/theme/default.json | BASE_DIR/impress/configuration/theme/default.json |
| TRASHBIN_CUTOFF_DAYS | trashbin cutoff | 30 |
| USER_OIDC_ESSENTIAL_CLAIMS | essential claims in OIDC token | [] |
| Y_PROVIDER_API_BASE_URL | Y Provider url | |
| Y_PROVIDER_API_KEY | Y provider API key | |
| Option | Description | default |
|-------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------|
| AI_ALLOW_REACH_FROM | Users that can use AI must be this level. options are "public", "authenticated", "restricted" | authenticated |
| AI_API_KEY | AI key to be used for AI Base url | |
| AI_BASE_URL | OpenAI compatible AI base url | |
| AI_FEATURE_ENABLED | Enable AI options | false |
| AI_MODEL | AI Model to use | |
| ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true |
| API_USERS_LIST_LIMIT | Limit on API users | 5 |
| API_USERS_LIST_THROTTLE_RATE_BURST | Throttle rate for api on burst | 30/minute |
| API_USERS_LIST_THROTTLE_RATE_SUSTAINED | Throttle rate for api | 180/hour |
| AWS_S3_ACCESS_KEY_ID | Access id for s3 endpoint | |
| AWS_S3_ENDPOINT_URL | S3 endpoint | |
| AWS_S3_REGION_NAME | Region name for s3 endpoint | |
| AWS_S3_SECRET_ACCESS_KEY | Access key for s3 endpoint | |
| AWS_STORAGE_BUCKET_NAME | Bucket name for s3 endpoint | impress-media-storage |
| CACHES_DEFAULT_TIMEOUT | Cache default timeout | 30 |
| CACHES_KEY_PREFIX | The prefix used to every cache keys. | docs |
| COLLABORATION_API_URL | Collaboration api host | |
| COLLABORATION_SERVER_SECRET | Collaboration api secret | |
| COLLABORATION_WS_NOT_CONNECTED_READY_ONLY | Users not connected to the collaboration server cannot edit | false |
| COLLABORATION_WS_URL | Collaboration websocket url | |
| CONVERSION_API_CONTENT_FIELD | Conversion api content field | content |
| CONVERSION_API_ENDPOINT | Conversion API endpoint | convert |
| CONVERSION_API_SECURE | Require secure conversion api | false |
| CONVERSION_API_TIMEOUT | Conversion api timeout | 30 |
| CRISP_WEBSITE_ID | Crisp website id for support | |
| DB_ENGINE | Engine to use for database connections | django.db.backends.postgresql_psycopg2 |
| DB_HOST | Host of the database | localhost |
| DB_NAME | Name of the database | impress |
| DB_PASSWORD | Password to authenticate with | pass |
| DB_PORT | Port of the database | 5432 |
| DB_USER | User to authenticate with | dinum |
| DJANGO_ALLOWED_HOSTS | Allowed hosts | [] |
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker transport options | {} |
| DJANGO_CELERY_BROKER_URL | Celery broker url | redis://redis:6379/0 |
| DJANGO_CORS_ALLOW_ALL_ORIGINS | Allow all CORS origins | false |
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | List of origins allowed for CORS using regulair expressions | [] |
| DJANGO_CORS_ALLOWED_ORIGINS | List of origins allowed for CORS | [] |
| DJANGO_CSRF_TRUSTED_ORIGINS | CSRF trusted origins | [] |
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
| DJANGO_EMAIL_BRAND_NAME | Brand name for email | |
| DJANGO_EMAIL_FROM | Email address used as sender | from@example.com |
| DJANGO_EMAIL_HOST | Hostname of email | |
| DJANGO_EMAIL_HOST_PASSWORD | Password to authenticate with on the email host | |
| DJANGO_EMAIL_HOST_USER | User to authenticate with on the email host | |
| DJANGO_EMAIL_LOGO_IMG | Logo for the email | |
| DJANGO_EMAIL_PORT | Port used to connect to email host | |
| DJANGO_EMAIL_USE_SSL | Use ssl for email host connection | false |
| DJANGO_EMAIL_USE_TLS | Use tls for email host connection | false |
| DJANGO_SECRET_KEY | Secret key | |
| DJANGO_SERVER_TO_SERVER_API_TOKENS | | [] |
| DOCUMENT_IMAGE_MAX_SIZE | Maximum size of document in bytes | 10485760 |
| FRONTEND_CSS_URL | To add a external css file to the app | |
| FRONTEND_HOMEPAGE_FEATURE_ENABLED | Frontend feature flag to display the homepage | false |
| FRONTEND_THEME | Frontend theme to use | |
| LANGUAGE_CODE | Default language | en-us |
| LOGGING_LEVEL_LOGGERS_APP | Application logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
| LOGGING_LEVEL_LOGGERS_ROOT | Default logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
| LOGIN_REDIRECT_URL | Login redirect url | |
| LOGIN_REDIRECT_URL_FAILURE | Login redirect url on failure | |
| LOGOUT_REDIRECT_URL | Logout redirect url | |
| MALWARE_DETECTION_BACKEND | The malware detection backend use from the django-lasuite package | lasuite.malware_detection.backends.dummy.DummyBackend |
| MALWARE_DETECTION_PARAMETERS | A dict containing all the parameters to initiate the malware detection backend | {"callback_path": "core.malware_detection.malware_detection_callback",} |
| MEDIA_BASE_URL | | |
| NO_WEBSOCKET_CACHE_TIMEOUT | Cache used to store current editor session key when only users without websocket are editing a document | 120 |
| OIDC_ALLOW_DUPLICATE_EMAILS | Allow duplicate emails | false |
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth parameters | {} |
| OIDC_CREATE_USER | Create used on OIDC | false |
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | true |
| OIDC_OP_AUTHORIZATION_ENDPOINT | Authorization endpoint for OIDC | |
| OIDC_OP_JWKS_ENDPOINT | JWKS endpoint for OIDC | |
| OIDC_OP_LOGOUT_ENDPOINT | Logout endpoint for OIDC | |
| OIDC_OP_TOKEN_ENDPOINT | Token endpoint for OIDC | |
| OIDC_OP_USER_ENDPOINT | User endpoint for OIDC | |
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed hosts for OIDC redirect url | [] |
| OIDC_REDIRECT_REQUIRE_HTTPS | Require https for OIDC redirect url | false |
| OIDC_RP_CLIENT_ID | Client id used for OIDC | impress |
| OIDC_RP_CLIENT_SECRET | Client secret used for OIDC | |
| OIDC_RP_SCOPES | Scopes requested for OIDC | openid email |
| OIDC_RP_SIGN_ALGO | verification algorithm used OIDC tokens | RS256 |
| OIDC_STORE_ID_TOKEN | Store OIDC token | true |
| OIDC_USE_NONCE | Use nonce for OIDC | true |
| 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 |
| POSTHOG_KEY | Posthog key for analytics | |
| REDIS_URL | Cache url | redis://redis:6379/1 |
| SENTRY_DSN | Sentry host | |
| SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 |
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | | false |
| STORAGES_STATICFILES_BACKEND | | whitenoise.storage.CompressedManifestStaticFilesStorage |
| THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 |
| THEME_CUSTOMIZATION_FILE_PATH | Full path to the file customizing the theme. An example is provided in src/backend/impress/configuration/theme/default.json | BASE_DIR/impress/configuration/theme/default.json |
| TRASHBIN_CUTOFF_DAYS | Trashbin cutoff | 30 |
| USER_OIDC_ESSENTIAL_CLAIMS | Essential claims in OIDC token | [] |
| Y_PROVIDER_API_BASE_URL | Y Provider url | |
| Y_PROVIDER_API_KEY | Y provider API key | |
## impress-frontend image
@@ -136,9 +136,10 @@ NODE_ENV=production NEXT_PUBLIC_PUBLISH_AS_MIT=false yarn build
Packages with licences incompatible with the MIT licence:
* `xl-docx-exporter`: [AGPL-3.0](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-docx-exporter/LICENSE),
* `xl-pdf-exporter`: [AGPL-3.0](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE)
* `xl-pdf-exporter`: [AGPL-3.0](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE),
* `xl-multi-column`: [AGPL-3.0](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-multi-column/LICENSE).
In `.env.development`, `PUBLISH_AS_MIT` is set to `false`, allowing developers to test Docs with all its features.
⚠️ If you run Docs in production with `PUBLISH_AS_MIT` set to `false` make sure you fulfill your [BlockNote licensing](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE) or [subscription](https://www.blocknotejs.org/about#partner-with-us) obligations.
⚠️ If you run Docs in production with `PUBLISH_AS_MIT` set to `false` make sure you fulfill your BlockNote licensing or [subscription](https://www.blocknotejs.org/about#partner-with-us) obligations.
+1 -1
View File
@@ -1,6 +1,6 @@
# Installation with docker compose
We provide a sample configuration for running Docs using Docker Compose. Please note that this configuration is experimental, and the official way to deploy Docs in production is to use [k8s](../installation/k8s.md)
We provide a sample configuration for running Docs using Docker Compose. Please note that this configuration is experimental, and the official way to deploy Docs in production is to use [k8s](../installation/kubernetes.md)
## Requirements
@@ -60,6 +60,7 @@ COLLABORATION_API_URL=http://y-provider-development:4444/collaboration/api/
COLLABORATION_BACKEND_BASE_URL=http://app-dev:8000
COLLABORATION_SERVER_ORIGIN=http://localhost:3000
COLLABORATION_SERVER_SECRET=my-secret
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY=true
COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/
DJANGO_SERVER_TO_SERVER_API_TOKENS=server-api-token
@@ -2,4 +2,4 @@
BURST_THROTTLE_RATES="200/minute"
COLLABORATION_API_URL=http://y-provider:4444/collaboration/api/
SUSTAINED_THROTTLE_RATES="200/hour"
Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/
Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/
+22
View File
@@ -60,6 +60,9 @@ class ListDocumentFilter(DocumentFilter):
is_creator_me = django_filters.BooleanFilter(
method="filter_is_creator_me", label=_("Creator is me")
)
is_masked = django_filters.BooleanFilter(
method="filter_is_masked", label=_("Masked")
)
is_favorite = django_filters.BooleanFilter(
method="filter_is_favorite", label=_("Favorite")
)
@@ -106,3 +109,22 @@ class ListDocumentFilter(DocumentFilter):
return queryset
return queryset.filter(is_favorite=bool(value))
# pylint: disable=unused-argument
def filter_is_masked(self, queryset, name, value):
"""
Filter documents based on whether they are masked by the current user.
Example:
- /api/v1.0/documents/?is_masked=true
→ Filters documents marked as masked by the logged-in user
- /api/v1.0/documents/?is_masked=false
→ Filters documents not marked as masked by the logged-in user
"""
user = self.request.user
if not user.is_authenticated:
return queryset
queryset_method = queryset.filter if bool(value) else queryset.exclude
return queryset_method(link_traces__user=user, link_traces__is_masked=True)
+46 -9
View File
@@ -6,6 +6,7 @@ from django.http import Http404
from rest_framework import permissions
from core import choices
from core.models import DocumentAccess, RoleChoices, get_trashbin_cutoff
ACTION_FOR_METHOD_TO_PERMISSION = {
@@ -96,26 +97,27 @@ class CanCreateInvitationPermission(permissions.BasePermission):
).exists()
class AccessPermission(permissions.BasePermission):
"""Permission class for access objects."""
class ResourceWithAccessPermission(permissions.BasePermission):
"""A permission class for templates and invitations."""
def has_permission(self, request, view):
"""check create permission for templates."""
return request.user.is_authenticated or view.action != "create"
def has_object_permission(self, request, view, obj):
"""Check permission for a given object."""
abilities = obj.get_abilities(request.user)
action = view.action
try:
action = ACTION_FOR_METHOD_TO_PERMISSION[view.action][request.method]
except KeyError:
pass
return abilities.get(action, False)
class DocumentAccessPermission(AccessPermission):
class DocumentPermission(permissions.BasePermission):
"""Subclass to handle soft deletion specificities."""
def has_permission(self, request, view):
"""check create permission for documents."""
return request.user.is_authenticated or view.action != "create"
def has_object_permission(self, request, view, obj):
"""
Return a 404 on deleted documents
@@ -127,10 +129,45 @@ class DocumentAccessPermission(AccessPermission):
) and deleted_at < get_trashbin_cutoff():
raise Http404
# Compute permission first to ensure the "user_roles" attribute is set
has_permission = super().has_object_permission(request, view, obj)
abilities = obj.get_abilities(request.user)
action = view.action
try:
action = ACTION_FOR_METHOD_TO_PERMISSION[view.action][request.method]
except KeyError:
pass
has_permission = abilities.get(action, False)
if obj.ancestors_deleted_at and not RoleChoices.OWNER in obj.user_roles:
raise Http404
return has_permission
class ResourceAccessPermission(IsAuthenticated):
"""Permission class for document access objects."""
def has_permission(self, request, view):
"""check create permission for accesses in documents tree."""
if super().has_permission(request, view) is False:
return False
if view.action == "create":
role = getattr(view, view.resource_field_name).get_role(request.user)
if role not in choices.PRIVILEGED_ROLES:
raise exceptions.PermissionDenied(
"You are not allowed to manage accesses for this resource."
)
return True
def has_object_permission(self, request, view, obj):
"""Check permission for a given object."""
abilities = obj.get_abilities(request.user)
requested_role = request.data.get("role")
if requested_role and requested_role not in abilities.get("set_role_to", []):
return False
action = view.action
return abilities.get(action, False)
+161 -140
View File
@@ -10,9 +10,9 @@ from django.utils.functional import lazy
from django.utils.translation import gettext_lazy as _
import magic
from rest_framework import exceptions, serializers
from rest_framework import serializers
from core import enums, models, utils
from core import choices, enums, models, utils
from core.services.ai_services import AI_ACTIONS
from core.services.converter_services import (
ConversionError,
@@ -32,134 +32,35 @@ class UserSerializer(serializers.ModelSerializer):
class UserLightSerializer(UserSerializer):
"""Serialize users with limited fields."""
id = serializers.SerializerMethodField(read_only=True)
email = serializers.SerializerMethodField(read_only=True)
def get_id(self, _user):
"""Return always None. Here to have the same fields than in UserSerializer."""
return None
def get_email(self, _user):
"""Return always None. Here to have the same fields than in UserSerializer."""
return None
class Meta:
model = models.User
fields = ["id", "email", "full_name", "short_name"]
read_only_fields = ["id", "email", "full_name", "short_name"]
fields = ["full_name", "short_name"]
read_only_fields = ["full_name", "short_name"]
class BaseAccessSerializer(serializers.ModelSerializer):
class TemplateAccessSerializer(serializers.ModelSerializer):
"""Serialize template accesses."""
abilities = serializers.SerializerMethodField(read_only=True)
def update(self, instance, validated_data):
"""Make "user" field is readonly but only on update."""
validated_data.pop("user", None)
return super().update(instance, validated_data)
def get_abilities(self, access) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
return access.get_abilities(request.user)
return {}
def validate(self, attrs):
"""
Check access rights specific to writing (create/update)
"""
request = self.context.get("request")
user = getattr(request, "user", None)
role = attrs.get("role")
# Update
if self.instance:
can_set_role_to = self.instance.get_abilities(user)["set_role_to"]
if role and role not in can_set_role_to:
message = (
f"You are only allowed to set role to {', '.join(can_set_role_to)}"
if can_set_role_to
else "You are not allowed to set this role for this template."
)
raise exceptions.PermissionDenied(message)
# Create
else:
try:
resource_id = self.context["resource_id"]
except KeyError as exc:
raise exceptions.ValidationError(
"You must set a resource ID in kwargs to create a new access."
) from exc
if not self.Meta.model.objects.filter( # pylint: disable=no-member
Q(user=user) | Q(team__in=user.teams),
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
**{self.Meta.resource_field_name: resource_id}, # pylint: disable=no-member
).exists():
raise exceptions.PermissionDenied(
"You are not allowed to manage accesses for this resource."
)
if (
role == models.RoleChoices.OWNER
and not self.Meta.model.objects.filter( # pylint: disable=no-member
Q(user=user) | Q(team__in=user.teams),
role=models.RoleChoices.OWNER,
**{self.Meta.resource_field_name: resource_id}, # pylint: disable=no-member
).exists()
):
raise exceptions.PermissionDenied(
"Only owners of a resource can assign other users as owners."
)
# pylint: disable=no-member
attrs[f"{self.Meta.resource_field_name}_id"] = self.context["resource_id"]
return attrs
class DocumentAccessSerializer(BaseAccessSerializer):
"""Serialize document accesses."""
user_id = serializers.PrimaryKeyRelatedField(
queryset=models.User.objects.all(),
write_only=True,
source="user",
required=False,
allow_null=True,
)
user = UserSerializer(read_only=True)
class Meta:
model = models.DocumentAccess
resource_field_name = "document"
fields = ["id", "user", "user_id", "team", "role", "abilities"]
read_only_fields = ["id", "abilities"]
class DocumentAccessLightSerializer(DocumentAccessSerializer):
"""Serialize document accesses with limited fields."""
user = UserLightSerializer(read_only=True)
class Meta:
model = models.DocumentAccess
fields = ["id", "user", "team", "role", "abilities"]
read_only_fields = ["id", "team", "role", "abilities"]
class TemplateAccessSerializer(BaseAccessSerializer):
"""Serialize template accesses."""
class Meta:
model = models.TemplateAccess
resource_field_name = "template"
fields = ["id", "user", "team", "role", "abilities"]
read_only_fields = ["id", "abilities"]
def get_abilities(self, instance) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
return instance.get_abilities(request.user)
return {}
def update(self, instance, validated_data):
"""Make "user" field is readonly but only on update."""
validated_data.pop("user", None)
return super().update(instance, validated_data)
class ListDocumentSerializer(serializers.ModelSerializer):
"""Serialize documents with limited fields for display in lists."""
@@ -167,7 +68,7 @@ class ListDocumentSerializer(serializers.ModelSerializer):
is_favorite = serializers.BooleanField(read_only=True)
nb_accesses_ancestors = serializers.IntegerField(read_only=True)
nb_accesses_direct = serializers.IntegerField(read_only=True)
user_roles = serializers.SerializerMethodField(read_only=True)
user_role = serializers.SerializerMethodField(read_only=True)
abilities = serializers.SerializerMethodField(read_only=True)
class Meta:
@@ -175,6 +76,10 @@ class ListDocumentSerializer(serializers.ModelSerializer):
fields = [
"id",
"abilities",
"ancestors_link_reach",
"ancestors_link_role",
"computed_link_reach",
"computed_link_role",
"created_at",
"creator",
"depth",
@@ -188,11 +93,15 @@ class ListDocumentSerializer(serializers.ModelSerializer):
"path",
"title",
"updated_at",
"user_roles",
"user_role",
]
read_only_fields = [
"id",
"abilities",
"ancestors_link_reach",
"ancestors_link_role",
"computed_link_reach",
"computed_link_role",
"created_at",
"creator",
"depth",
@@ -205,46 +114,62 @@ class ListDocumentSerializer(serializers.ModelSerializer):
"numchild",
"path",
"updated_at",
"user_roles",
"user_role",
]
def get_abilities(self, document) -> dict:
def to_representation(self, instance):
"""Precompute once per instance"""
paths_links_mapping = self.context.get("paths_links_mapping")
if paths_links_mapping is not None:
links = paths_links_mapping.get(instance.path[: -instance.steplen], [])
instance.ancestors_link_definition = choices.get_equivalent_link_definition(
links
)
return super().to_representation(instance)
def get_abilities(self, instance) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if not request:
return {}
if request:
paths_links_mapping = self.context.get("paths_links_mapping", None)
# Retrieve ancestor links from paths_links_mapping (if provided)
ancestors_links = (
paths_links_mapping.get(document.path[: -document.steplen])
if paths_links_mapping
else None
)
return document.get_abilities(request.user, ancestors_links=ancestors_links)
return instance.get_abilities(request.user)
return {}
def get_user_roles(self, document):
def get_user_role(self, instance):
"""
Return roles of the logged-in user for the current document,
taking into account ancestors.
"""
request = self.context.get("request")
if request:
return document.get_roles(request.user)
return []
return instance.get_role(request.user) if request else None
class DocumentLightSerializer(serializers.ModelSerializer):
"""Minial document serializer for nesting in document accesses."""
class Meta:
model = models.Document
fields = ["id", "path", "depth"]
read_only_fields = ["id", "path", "depth"]
class DocumentSerializer(ListDocumentSerializer):
"""Serialize documents with all fields for display in detail views."""
content = serializers.CharField(required=False)
websocket = serializers.BooleanField(required=False, write_only=True)
class Meta:
model = models.Document
fields = [
"id",
"abilities",
"ancestors_link_reach",
"ancestors_link_role",
"computed_link_reach",
"computed_link_role",
"content",
"created_at",
"creator",
@@ -259,11 +184,16 @@ class DocumentSerializer(ListDocumentSerializer):
"path",
"title",
"updated_at",
"user_roles",
"user_role",
"websocket",
]
read_only_fields = [
"id",
"abilities",
"ancestors_link_reach",
"ancestors_link_role",
"computed_link_reach",
"computed_link_role",
"created_at",
"creator",
"depth",
@@ -275,7 +205,7 @@ class DocumentSerializer(ListDocumentSerializer):
"numchild",
"path",
"updated_at",
"user_roles",
"user_role",
]
def get_fields(self):
@@ -361,6 +291,99 @@ class DocumentSerializer(ListDocumentSerializer):
return super().save(**kwargs)
class DocumentAccessSerializer(serializers.ModelSerializer):
"""Serialize document accesses."""
document = DocumentLightSerializer(read_only=True)
user_id = serializers.PrimaryKeyRelatedField(
queryset=models.User.objects.all(),
write_only=True,
source="user",
required=False,
allow_null=True,
)
user = UserSerializer(read_only=True)
team = serializers.CharField(required=False, allow_blank=True)
abilities = serializers.SerializerMethodField(read_only=True)
max_ancestors_role = serializers.SerializerMethodField(read_only=True)
max_role = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.DocumentAccess
resource_field_name = "document"
fields = [
"id",
"document",
"user",
"user_id",
"team",
"role",
"abilities",
"max_ancestors_role",
"max_role",
]
read_only_fields = [
"id",
"document",
"abilities",
"max_ancestors_role",
"max_role",
]
def get_abilities(self, instance) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
return instance.get_abilities(request.user)
return {}
def get_max_ancestors_role(self, instance):
"""Return max_ancestors_role if annotated; else None."""
return getattr(instance, "max_ancestors_role", None)
def get_max_role(self, instance):
"""Return max_ancestors_role if annotated; else None."""
return choices.RoleChoices.max(
getattr(instance, "max_ancestors_role", None),
instance.role,
)
def update(self, instance, validated_data):
"""Make "user" field readonly but only on update."""
validated_data.pop("team", None)
validated_data.pop("user", None)
return super().update(instance, validated_data)
class DocumentAccessLightSerializer(DocumentAccessSerializer):
"""Serialize document accesses with limited fields."""
user = UserLightSerializer(read_only=True)
class Meta:
model = models.DocumentAccess
resource_field_name = "document"
fields = [
"id",
"document",
"user",
"team",
"role",
"abilities",
"max_ancestors_role",
"max_role",
]
read_only_fields = [
"id",
"document",
"team",
"role",
"abilities",
"max_ancestors_role",
"max_role",
]
class ServerCreateDocumentSerializer(serializers.Serializer):
"""
Serializer for creating a document from a server-to-server request.
@@ -408,9 +431,7 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
language = user.language or language
try:
document_content = YdocConverter().convert_markdown(
validated_data["content"]
)
document_content = YdocConverter().convert(validated_data["content"])
except ConversionError as err:
raise serializers.ValidationError(
{"content": ["Could not convert content"]}
+409 -257
View File
@@ -4,11 +4,11 @@
import json
import logging
import uuid
from collections import defaultdict
from urllib.parse import unquote, urlencode, urlparse
from django.conf import settings
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.contrib.postgres.search import TrigramSimilarity
from django.core.cache import cache
from django.core.exceptions import ValidationError
@@ -19,6 +19,7 @@ from django.db.models.expressions import RawSQL
from django.db.models.functions import Left, Length
from django.http import Http404, StreamingHttpResponse
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.text import capfirst, slugify
from django.utils.translation import gettext_lazy as _
@@ -33,7 +34,7 @@ from rest_framework import response as drf_response
from rest_framework.permissions import AllowAny
from rest_framework.throttling import UserRateThrottle
from core import authentication, enums, models
from core import authentication, choices, enums, models
from core.services.ai_services import AIService
from core.services.collaboration_services import CollaborationService
from core.tasks.mail import send_ask_for_access_mail
@@ -223,14 +224,10 @@ class UserViewSet(
class ResourceAccessViewsetMixin:
"""Mixin with methods common to all access viewsets."""
def get_permissions(self):
"""User only needs to be authenticated to list resource accesses"""
if self.action == "list":
permission_classes = [permissions.IsAuthenticated]
else:
return super().get_permissions()
return [permission() for permission in permission_classes]
def filter_queryset(self, queryset):
"""Override to filter on related resource."""
queryset = super().filter_queryset(queryset)
return queryset.filter(**{self.resource_field_name: self.kwargs["resource_id"]})
def get_serializer_context(self):
"""Extra context provided to the serializer class."""
@@ -238,80 +235,6 @@ class ResourceAccessViewsetMixin:
context["resource_id"] = self.kwargs["resource_id"]
return context
def get_queryset(self):
"""Return the queryset according to the action."""
queryset = super().get_queryset()
queryset = queryset.filter(
**{self.resource_field_name: self.kwargs["resource_id"]}
)
if self.action == "list":
user = self.request.user
teams = user.teams
user_roles_query = (
queryset.filter(
db.Q(user=user) | db.Q(team__in=teams),
**{self.resource_field_name: self.kwargs["resource_id"]},
)
.values(self.resource_field_name)
.annotate(roles_array=ArrayAgg("role"))
.values("roles_array")
)
# Limit to resource access instances related to a resource THAT also has
# a resource access
# instance for the logged-in user (we don't want to list only the resource
# access instances pointing to the logged-in user)
queryset = (
queryset.filter(
db.Q(**{f"{self.resource_field_name}__accesses__user": user})
| db.Q(
**{f"{self.resource_field_name}__accesses__team__in": teams}
),
**{self.resource_field_name: self.kwargs["resource_id"]},
)
.annotate(user_roles=db.Subquery(user_roles_query))
.distinct()
)
return queryset
def destroy(self, request, *args, **kwargs):
"""Forbid deleting the last owner access"""
instance = self.get_object()
resource = getattr(instance, self.resource_field_name)
# Check if the access being deleted is the last owner access for the resource
if (
instance.role == "owner"
and resource.accesses.filter(role="owner").count() == 1
):
return drf.response.Response(
{"detail": "Cannot delete the last owner access for the resource."},
status=drf.status.HTTP_403_FORBIDDEN,
)
return super().destroy(request, *args, **kwargs)
def perform_update(self, serializer):
"""Check that we don't change the role if it leads to losing the last owner."""
instance = serializer.instance
# Check if the role is being updated and the new role is not "owner"
if (
"role" in self.request.data
and self.request.data["role"] != models.RoleChoices.OWNER
):
resource = getattr(instance, self.resource_field_name)
# Check if the access being updated is the last owner access for the resource
if (
instance.role == models.RoleChoices.OWNER
and resource.accesses.filter(role=models.RoleChoices.OWNER).count() == 1
):
message = "Cannot change the role to a non-owner role for the last owner access."
raise drf.exceptions.PermissionDenied({"detail": message})
serializer.save()
class DocumentMetadata(drf.metadata.SimpleMetadata):
"""Custom metadata class to add information"""
@@ -434,7 +357,7 @@ class DocumentViewSet(
ordering_fields = ["created_at", "updated_at", "title"]
pagination_class = Pagination
permission_classes = [
permissions.DocumentAccessPermission,
permissions.DocumentPermission,
]
queryset = models.Document.objects.all()
serializer_class = serializers.DocumentSerializer
@@ -445,44 +368,6 @@ class DocumentViewSet(
trashbin_serializer_class = serializers.ListDocumentSerializer
tree_serializer_class = serializers.ListDocumentSerializer
def annotate_is_favorite(self, queryset):
"""
Annotate document queryset with the favorite status for the current user.
"""
user = self.request.user
if user.is_authenticated:
favorite_exists_subquery = models.DocumentFavorite.objects.filter(
document_id=db.OuterRef("pk"), user=user
)
return queryset.annotate(is_favorite=db.Exists(favorite_exists_subquery))
return queryset.annotate(is_favorite=db.Value(False))
def annotate_user_roles(self, queryset):
"""
Annotate document queryset with the roles of the current user
on the document or its ancestors.
"""
user = self.request.user
output_field = ArrayField(base_field=db.CharField())
if user.is_authenticated:
user_roles_subquery = models.DocumentAccess.objects.filter(
db.Q(user=user) | db.Q(team__in=user.teams),
document__path=Left(db.OuterRef("path"), Length("document__path")),
).values_list("role", flat=True)
return queryset.annotate(
user_roles=db.Func(
user_roles_subquery, function="ARRAY", output_field=output_field
)
)
return queryset.annotate(
user_roles=db.Value([], output_field=output_field),
)
def get_queryset(self):
"""Get queryset performing all annotation and filtering on the document tree structure."""
user = self.request.user
@@ -518,18 +403,20 @@ class DocumentViewSet(
def filter_queryset(self, queryset):
"""Override to apply annotations to generic views."""
queryset = super().filter_queryset(queryset)
queryset = self.annotate_is_favorite(queryset)
queryset = self.annotate_user_roles(queryset)
user = self.request.user
queryset = queryset.annotate_is_favorite(user)
queryset = queryset.annotate_user_roles(user)
return queryset
def get_response_for_queryset(self, queryset):
def get_response_for_queryset(self, queryset, context=None):
"""Return paginated response for the queryset if requested."""
context = context or self.get_serializer_context()
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
serializer = self.get_serializer(page, many=True, context=context)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
serializer = self.get_serializer(queryset, many=True, context=context)
return drf.response.Response(serializer.data)
def list(self, request, *args, **kwargs):
@@ -539,13 +426,11 @@ class DocumentViewSet(
This method applies filtering based on request parameters using `ListDocumentFilter`.
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.
Additional annotations (e.g., `is_highest_ancestor_for_user`, favorite status) are
applied before ordering and returning the response.
"""
queryset = (
self.get_queryset()
) # Not calling filter_queryset. We do our own cooking.
user = self.request.user
# Not calling filter_queryset. We do our own cooking.
queryset = self.get_queryset()
filterset = ListDocumentFilter(
self.request.GET, queryset=queryset, request=self.request
@@ -558,7 +443,7 @@ class DocumentViewSet(
for field in ["is_creator_me", "title"]:
queryset = filterset.filters[field].filter(queryset, filter_data[field])
queryset = self.annotate_user_roles(queryset)
queryset = queryset.annotate_user_roles(user)
# Among the results, we may have documents that are ancestors/descendants
# of each other. In this case we want to keep only the highest ancestors.
@@ -568,17 +453,10 @@ class DocumentViewSet(
)
queryset = queryset.filter(path__in=root_paths)
# Annotate the queryset with an attribute marking instances as highest ancestor
# in order to save some time while computing abilities on the instance
queryset = queryset.annotate(
is_highest_ancestor_for_user=db.Value(True, output_field=db.BooleanField())
)
# Annotate favorite status and filter if applicable as late as possible
queryset = self.annotate_is_favorite(queryset)
queryset = filterset.filters["is_favorite"].filter(
queryset, filter_data["is_favorite"]
)
queryset = queryset.annotate_is_favorite(user)
for field in ["is_favorite", "is_masked"]:
queryset = filterset.filters[field].filter(queryset, filter_data[field])
# Apply ordering only now that everything is filtered and annotated
queryset = filters.OrderingFilter().filter_queryset(
@@ -634,6 +512,83 @@ class DocumentViewSet(
"""Override to implement a soft delete instead of dumping the record in database."""
instance.soft_delete()
def _can_user_edit_document(self, document_id, set_cache=False):
"""Check if the user can edit the document."""
try:
count, exists = CollaborationService().get_document_connection_info(
document_id,
self.request.session.session_key,
)
except requests.HTTPError as e:
logger.exception("Failed to call collaboration server: %s", e)
count = 0
exists = False
if count == 0:
# Nobody is connected to the websocket server
logger.debug("update without connection found in the websocket server")
cache_key = f"docs:no-websocket:{document_id}"
current_editor = cache.get(cache_key)
if not current_editor:
if set_cache:
cache.set(
cache_key,
self.request.session.session_key,
settings.NO_WEBSOCKET_CACHE_TIMEOUT,
)
return True
if current_editor != self.request.session.session_key:
return False
if set_cache:
cache.touch(cache_key, settings.NO_WEBSOCKET_CACHE_TIMEOUT)
return True
if exists:
# Current user is connected to the websocket server
logger.debug("session key found in the websocket server")
return True
logger.debug(
"Users connected to the websocket but current editor not connected to it. Can not edit."
)
return False
def perform_update(self, serializer):
"""Check rules about collaboration."""
if (
serializer.validated_data.get("websocket", False)
or not settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY
):
return super().perform_update(serializer)
if self._can_user_edit_document(serializer.instance.id, set_cache=True):
return super().perform_update(serializer)
raise drf.exceptions.PermissionDenied(
"You are not allowed to edit this document."
)
@drf.decorators.action(
detail=True,
methods=["get"],
url_path="can-edit",
)
def can_edit(self, request, *args, **kwargs):
"""Check if the current user can edit the document."""
document = self.get_object()
can_edit = (
True
if not settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY
else self._can_user_edit_document(document.id)
)
return drf.response.Response({"can_edit": can_edit})
@drf.decorators.action(
detail=False,
methods=["get"],
@@ -666,7 +621,7 @@ class DocumentViewSet(
deleted_at__isnull=False,
deleted_at__gte=models.get_trashbin_cutoff(),
)
queryset = self.annotate_user_roles(queryset)
queryset = queryset.annotate_user_roles(self.request.user)
queryset = queryset.filter(user_roles__contains=[models.RoleChoices.OWNER])
return self.get_response_for_queryset(queryset)
@@ -734,7 +689,7 @@ class DocumentViewSet(
position = validated_data["position"]
message = None
owner_accesses = []
if position in [
enums.MoveNodePositionChoices.FIRST_CHILD,
enums.MoveNodePositionChoices.LAST_CHILD,
@@ -744,12 +699,15 @@ class DocumentViewSet(
"You do not have permission to move documents "
"as a child to this target document."
)
elif not target_document.is_root():
if not target_document.get_parent().get_abilities(user).get("move"):
message = (
"You do not have permission to move documents "
"as a sibling of this target document."
)
elif target_document.is_root():
owner_accesses = document.get_root().accesses.filter(
role=models.RoleChoices.OWNER
)
elif not target_document.get_parent().get_abilities(user).get("move"):
message = (
"You do not have permission to move documents "
"as a sibling of this target document."
)
if message:
return drf.response.Response(
@@ -759,6 +717,19 @@ class DocumentViewSet(
document.move(target_document, pos=position)
# Make sure we have at least one owner
if (
owner_accesses
and not document.accesses.filter(role=models.RoleChoices.OWNER).exists()
):
for owner_access in owner_accesses:
models.DocumentAccess.objects.update_or_create(
document=document,
user=owner_access.user,
team=owner_access.team,
defaults={"role": models.RoleChoices.OWNER},
)
return drf.response.Response(
{"message": "Document moved successfully."}, status=status.HTTP_200_OK
)
@@ -805,11 +776,7 @@ class DocumentViewSet(
creator=request.user,
**serializer.validated_data,
)
models.DocumentAccess.objects.create(
document=child_document,
user=request.user,
role=models.RoleChoices.OWNER,
)
# Set the created instance to the serializer
serializer.instance = child_document
@@ -828,7 +795,17 @@ class DocumentViewSet(
queryset = filterset.qs
return self.get_response_for_queryset(queryset)
# Pass ancestors' links paths mapping to the serializer as a context variable
# in order to allow saving time while computing abilities on the instance
paths_links_mapping = document.compute_ancestors_links_paths_mapping()
return self.get_response_for_queryset(
queryset,
context={
"request": request,
"paths_links_mapping": paths_links_mapping,
},
)
@drf.decorators.action(
detail=True,
@@ -860,10 +837,12 @@ class DocumentViewSet(
List ancestors tree above the document.
What we need to display is the tree structure opened for the current document.
"""
user = self.request.user
try:
current_document = self.queryset.only("depth", "path").get(pk=pk)
except models.Document.DoesNotExist as excpt:
raise drf.exceptions.NotFound from excpt
raise drf.exceptions.NotFound() from excpt
ancestors = (
(current_document.get_ancestors() | self.queryset.filter(pk=pk))
@@ -885,13 +864,6 @@ class DocumentViewSet(
ancestors_links = []
children_clause = db.Q()
for ancestor in ancestors:
if ancestor.depth < highest_readable.depth:
continue
children_clause |= db.Q(
path__startswith=ancestor.path, depth=ancestor.depth + 1
)
# Compute cache for ancestors links to avoid many queries while computing
# abilities for his documents in the tree!
ancestors_links.append(
@@ -899,25 +871,21 @@ class DocumentViewSet(
)
paths_links_mapping[ancestor.path] = ancestors_links.copy()
if ancestor.depth < highest_readable.depth:
continue
children_clause |= db.Q(
path__startswith=ancestor.path, depth=ancestor.depth + 1
)
children = self.queryset.filter(children_clause, deleted_at__isnull=True)
queryset = ancestors.filter(depth__gte=highest_readable.depth) | children
queryset = queryset.order_by("path")
# Annotate if the current document is the highest ancestor for the user
queryset = queryset.annotate(
is_highest_ancestor_for_user=db.Case(
db.When(
path=db.Value(highest_readable.path),
then=db.Value(True),
),
default=db.Value(False),
output_field=db.BooleanField(),
)
)
queryset = self.annotate_user_roles(queryset)
queryset = self.annotate_is_favorite(queryset)
queryset = queryset.annotate_user_roles(user)
queryset = queryset.annotate_is_favorite(user)
# Pass ancestors' links definitions to the serializer as a context variable
# Pass ancestors' links paths mapping to the serializer as a context variable
# in order to allow saving time while computing abilities on the instance
serializer = self.get_serializer(
queryset,
@@ -934,7 +902,10 @@ class DocumentViewSet(
@drf.decorators.action(
detail=True,
methods=["post"],
permission_classes=[permissions.IsAuthenticated, permissions.AccessPermission],
permission_classes=[
permissions.IsAuthenticated,
permissions.DocumentPermission,
],
url_path="duplicate",
)
@transaction.atomic
@@ -954,6 +925,7 @@ class DocumentViewSet(
)
serializer.is_valid(raise_exception=True)
with_accesses = serializer.validated_data.get("with_accesses", False)
is_owner_or_admin = document.get_role(request.user) in models.PRIVILEGED_ROLES
base64_yjs_content = document.content
@@ -975,33 +947,34 @@ class DocumentViewSet(
**link_kwargs,
)
# Always add the logged-in user as OWNER
accesses_to_create = [
models.DocumentAccess(
document=duplicated_document,
user=request.user,
role=models.RoleChoices.OWNER,
)
]
# If accesses should be duplicated, add other users' accesses as per original document
if with_accesses:
original_accesses = models.DocumentAccess.objects.filter(
document=document
).exclude(user=request.user)
accesses_to_create.extend(
# Always add the logged-in user as OWNER for root documents
if document.is_root():
accesses_to_create = [
models.DocumentAccess(
document=duplicated_document,
user_id=access.user_id,
team=access.team,
role=access.role,
user=request.user,
role=models.RoleChoices.OWNER,
)
for access in original_accesses
)
]
# Bulk create all the duplicated accesses
models.DocumentAccess.objects.bulk_create(accesses_to_create)
# If accesses should be duplicated, add other users' accesses as per original document
if with_accesses and is_owner_or_admin:
original_accesses = models.DocumentAccess.objects.filter(
document=document
).exclude(user=request.user)
accesses_to_create.extend(
models.DocumentAccess(
document=duplicated_document,
user_id=access.user_id,
team=access.team,
role=access.role,
)
for access in original_accesses
)
# Bulk create all the duplicated accesses
models.DocumentAccess.objects.bulk_create(accesses_to_create)
return drf_response.Response(
{"id": str(duplicated_document.id)}, status=status.HTTP_201_CREATED
@@ -1135,15 +1108,50 @@ class DocumentViewSet(
document=document, user=user
).delete()
if deleted:
return drf.response.Response(
{"detail": "Document unmarked as favorite"},
status=drf.status.HTTP_204_NO_CONTENT,
)
return drf.response.Response(status=drf.status.HTTP_204_NO_CONTENT)
return drf.response.Response(
{"detail": "Document was already not marked as favorite"},
status=drf.status.HTTP_200_OK,
)
@drf.decorators.action(detail=True, methods=["post", "delete"], url_path="mask")
def mask(self, request, *args, **kwargs):
"""Mask or unmask the document for the logged-in user based on the HTTP method."""
# Check permissions first
document = self.get_object()
user = request.user
try:
link_trace = models.LinkTrace.objects.get(document=document, user=user)
except models.LinkTrace.DoesNotExist:
return drf.response.Response(
{"detail": "User never accessed this document before."},
status=status.HTTP_400_BAD_REQUEST,
)
if request.method == "POST":
if link_trace.is_masked:
return drf.response.Response(
{"detail": "Document was already masked"},
status=drf.status.HTTP_200_OK,
)
link_trace.is_masked = True
link_trace.save(update_fields=["is_masked"])
return drf.response.Response(
{"detail": "Document was masked"},
status=drf.status.HTTP_201_CREATED,
)
# Handle DELETE method to unmask document
if not link_trace.is_masked:
return drf.response.Response(
{"detail": "Document was already not masked"},
status=drf.status.HTTP_200_OK,
)
link_trace.is_masked = False
link_trace.save(update_fields=["is_masked"])
return drf.response.Response(status=drf.status.HTTP_204_NO_CONTENT)
@drf.decorators.action(detail=True, methods=["post"], url_path="attachment-upload")
def attachment_upload(self, request, *args, **kwargs):
"""Upload a file related to a given document"""
@@ -1472,7 +1480,11 @@ class DocumentViewSet(
class DocumentAccessViewSet(
ResourceAccessViewsetMixin,
viewsets.ModelViewSet,
drf.mixins.CreateModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.UpdateModelMixin,
drf.mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
"""
API ViewSet for all interactions with document accesses.
@@ -1499,50 +1511,143 @@ class DocumentAccessViewSet(
"""
lookup_field = "pk"
pagination_class = Pagination
permission_classes = [permissions.IsAuthenticated, permissions.AccessPermission]
queryset = models.DocumentAccess.objects.select_related("user").all()
permission_classes = [permissions.ResourceAccessPermission]
queryset = models.DocumentAccess.objects.select_related("user", "document").only(
"id",
"created_at",
"role",
"team",
"user__id",
"user__short_name",
"user__full_name",
"user__email",
"user__language",
"document__id",
"document__path",
"document__depth",
)
resource_field_name = "document"
serializer_class = serializers.DocumentAccessSerializer
is_current_user_owner_or_admin = False
def get_queryset(self):
"""Return the queryset according to the action."""
queryset = super().get_queryset()
if self.action == "list":
try:
document = models.Document.objects.get(pk=self.kwargs["resource_id"])
except models.Document.DoesNotExist:
return queryset.none()
roles = set(document.get_roles(self.request.user))
is_owner_or_admin = bool(roles.intersection(set(models.PRIVILEGED_ROLES)))
self.is_current_user_owner_or_admin = is_owner_or_admin
if not is_owner_or_admin:
# Return only the document owner access
queryset = queryset.filter(role__in=models.PRIVILEGED_ROLES)
return queryset
@cached_property
def document(self):
"""Get related document from resource ID in url and annotate user roles."""
try:
return models.Document.objects.annotate_user_roles(self.request.user).get(
pk=self.kwargs["resource_id"]
)
except models.Document.DoesNotExist as excpt:
raise drf.exceptions.NotFound() from excpt
def get_serializer_class(self):
if self.action == "list" and not self.is_current_user_owner_or_admin:
return serializers.DocumentAccessLightSerializer
"""Use light serializer for unprivileged users."""
return (
serializers.DocumentAccessSerializer
if self.document.get_role(self.request.user) in choices.PRIVILEGED_ROLES
else serializers.DocumentAccessLightSerializer
)
return super().get_serializer_class()
def list(self, request, *args, **kwargs):
"""Return accesses for the current document with filters and annotations."""
user = request.user
role = self.document.get_role(user)
if not role:
return drf.response.Response([])
ancestors = (
self.document.get_ancestors()
| models.Document.objects.filter(pk=self.document.pk)
).filter(ancestors_deleted_at__isnull=True)
queryset = self.get_queryset().filter(document__in=ancestors)
if role not in choices.PRIVILEGED_ROLES:
queryset = queryset.filter(role__in=choices.PRIVILEGED_ROLES)
accesses = list(queryset.order_by("document__path"))
# Annotate more information on roles
path_to_key_to_max_ancestors_role = defaultdict(
lambda: defaultdict(lambda: None)
)
path_to_ancestors_roles = defaultdict(list)
path_to_role = defaultdict(lambda: None)
for access in accesses:
key = access.target_key
path = access.document.path
parent_path = path[: -models.Document.steplen]
path_to_key_to_max_ancestors_role[path][key] = choices.RoleChoices.max(
path_to_key_to_max_ancestors_role[path][key], access.role
)
if parent_path:
path_to_key_to_max_ancestors_role[path][key] = choices.RoleChoices.max(
path_to_key_to_max_ancestors_role[parent_path][key],
path_to_key_to_max_ancestors_role[path][key],
)
path_to_ancestors_roles[path].extend(
path_to_ancestors_roles[parent_path]
)
path_to_ancestors_roles[path].append(path_to_role[parent_path])
else:
path_to_ancestors_roles[path] = []
if access.user_id == user.id or access.team in user.teams:
path_to_role[path] = choices.RoleChoices.max(
path_to_role[path], access.role
)
# serialize and return the response
context = self.get_serializer_context()
serializer_class = self.get_serializer_class()
serialized_data = []
for access in accesses:
path = access.document.path
parent_path = path[: -models.Document.steplen]
access.max_ancestors_role = (
path_to_key_to_max_ancestors_role[parent_path][access.target_key]
if parent_path
else None
)
access.set_user_roles_tuple(
choices.RoleChoices.max(*path_to_ancestors_roles[path]),
path_to_role.get(path),
)
serializer = serializer_class(access, context=context)
serialized_data.append(serializer.data)
return drf.response.Response(serialized_data)
def perform_create(self, serializer):
"""Add a new access to the document and send an email to the new added user."""
access = serializer.save()
"""
Actually create the new document access:
- Ensures the `document_id` is explicitly set from the URL
- If the assigned role is `OWNER`, checks that the requesting user is an owner
of the document. This is the only permission check deferred until this step;
all other access checks are handled earlier in the permission lifecycle.
- Sends an invitation email to the newly added user after saving the access.
"""
role = serializer.validated_data.get("role")
if (
role == choices.RoleChoices.OWNER
and self.document.get_role(self.request.user) != choices.RoleChoices.OWNER
):
raise drf.exceptions.PermissionDenied(
"Only owners of a document can assign other users as owners."
)
access.document.send_invitation_email(
access.user.email,
access.role,
self.request.user,
access.user.language
or self.request.user.language
or settings.LANGUAGE_CODE,
)
access = serializer.save(document_id=self.kwargs["resource_id"])
if access.user:
access.document.send_invitation_email(
access.user.email,
access.role,
self.request.user,
access.user.language
or self.request.user.language
or settings.LANGUAGE_CODE,
)
def perform_update(self, serializer):
"""Update an access to the document and notify the collaboration server."""
@@ -1579,7 +1684,7 @@ class TemplateViewSet(
filter_backends = [drf.filters.OrderingFilter]
permission_classes = [
permissions.IsAuthenticatedOrSafe,
permissions.AccessPermission,
permissions.ResourceWithAccessPermission,
]
ordering = ["-created_at"]
ordering_fields = ["created_at", "updated_at", "title"]
@@ -1641,7 +1746,6 @@ class TemplateAccessViewSet(
ResourceAccessViewsetMixin,
drf.mixins.CreateModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.ListModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.UpdateModelMixin,
viewsets.GenericViewSet,
@@ -1671,12 +1775,55 @@ class TemplateAccessViewSet(
"""
lookup_field = "pk"
pagination_class = Pagination
permission_classes = [permissions.IsAuthenticated, permissions.AccessPermission]
permission_classes = [permissions.ResourceAccessPermission]
queryset = models.TemplateAccess.objects.select_related("user").all()
resource_field_name = "template"
serializer_class = serializers.TemplateAccessSerializer
@cached_property
def template(self):
"""Get related template from resource ID in url."""
try:
return models.Template.objects.get(pk=self.kwargs["resource_id"])
except models.Template.DoesNotExist as excpt:
raise drf.exceptions.NotFound() from excpt
def list(self, request, *args, **kwargs):
"""Restrict templates returned by the list endpoint"""
user = self.request.user
teams = user.teams
queryset = self.filter_queryset(self.get_queryset())
# Limit to resource access instances related to a resource THAT also has
# a resource access instance for the logged-in user (we don't want to list
# only the resource access instances pointing to the logged-in user)
queryset = queryset.filter(
db.Q(template__accesses__user=user)
| db.Q(template__accesses__team__in=teams),
).distinct()
serializer = self.get_serializer(queryset, many=True)
return drf.response.Response(serializer.data)
def perform_create(self, serializer):
"""
Actually create the new template access:
- Ensures the `template_id` is explicitly set from the URL.
- If the assigned role is `OWNER`, checks that the requesting user is an owner
of the document. This is the only permission check deferred until this step;
all other access checks are handled earlier in the permission lifecycle.
"""
role = serializer.validated_data.get("role")
if (
role == choices.RoleChoices.OWNER
and self.template.get_role(self.request.user) != choices.RoleChoices.OWNER
):
raise drf.exceptions.PermissionDenied(
"Only owners of a template can assign other users as owners."
)
serializer.save(template_id=self.kwargs["resource_id"])
class InvitationViewset(
drf.mixins.CreateModelMixin,
@@ -1709,7 +1856,7 @@ class InvitationViewset(
pagination_class = Pagination
permission_classes = [
permissions.CanCreateInvitationPermission,
permissions.AccessPermission,
permissions.ResourceWithAccessPermission,
]
queryset = (
models.Invitation.objects.all()
@@ -1749,11 +1896,11 @@ class InvitationViewset(
queryset.filter(
db.Q(
document__accesses__user=user,
document__accesses__role__in=models.PRIVILEGED_ROLES,
document__accesses__role__in=choices.PRIVILEGED_ROLES,
)
| db.Q(
document__accesses__team__in=teams,
document__accesses__role__in=models.PRIVILEGED_ROLES,
document__accesses__role__in=choices.PRIVILEGED_ROLES,
),
)
# Abilities are computed based on logged-in user's role and
@@ -1785,7 +1932,10 @@ class DocumentAskForAccessViewSet(
lookup_field = "id"
pagination_class = Pagination
permission_classes = [permissions.IsAuthenticated, permissions.AccessPermission]
permission_classes = [
permissions.IsAuthenticated,
permissions.ResourceWithAccessPermission,
]
queryset = models.DocumentAskForAccess.objects.all()
serializer_class = serializers.DocumentAskForAccessSerializer
_document = None
@@ -1795,7 +1945,8 @@ class DocumentAskForAccessViewSet(
if self._document is None:
try:
self._document = models.Document.objects.get(
pk=self.kwargs["resource_id"]
pk=self.kwargs["resource_id"],
depth=1,
)
except models.Document.DoesNotExist as e:
raise drf.exceptions.NotFound("Document not found.") from e
@@ -1808,8 +1959,9 @@ class DocumentAskForAccessViewSet(
queryset = super().get_queryset()
queryset = queryset.filter(document=document)
roles = set(document.get_roles(self.request.user))
is_owner_or_admin = bool(roles.intersection(set(models.PRIVILEGED_ROLES)))
is_owner_or_admin = (
document.get_role(self.request.user) in models.PRIVILEGED_ROLES
)
if not is_owner_or_admin:
queryset = queryset.filter(user=self.request.user)
+115
View File
@@ -0,0 +1,115 @@
"""Declare and configure choices for Docs' core application."""
from django.db.models import TextChoices
from django.utils.translation import gettext_lazy as _
class PriorityTextChoices(TextChoices):
"""
This class inherits from Django's TextChoices and provides a method to get the priority
of a given value based on its position in the class.
"""
@classmethod
def get_priority(cls, role):
"""Returns the priority of the given role based on its order in the class."""
members = list(cls.__members__.values())
return members.index(role) + 1 if role in members else 0
@classmethod
def max(cls, *roles):
"""
Return the highest-priority role among the given roles, using get_priority().
If no valid roles are provided, returns None.
"""
valid_roles = [role for role in roles if cls.get_priority(role) is not None]
if not valid_roles:
return None
return max(valid_roles, key=cls.get_priority)
class LinkRoleChoices(PriorityTextChoices):
"""Defines the possible roles a link can offer on a document."""
READER = "reader", _("Reader") # Can read
EDITOR = "editor", _("Editor") # Can read and edit
class RoleChoices(PriorityTextChoices):
"""Defines the possible roles a user can have in a resource."""
READER = "reader", _("Reader") # Can read
EDITOR = "editor", _("Editor") # Can read and edit
ADMIN = "administrator", _("Administrator") # Can read, edit, delete and share
OWNER = "owner", _("Owner")
PRIVILEGED_ROLES = [RoleChoices.ADMIN, RoleChoices.OWNER]
class LinkReachChoices(PriorityTextChoices):
"""Defines types of access for links"""
RESTRICTED = (
"restricted",
_("Restricted"),
) # Only users with a specific access can read/edit the document
AUTHENTICATED = (
"authenticated",
_("Authenticated"),
) # Any authenticated user can access the document
PUBLIC = "public", _("Public") # Even anonymous users can access the document
@classmethod
def get_select_options(cls, link_reach, link_role):
"""
Determines the valid select options for link reach and link role depending on the
ancestors' link reach/role given as arguments.
Returns:
Dictionary mapping possible reach levels to their corresponding possible roles.
"""
return {
reach: [
role
for role in LinkRoleChoices.values
if LinkRoleChoices.get_priority(role)
>= LinkRoleChoices.get_priority(link_role)
]
if reach != cls.RESTRICTED
else None
for reach in cls.values
if LinkReachChoices.get_priority(reach)
>= LinkReachChoices.get_priority(link_reach)
}
def get_equivalent_link_definition(ancestors_links):
"""
Return the (reach, role) pair with:
1. Highest reach
2. Highest role among links having that reach
"""
if not ancestors_links:
return {"link_reach": None, "link_role": None}
# 1) Find the highest reach
max_reach = max(
ancestors_links,
key=lambda link: LinkReachChoices.get_priority(link["link_reach"]),
)["link_reach"]
# 2) Among those, find the highest role (ignore role if RESTRICTED)
if max_reach == LinkReachChoices.RESTRICTED:
max_role = None
else:
max_role = max(
(
link["link_role"]
for link in ancestors_links
if link["link_reach"] == max_reach
),
key=LinkRoleChoices.get_priority,
)
return {"link_reach": max_reach, "link_role": max_role}
+10 -1
View File
@@ -150,7 +150,7 @@ class DocumentFactory(factory.django.DjangoModelFactory):
"""Add link traces to document from a given list of users."""
if create and extracted:
for item in extracted:
models.LinkTrace.objects.create(document=self, user=item)
models.LinkTrace.objects.update_or_create(document=self, user=item)
@factory.post_generation
def favorited_by(self, create, extracted, **kwargs):
@@ -159,6 +159,15 @@ class DocumentFactory(factory.django.DjangoModelFactory):
for item in extracted:
models.DocumentFavorite.objects.create(document=self, user=item)
@factory.post_generation
def masked_by(self, create, extracted, **kwargs):
"""Mark document as masked by a list of users."""
if create and extracted:
for item in extracted:
models.LinkTrace.objects.update_or_create(
document=self, user=item, defaults={"is_masked": True}
)
class UserDocumentAccessFactory(factory.django.DjangoModelFactory):
"""Create fake document user accesses for testing."""
+21
View File
@@ -0,0 +1,21 @@
"""Force session creation for all requests."""
class ForceSessionMiddleware:
"""
Force session creation for unauthenticated users.
Must be used after Authentication middleware.
"""
def __init__(self, get_response):
"""Initialize the middleware."""
self.get_response = get_response
def __call__(self, request):
"""Force session creation for unauthenticated users."""
if not request.user.is_authenticated and request.session.session_key is None:
request.session.create()
response = self.get_response(request)
return response
@@ -0,0 +1,17 @@
# Generated by Django 5.1.7 on 2025-03-14 14:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0022_alter_user_language_documentaskforaccess"),
]
operations = [
migrations.AddField(
model_name="document",
name="has_deleted_children",
field=models.BooleanField(default=False),
),
]
@@ -0,0 +1,36 @@
# Generated by Django 5.2.3 on 2025-07-13 08:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0023_remove_document_is_public_and_more"),
]
operations = [
migrations.AddField(
model_name="linktrace",
name="is_masked",
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name="user",
name="language",
field=models.CharField(
blank=True,
choices=[
("en-us", "English"),
("fr-fr", "Français"),
("de-de", "Deutsch"),
("nl-nl", "Nederlands"),
("es-es", "Español"),
],
default=None,
help_text="The language in which the user wants to see the interface.",
max_length=10,
null=True,
verbose_name="language",
),
),
]
+309 -234
View File
@@ -6,7 +6,6 @@ Declare and configure the models for the impress core application
import hashlib
import smtplib
import uuid
from collections import defaultdict
from datetime import timedelta
from logging import getLogger
@@ -33,6 +32,14 @@ from rest_framework.exceptions import ValidationError
from timezone_field import TimeZoneField
from treebeard.mp_tree import MP_Node, MP_NodeManager, MP_NodeQuerySet
from .choices import (
PRIVILEGED_ROLES,
LinkReachChoices,
LinkRoleChoices,
RoleChoices,
get_equivalent_link_definition,
)
logger = getLogger(__name__)
@@ -50,88 +57,6 @@ def get_trashbin_cutoff():
return timezone.now() - timedelta(days=settings.TRASHBIN_CUTOFF_DAYS)
class LinkRoleChoices(models.TextChoices):
"""Defines the possible roles a link can offer on a document."""
READER = "reader", _("Reader") # Can read
EDITOR = "editor", _("Editor") # Can read and edit
class RoleChoices(models.TextChoices):
"""Defines the possible roles a user can have in a resource."""
READER = "reader", _("Reader") # Can read
EDITOR = "editor", _("Editor") # Can read and edit
ADMIN = "administrator", _("Administrator") # Can read, edit, delete and share
OWNER = "owner", _("Owner")
PRIVILEGED_ROLES = [RoleChoices.ADMIN, RoleChoices.OWNER]
class LinkReachChoices(models.TextChoices):
"""Defines types of access for links"""
RESTRICTED = (
"restricted",
_("Restricted"),
) # Only users with a specific access can read/edit the document
AUTHENTICATED = (
"authenticated",
_("Authenticated"),
) # Any authenticated user can access the document
PUBLIC = "public", _("Public") # Even anonymous users can access the document
@classmethod
def get_select_options(cls, ancestors_links):
"""
Determines the valid select options for link reach and link role depending on the
list of ancestors' link reach/role.
Args:
ancestors_links: List of dictionaries, each with 'link_reach' and 'link_role' keys
representing the reach and role of ancestors links.
Returns:
Dictionary mapping possible reach levels to their corresponding possible roles.
"""
# If no ancestors, return all options
if not ancestors_links:
return dict.fromkeys(cls.values, LinkRoleChoices.values)
# Initialize result with all possible reaches and role options as sets
result = {reach: set(LinkRoleChoices.values) for reach in cls.values}
# Group roles by reach level
reach_roles = defaultdict(set)
for link in ancestors_links:
reach_roles[link["link_reach"]].add(link["link_role"])
# Apply constraints based on ancestor links
if LinkRoleChoices.EDITOR in reach_roles[cls.RESTRICTED]:
result[cls.RESTRICTED].discard(LinkRoleChoices.READER)
if LinkRoleChoices.EDITOR in reach_roles[cls.AUTHENTICATED]:
result[cls.AUTHENTICATED].discard(LinkRoleChoices.READER)
result.pop(cls.RESTRICTED, None)
elif LinkRoleChoices.READER in reach_roles[cls.AUTHENTICATED]:
result[cls.RESTRICTED].discard(LinkRoleChoices.READER)
if LinkRoleChoices.EDITOR in reach_roles[cls.PUBLIC]:
result[cls.PUBLIC].discard(LinkRoleChoices.READER)
result.pop(cls.AUTHENTICATED, None)
result.pop(cls.RESTRICTED, None)
elif LinkRoleChoices.READER in reach_roles[cls.PUBLIC]:
result[cls.AUTHENTICATED].discard(LinkRoleChoices.READER)
result.get(cls.RESTRICTED, set()).discard(LinkRoleChoices.READER)
# Convert roles sets to lists while maintaining the order from LinkRoleChoices
for reach, roles in result.items():
result[reach] = [role for role in LinkRoleChoices.values if role in roles]
return result
class DuplicateEmailError(Exception):
"""Raised when an email is already associated with a pre-existing user."""
@@ -364,69 +289,6 @@ class BaseAccess(BaseModel):
class Meta:
abstract = True
def _get_roles(self, resource, user):
"""
Get the roles a user has on a resource.
"""
roles = []
if user.is_authenticated:
teams = user.teams
try:
roles = self.user_roles or []
except AttributeError:
try:
roles = resource.accesses.filter(
models.Q(user=user) | models.Q(team__in=teams),
).values_list("role", flat=True)
except (self._meta.model.DoesNotExist, IndexError):
roles = []
return roles
def _get_abilities(self, resource, user):
"""
Compute and return abilities for a given user taking into account
the current state of the object.
"""
roles = self._get_roles(resource, user)
is_owner_or_admin = bool(
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
if self.role == RoleChoices.OWNER:
can_delete = (
RoleChoices.OWNER in roles
and resource.accesses.filter(role=RoleChoices.OWNER).count() > 1
)
set_role_to = (
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
if can_delete
else []
)
else:
can_delete = is_owner_or_admin
set_role_to = []
if RoleChoices.OWNER in roles:
set_role_to.append(RoleChoices.OWNER)
if is_owner_or_admin:
set_role_to.extend(
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
)
# Remove the current role as we don't want to propose it as an option
try:
set_role_to.remove(self.role)
except ValueError:
pass
return {
"destroy": can_delete,
"update": bool(set_role_to),
"partial_update": bool(set_role_to),
"retrieve": bool(roles),
"set_role_to": set_role_to,
}
class DocumentQuerySet(MP_NodeQuerySet):
"""
@@ -452,6 +314,41 @@ class DocumentQuerySet(MP_NodeQuerySet):
return self.filter(link_reach=LinkReachChoices.PUBLIC)
def annotate_is_favorite(self, user):
"""
Annotate document queryset with the favorite status for the current user.
"""
if user.is_authenticated:
favorite_exists_subquery = DocumentFavorite.objects.filter(
document_id=models.OuterRef("pk"), user=user
)
return self.annotate(is_favorite=models.Exists(favorite_exists_subquery))
return self.annotate(is_favorite=models.Value(False))
def annotate_user_roles(self, user):
"""
Annotate document queryset with the roles of the current user
on the document or its ancestors.
"""
output_field = ArrayField(base_field=models.CharField())
if user.is_authenticated:
user_roles_subquery = DocumentAccess.objects.filter(
models.Q(user=user) | models.Q(team__in=user.teams),
document__path=Left(models.OuterRef("path"), Length("document__path")),
).values_list("role", flat=True)
return self.annotate(
user_roles=models.Func(
user_roles_subquery, function="ARRAY", output_field=output_field
)
)
return self.annotate(
user_roles=models.Value([], output_field=output_field),
)
class DocumentManager(MP_NodeManager.from_queryset(DocumentQuerySet)):
"""
@@ -464,6 +361,7 @@ class DocumentManager(MP_NodeManager.from_queryset(DocumentQuerySet)):
return self._queryset_class(self.model).order_by("path")
# pylint: disable=too-many-public-methods
class Document(MP_Node, BaseModel):
"""Pad document carrying the content."""
@@ -486,6 +384,7 @@ class Document(MP_Node, BaseModel):
)
deleted_at = models.DateTimeField(null=True, blank=True)
ancestors_deleted_at = models.DateTimeField(null=True, blank=True)
has_deleted_children = models.BooleanField(default=False)
duplicated_from = models.ForeignKey(
"self",
on_delete=models.SET_NULL,
@@ -531,6 +430,12 @@ class Document(MP_Node, BaseModel):
def __str__(self):
return str(self.title) if self.title else str(_("Untitled Document"))
def __init__(self, *args, **kwargs):
"""Initialize cache property."""
super().__init__(*args, **kwargs)
self._ancestors_link_definition = None
self._computed_link_definition = None
def save(self, *args, **kwargs):
"""Write content to object storage only if _content has changed."""
super().save(*args, **kwargs)
@@ -561,6 +466,12 @@ class Document(MP_Node, BaseModel):
content_file = ContentFile(bytes_content)
default_storage.save(file_key, content_file)
def is_leaf(self):
"""
:returns: True if the node is has no children
"""
return not self.has_deleted_children and self.numchild == 0
@property
def key_base(self):
"""Key base of the location where the document is stored in object storage."""
@@ -718,38 +629,22 @@ class Document(MP_Node, BaseModel):
cache_key = document.get_nb_accesses_cache_key()
cache.delete(cache_key)
def get_roles(self, user):
def get_role(self, user):
"""Return the roles a user has on a document."""
if not user.is_authenticated:
return []
return None
try:
roles = self.user_roles or []
except AttributeError:
try:
roles = DocumentAccess.objects.filter(
models.Q(user=user) | models.Q(team__in=user.teams),
document__path=Left(
models.Value(self.path), Length("document__path")
),
).values_list("role", flat=True)
except (models.ObjectDoesNotExist, IndexError):
roles = []
return roles
roles = DocumentAccess.objects.filter(
models.Q(user=user) | models.Q(team__in=user.teams),
document__path=Left(models.Value(self.path), Length("document__path")),
).values_list("role", flat=True)
def get_links_definitions(self, ancestors_links):
"""Get links reach/role definitions for the current document and its ancestors."""
return RoleChoices.max(*roles)
links_definitions = defaultdict(set)
links_definitions[self.link_reach].add(self.link_role)
# Merge ancestor link definitions
for ancestor in ancestors_links:
links_definitions[ancestor["link_reach"]].add(ancestor["link_role"])
return dict(links_definitions) # Convert default dict back to a normal dict
def compute_ancestors_links(self, user):
def compute_ancestors_links_paths_mapping(self):
"""
Compute the ancestors links for the current document up to the highest readable ancestor.
"""
@@ -758,63 +653,114 @@ class Document(MP_Node, BaseModel):
.filter(ancestors_deleted_at__isnull=True)
.order_by("path")
)
highest_readable = ancestors.readable_per_se(user).only("depth").first()
if highest_readable is None:
return []
ancestors_links = []
paths_links_mapping = {}
for ancestor in ancestors.filter(depth__gte=highest_readable.depth):
for ancestor in ancestors:
ancestors_links.append(
{"link_reach": ancestor.link_reach, "link_role": ancestor.link_role}
)
paths_links_mapping[ancestor.path] = ancestors_links.copy()
ancestors_links = paths_links_mapping.get(self.path[: -self.steplen], [])
return paths_links_mapping
return ancestors_links
@property
def link_definition(self):
"""Returns link reach/role as a definition in dictionary format."""
return {"link_reach": self.link_reach, "link_role": self.link_role}
def get_abilities(self, user, ancestors_links=None):
@property
def ancestors_link_definition(self):
"""Link definition equivalent to all document's ancestors."""
if getattr(self, "_ancestors_link_definition", None) is None:
if self.depth <= 1:
ancestors_links = []
else:
mapping = self.compute_ancestors_links_paths_mapping()
ancestors_links = mapping.get(self.path[: -self.steplen], [])
self._ancestors_link_definition = get_equivalent_link_definition(
ancestors_links
)
return self._ancestors_link_definition
@ancestors_link_definition.setter
def ancestors_link_definition(self, definition):
"""Cache the ancestors_link_definition."""
self._ancestors_link_definition = definition
@property
def ancestors_link_reach(self):
"""Link reach equivalent to all document's ancestors."""
return self.ancestors_link_definition["link_reach"]
@property
def ancestors_link_role(self):
"""Link role equivalent to all document's ancestors."""
return self.ancestors_link_definition["link_role"]
@property
def computed_link_definition(self):
"""
Link reach/role on the document, combining inherited ancestors' link
definitions and the document's own link definition.
"""
if getattr(self, "_computed_link_definition", None) is None:
self._computed_link_definition = get_equivalent_link_definition(
[self.ancestors_link_definition, self.link_definition]
)
return self._computed_link_definition
@property
def computed_link_reach(self):
"""Actual link reach on the document."""
return self.computed_link_definition["link_reach"]
@property
def computed_link_role(self):
"""Actual link role on the document."""
return self.computed_link_definition["link_role"]
def get_abilities(self, user):
"""
Compute and return abilities for a given user on the document.
"""
if self.depth <= 1 or getattr(self, "is_highest_ancestor_for_user", False):
ancestors_links = []
elif ancestors_links is None:
ancestors_links = self.compute_ancestors_links(user=user)
roles = set(
self.get_roles(user)
) # at this point only roles based on specific access
# First get the role based on specific access
role = self.get_role(user)
# Characteristics that are based only on specific access
is_owner = RoleChoices.OWNER in roles
is_owner = role == RoleChoices.OWNER
is_deleted = self.ancestors_deleted_at and not is_owner
is_owner_or_admin = (is_owner or RoleChoices.ADMIN in roles) and not is_deleted
is_owner_or_admin = (is_owner or role == RoleChoices.ADMIN) and not is_deleted
# Compute access roles before adding link roles because we don't
# want anonymous users to access versions (we wouldn't know from
# which date to allow them anyway)
# Anonymous users should also not see document accesses
has_access_role = bool(roles) and not is_deleted
has_access_role = bool(role) and not is_deleted
can_update_from_access = (
is_owner_or_admin or RoleChoices.EDITOR in roles
is_owner_or_admin or role == RoleChoices.EDITOR
) and not is_deleted
# Add roles provided by the document link, taking into account its ancestors
links_definitions = self.get_links_definitions(ancestors_links)
public_roles = links_definitions.get(LinkReachChoices.PUBLIC, set())
authenticated_roles = (
links_definitions.get(LinkReachChoices.AUTHENTICATED, set())
if user.is_authenticated
else set()
link_select_options = LinkReachChoices.get_select_options(
**self.ancestors_link_definition
)
link_definition = get_equivalent_link_definition(
[
self.ancestors_link_definition,
{"link_reach": self.link_reach, "link_role": self.link_role},
]
)
roles = roles | public_roles | authenticated_roles
can_get = bool(roles) and not is_deleted
link_reach = link_definition["link_reach"]
if link_reach == LinkReachChoices.PUBLIC or (
link_reach == LinkReachChoices.AUTHENTICATED and user.is_authenticated
):
role = RoleChoices.max(role, link_definition["link_role"])
can_get = bool(role) and not is_deleted
can_update = (
is_owner_or_admin or RoleChoices.EDITOR in roles
is_owner_or_admin or role == RoleChoices.EDITOR
) and not is_deleted
ai_allow_reach_from = settings.AI_ALLOW_REACH_FROM
@@ -836,22 +782,24 @@ class Document(MP_Node, BaseModel):
"ai_translate": ai_access,
"attachment_upload": can_update,
"media_check": can_get,
"can_edit": can_update,
"children_list": can_get,
"children_create": can_update and user.is_authenticated,
"collaboration_auth": can_get,
"cors_proxy": can_get,
"descendants": can_get,
"destroy": is_owner,
"duplicate": can_get,
"duplicate": can_get and user.is_authenticated,
"favorite": can_get and user.is_authenticated,
"link_configuration": is_owner_or_admin,
"invite_owner": is_owner,
"mask": can_get and user.is_authenticated,
"move": is_owner_or_admin and not self.ancestors_deleted_at,
"partial_update": can_update,
"restore": is_owner,
"retrieve": can_get,
"media_auth": can_get,
"link_select_options": LinkReachChoices.get_select_options(ancestors_links),
"link_select_options": link_select_options,
"tree": can_get,
"update": can_update,
"versions_destroy": is_owner_or_admin,
@@ -946,7 +894,8 @@ class Document(MP_Node, BaseModel):
if self.depth > 1:
self._meta.model.objects.filter(pk=self.get_parent().pk).update(
numchild=models.F("numchild") - 1
numchild=models.F("numchild") - 1,
has_deleted_children=True,
)
# Mark all descendants as soft deleted
@@ -1010,6 +959,7 @@ class LinkTrace(BaseModel):
related_name="link_traces",
)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="link_traces")
is_masked = models.BooleanField(default=False)
class Meta:
db_table = "impress_link_trace"
@@ -1103,48 +1053,120 @@ class DocumentAccess(BaseAccess):
super().save(*args, **kwargs)
self.document.invalidate_nb_accesses_cache()
@property
def target_key(self):
"""Get a unique key for the actor targeted by the access, without possible conflict."""
return f"user:{self.user_id!s}" if self.user_id else f"team:{self.team:s}"
def delete(self, *args, **kwargs):
"""Override delete to clear the document's cache for number of accesses."""
super().delete(*args, **kwargs)
self.document.invalidate_nb_accesses_cache()
def set_user_roles_tuple(self, ancestors_role, current_role):
"""
Set a precomputed (ancestor_role, current_role) tuple for this instance.
This avoids querying the database in `get_roles_tuple()` and is useful
when roles are already known, such as in bulk serialization.
Args:
ancestor_role (str | None): Highest role on any ancestor document.
current_role (str | None): Role on the current document.
"""
# pylint: disable=attribute-defined-outside-init
self._prefetched_user_roles_tuple = (ancestors_role, current_role)
def get_user_roles_tuple(self, user):
"""
Return a tuple of:
- the highest role the user has on any ancestor of the document
- the role the user has on the current document
If roles have been explicitly set using `set_user_roles_tuple()`,
those will be returned instead of querying the database.
This allows viewsets or serializers to precompute roles for performance
when handling multiple documents at once.
Args:
user (User): The user whose roles are being evaluated.
Returns:
tuple[str | None, str | None]: (max_ancestor_role, current_document_role)
"""
if not user.is_authenticated:
return None, None
try:
return self._prefetched_user_roles_tuple
except AttributeError:
pass
ancestors = (
self.document.get_ancestors() | Document.objects.filter(pk=self.document_id)
).filter(ancestors_deleted_at__isnull=True)
access_tuples = DocumentAccess.objects.filter(
models.Q(user=user) | models.Q(team__in=user.teams),
document__in=ancestors,
).values_list("document_id", "role")
ancestors_roles = []
current_roles = []
for doc_id, role in access_tuples:
if doc_id == self.document_id:
current_roles.append(role)
else:
ancestors_roles.append(role)
return RoleChoices.max(*ancestors_roles), RoleChoices.max(*current_roles)
def get_abilities(self, user):
"""
Compute and return abilities for a given user on the document access.
"""
roles = self._get_roles(self.document, user)
is_owner_or_admin = bool(set(roles).intersection(set(PRIVILEGED_ROLES)))
ancestors_role, current_role = self.get_user_roles_tuple(user)
role = RoleChoices.max(ancestors_role, current_role)
is_owner_or_admin = role in PRIVILEGED_ROLES
if self.role == RoleChoices.OWNER:
can_delete = (
RoleChoices.OWNER in roles
and self.document.accesses.filter(role=RoleChoices.OWNER).count() > 1
)
set_role_to = (
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
if can_delete
else []
can_delete = role == RoleChoices.OWNER and (
# check if document is not root trying to avoid an extra query
self.document.depth > 1
or DocumentAccess.objects.filter(
document_id=self.document_id, role=RoleChoices.OWNER
).count()
> 1
)
set_role_to = RoleChoices.values if can_delete else []
else:
can_delete = is_owner_or_admin
set_role_to = []
if RoleChoices.OWNER in roles:
set_role_to.append(RoleChoices.OWNER)
if is_owner_or_admin:
set_role_to.extend(
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
[RoleChoices.READER, RoleChoices.EDITOR, RoleChoices.ADMIN]
)
if role == RoleChoices.OWNER:
set_role_to.append(RoleChoices.OWNER)
# Remove the current role as we don't want to propose it as an option
try:
set_role_to.remove(self.role)
except ValueError:
pass
# Filter out roles that would be lower than the one the user already has
ancestors_role_priority = RoleChoices.get_priority(
getattr(self, "max_ancestors_role", None)
)
set_role_to = [
candidate_role
for candidate_role in set_role_to
if RoleChoices.get_priority(candidate_role) >= ancestors_role_priority
]
if len(set_role_to) == 1:
set_role_to = []
return {
"destroy": can_delete,
"update": bool(set_role_to) and is_owner_or_admin,
"partial_update": bool(set_role_to) and is_owner_or_admin,
"retrieve": self.user and self.user.id == user.id or is_owner_or_admin,
"retrieve": (self.user and self.user.id == user.id) or is_owner_or_admin,
"set_role_to": set_role_to,
}
@@ -1277,10 +1299,10 @@ class Template(BaseModel):
def __str__(self):
return self.title
def get_roles(self, user):
def get_role(self, user):
"""Return the roles a user has on a resource as an iterable."""
if not user.is_authenticated:
return []
return None
try:
roles = self.user_roles or []
@@ -1291,21 +1313,20 @@ class Template(BaseModel):
).values_list("role", flat=True)
except (models.ObjectDoesNotExist, IndexError):
roles = []
return roles
return RoleChoices.max(*roles)
def get_abilities(self, user):
"""
Compute and return abilities for a given user on the template.
"""
roles = self.get_roles(user)
is_owner_or_admin = bool(
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
can_get = self.is_public or bool(roles)
can_update = is_owner_or_admin or RoleChoices.EDITOR in roles
role = self.get_role(user)
is_owner_or_admin = role in PRIVILEGED_ROLES
can_get = self.is_public or bool(role)
can_update = is_owner_or_admin or role == RoleChoices.EDITOR
return {
"destroy": RoleChoices.OWNER in roles,
"destroy": role == RoleChoices.OWNER,
"generate_document": can_get,
"accesses_manage": is_owner_or_admin,
"update": can_update,
@@ -1352,11 +1373,65 @@ class TemplateAccess(BaseAccess):
def __str__(self):
return f"{self.user!s} is {self.role:s} in template {self.template!s}"
def get_role(self, user):
"""
Get the role a user has on a resource.
"""
if not user.is_authenticated:
return None
try:
roles = self.user_roles or []
except AttributeError:
teams = user.teams
try:
roles = self.template.accesses.filter(
models.Q(user=user) | models.Q(team__in=teams),
).values_list("role", flat=True)
except (Template.DoesNotExist, IndexError):
roles = []
return RoleChoices.max(*roles)
def get_abilities(self, user):
"""
Compute and return abilities for a given user on the template access.
"""
return self._get_abilities(self.template, user)
role = self.get_role(user)
is_owner_or_admin = role in PRIVILEGED_ROLES
if self.role == RoleChoices.OWNER:
can_delete = (role == RoleChoices.OWNER) and self.template.accesses.filter(
role=RoleChoices.OWNER
).count() > 1
set_role_to = (
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
if can_delete
else []
)
else:
can_delete = is_owner_or_admin
set_role_to = []
if role == RoleChoices.OWNER:
set_role_to.append(RoleChoices.OWNER)
if is_owner_or_admin:
set_role_to.extend(
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
)
# Remove the current role as we don't want to propose it as an option
try:
set_role_to.remove(self.role)
except ValueError:
pass
return {
"destroy": can_delete,
"update": bool(set_role_to),
"partial_update": bool(set_role_to),
"retrieve": bool(role),
"set_role_to": set_role_to,
}
class Invitation(BaseModel):
+2 -1
View File
@@ -9,7 +9,8 @@ from core import enums
AI_ACTIONS = {
"prompt": (
"Answer the prompt in markdown format. "
"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."
@@ -41,3 +41,35 @@ class CollaborationService:
f"Failed to notify WebSocket server. Status code: {response.status_code}, "
f"Response: {response.text}"
)
def get_document_connection_info(self, room, session_key):
"""
Get the connection info for a document.
"""
endpoint = "get-connections"
querystring = {
"room": room,
"sessionKey": session_key,
}
endpoint_url = f"{settings.COLLABORATION_API_URL}{endpoint}/"
headers = {"Authorization": settings.COLLABORATION_SERVER_SECRET}
try:
response = requests.get(
endpoint_url, headers=headers, params=querystring, timeout=10
)
except requests.RequestException as e:
raise requests.HTTPError("Failed to get document connection info.") from e
if response.status_code == 200:
result = response.json()
return result.get("count", 0), result.get("exists", False)
if response.status_code == 404:
return 0, False
raise requests.HTTPError(
f"Failed to get document connection info. Status code: {response.status_code}, "
f"Response: {response.text}"
)
@@ -1,5 +1,7 @@
"""Converter services."""
from base64 import b64encode
from django.conf import settings
import requests
@@ -17,14 +19,6 @@ class ServiceUnavailableError(ConversionError):
"""Raised when the conversion service is unavailable."""
class InvalidResponseError(ConversionError):
"""Raised when the conversion service returns an invalid response."""
class MissingContentError(ConversionError):
"""Raised when the response is missing required content."""
class YdocConverter:
"""Service class for conversion-related operations."""
@@ -32,9 +26,9 @@ class YdocConverter:
def auth_header(self):
"""Build microservice authentication header."""
# Note: Yprovider microservice accepts only raw token, which is not recommended
return settings.Y_PROVIDER_API_KEY
return f"Bearer {settings.Y_PROVIDER_API_KEY}"
def convert_markdown(self, text):
def convert(self, text):
"""Convert a Markdown text into our internal format using an external microservice."""
if not text:
@@ -43,36 +37,17 @@ class YdocConverter:
try:
response = requests.post(
f"{settings.Y_PROVIDER_API_BASE_URL}{settings.CONVERSION_API_ENDPOINT}/",
json={
"content": text,
},
data=text,
headers={
"Authorization": self.auth_header,
"Content-Type": "application/json",
"Content-Type": "text/markdown",
},
timeout=settings.CONVERSION_API_TIMEOUT,
verify=settings.CONVERSION_API_SECURE,
)
response.raise_for_status()
conversion_response = response.json()
return b64encode(response.content).decode("utf-8")
except requests.RequestException as err:
raise ServiceUnavailableError(
"Failed to connect to conversion service",
) from err
except ValueError as err:
raise InvalidResponseError(
"Could not parse conversion service response"
) from err
try:
document_content = conversion_response[
settings.CONVERSION_API_CONTENT_FIELD
]
except KeyError as err:
raise MissingContentError(
f"Response missing required field: {settings.CONVERSION_API_CONTENT_FIELD}"
) from err
return document_content
@@ -1,6 +1,7 @@
"""
Test document accesses API endpoints for users in impress's core app.
"""
# pylint: disable=too-many-lines
import random
from uuid import uuid4
@@ -8,7 +9,7 @@ from uuid import uuid4
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core import choices, factories, models
from core.api import serializers
from core.tests.conftest import TEAM, USER, VIA
from core.tests.test_services_collaboration_services import ( # pylint: disable=unused-import
@@ -51,12 +52,7 @@ def test_api_document_accesses_list_authenticated_unrelated():
f"/api/v1.0/documents/{document.id!s}/accesses/",
)
assert response.status_code == 200
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
assert response.json() == []
def test_api_document_accesses_list_unexisting_document():
@@ -69,39 +65,46 @@ def test_api_document_accesses_list_unexisting_document():
client.force_login(user)
response = client.get(f"/api/v1.0/documents/{uuid4()!s}/accesses/")
assert response.status_code == 200
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize(
"role", [role for role in models.RoleChoices if role not in models.PRIVILEGED_ROLES]
"role",
[role for role in choices.RoleChoices if role not in choices.PRIVILEGED_ROLES],
)
def test_api_document_accesses_list_authenticated_related_non_privileged(
via, role, mock_user_teams
via, role, mock_user_teams, django_assert_num_queries
):
"""
Authenticated users should be able to list document accesses for a document
to which they are directly related, whatever their role in the document.
Authenticated users with no privileged role should only be able to list document
accesses associated with privileged roles for a document, including from ancestors.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
owner = factories.UserFactory()
accesses = []
document_access = factories.UserDocumentAccessFactory(
user=owner, role=models.RoleChoices.OWNER
# Create documents structured as a tree
unreadable_ancestor = factories.DocumentFactory(link_reach="restricted")
# make all documents below the grand parent readable without a specific access for the user
grand_parent = factories.DocumentFactory(
parent=unreadable_ancestor, link_reach="authenticated"
)
accesses.append(document_access)
document = document_access.document
parent = factories.DocumentFactory(parent=grand_parent)
document = factories.DocumentFactory(parent=parent)
child = factories.DocumentFactory(parent=document)
# Create accesses related to each document
accesses = (
factories.UserDocumentAccessFactory(document=unreadable_ancestor),
factories.UserDocumentAccessFactory(document=grand_parent),
factories.UserDocumentAccessFactory(document=parent),
factories.UserDocumentAccessFactory(document=document),
factories.TeamDocumentAccessFactory(document=document),
)
factories.UserDocumentAccessFactory(document=child)
if via == USER:
models.DocumentAccess.objects.create(
document=document,
@@ -116,33 +119,32 @@ def test_api_document_accesses_list_authenticated_related_non_privileged(
role=role,
)
access1 = factories.TeamDocumentAccessFactory(document=document)
access2 = factories.UserDocumentAccessFactory(document=document)
accesses.append(access1)
accesses.append(access2)
# Accesses for other documents to which the user is related should not be listed either
other_access = factories.UserDocumentAccessFactory(user=user)
factories.UserDocumentAccessFactory(document=other_access.document)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/accesses/",
)
with django_assert_num_queries(3):
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
# Return only owners
owners_accesses = [
access for access in accesses if access.role in models.PRIVILEGED_ROLES
]
assert response.status_code == 200
content = response.json()
assert content["count"] == len(owners_accesses)
assert sorted(content["results"], key=lambda x: x["id"]) == sorted(
# Make sure only privileged roles are returned
privileged_accesses = [
acc for acc in accesses if acc.role in choices.PRIVILEGED_ROLES
]
assert len(content) == len(privileged_accesses)
assert sorted(content, key=lambda x: x["id"]) == sorted(
[
{
"id": str(access.id),
"document": {
"id": str(access.document_id),
"path": access.document.path,
"depth": access.document.depth,
},
"user": {
"id": None,
"email": None,
"full_name": access.user.full_name,
"short_name": access.user.short_name,
}
@@ -150,40 +152,47 @@ def test_api_document_accesses_list_authenticated_related_non_privileged(
else None,
"team": access.team,
"role": access.role,
"abilities": access.get_abilities(user),
"max_ancestors_role": None,
"max_role": access.role,
"abilities": {
"destroy": False,
"partial_update": False,
"retrieve": False,
"set_role_to": [],
"update": False,
},
}
for access in owners_accesses
for access in privileged_accesses
],
key=lambda x: x["id"],
)
for access in content["results"]:
assert access["role"] in models.PRIVILEGED_ROLES
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize("role", models.PRIVILEGED_ROLES)
def test_api_document_accesses_list_authenticated_related_privileged_roles(
via, role, mock_user_teams
@pytest.mark.parametrize(
"role", [role for role in choices.RoleChoices if role in choices.PRIVILEGED_ROLES]
)
def test_api_document_accesses_list_authenticated_related_privileged(
via, role, mock_user_teams, django_assert_num_queries
):
"""
Authenticated users should be able to list document accesses for a document
to which they are directly related, whatever their role in the document.
Authenticated users with a privileged role should be able to list all
document accesses whatever the role, including from ancestors.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
owner = factories.UserFactory()
accesses = []
document_access = factories.UserDocumentAccessFactory(
user=owner, role=models.RoleChoices.OWNER
# Create documents structured as a tree
unreadable_ancestor = factories.DocumentFactory(link_reach="restricted")
# make all documents below the grand parent readable without a specific access for the user
grand_parent = factories.DocumentFactory(
parent=unreadable_ancestor, link_reach="authenticated"
)
accesses.append(document_access)
document = document_access.document
user_access = None
parent = factories.DocumentFactory(parent=grand_parent)
document = factories.DocumentFactory(parent=parent)
child = factories.DocumentFactory(parent=document)
if via == USER:
user_access = models.DocumentAccess.objects.create(
document=document,
@@ -197,61 +206,319 @@ def test_api_document_accesses_list_authenticated_related_privileged_roles(
team="lasuite",
role=role,
)
else:
raise RuntimeError()
access1 = factories.TeamDocumentAccessFactory(document=document)
access2 = factories.UserDocumentAccessFactory(document=document)
accesses.append(access1)
accesses.append(access2)
# Create accesses related to each document
ancestors_accesses = [
# Access on unreadable ancestor should still be listed
# as the related user gains access to our document
factories.UserDocumentAccessFactory(document=unreadable_ancestor),
factories.UserDocumentAccessFactory(document=grand_parent),
factories.UserDocumentAccessFactory(document=parent),
]
document_accesses = [
factories.UserDocumentAccessFactory(document=document),
factories.TeamDocumentAccessFactory(document=document),
factories.UserDocumentAccessFactory(document=document),
user_access,
]
factories.UserDocumentAccessFactory(document=child)
# Accesses for other documents to which the user is related should not be listed either
other_access = factories.UserDocumentAccessFactory(user=user)
factories.UserDocumentAccessFactory(document=other_access.document)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/accesses/",
)
access2_user = serializers.UserSerializer(instance=access2.user).data
base_user = serializers.UserSerializer(instance=user).data
with django_assert_num_queries(3):
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
assert response.status_code == 200
content = response.json()
assert len(content["results"]) == 4
assert sorted(content["results"], key=lambda x: x["id"]) == sorted(
assert len(content) == 7
assert sorted(content, key=lambda x: x["id"]) == sorted(
[
{
"id": str(user_access.id),
"user": base_user if via == "user" else None,
"team": "lasuite" if via == "team" else "",
"role": user_access.role,
"abilities": user_access.get_abilities(user),
},
{
"id": str(access1.id),
"user": None,
"team": access1.team,
"role": access1.role,
"abilities": access1.get_abilities(user),
},
{
"id": str(access2.id),
"user": access2_user,
"team": "",
"role": access2.role,
"abilities": access2.get_abilities(user),
},
{
"id": str(document_access.id),
"user": serializers.UserSerializer(instance=owner).data,
"team": "",
"role": models.RoleChoices.OWNER,
"abilities": document_access.get_abilities(user),
},
"id": str(access.id),
"document": {
"id": str(access.document_id),
"path": access.document.path,
"depth": access.document.depth,
},
"user": {
"id": str(access.user.id),
"email": access.user.email,
"language": access.user.language,
"full_name": access.user.full_name,
"short_name": access.user.short_name,
}
if access.user
else None,
"max_ancestors_role": None,
"max_role": access.role,
"team": access.team,
"role": access.role,
"abilities": access.get_abilities(user),
}
for access in ancestors_accesses + document_accesses
],
key=lambda x: x["id"],
)
def test_api_document_accesses_retrieve_set_role_to_child():
"""Check set_role_to for an access with no access on the ancestor."""
user, other_user = factories.UserFactory.create_batch(2)
client = APIClient()
client.force_login(user)
parent = factories.DocumentFactory()
parent_access = factories.UserDocumentAccessFactory(
document=parent, user=user, role="owner"
)
document = factories.DocumentFactory(parent=parent)
document_access_other_user = factories.UserDocumentAccessFactory(
document=document, user=other_user, role="editor"
)
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
assert response.status_code == 200
content = response.json()
assert len(content) == 2
result_dict = {
result["id"]: result["abilities"]["set_role_to"] for result in content
}
assert result_dict[str(document_access_other_user.id)] == [
"reader",
"editor",
"administrator",
"owner",
]
assert result_dict[str(parent_access.id)] == []
# Add an access for the other user on the parent
parent_access_other_user = factories.UserDocumentAccessFactory(
document=parent, user=other_user, role="editor"
)
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
assert response.status_code == 200
content = response.json()
assert len(content) == 3
result_dict = {
result["id"]: result["abilities"]["set_role_to"] for result in content
}
assert result_dict[str(document_access_other_user.id)] == [
"editor",
"administrator",
"owner",
]
assert result_dict[str(parent_access.id)] == []
assert result_dict[str(parent_access_other_user.id)] == [
"reader",
"editor",
"administrator",
"owner",
]
@pytest.mark.parametrize(
"roles,results",
[
[
["administrator", "reader", "reader", "reader"],
[
["reader", "editor", "administrator"],
[],
[],
["reader", "editor", "administrator"],
],
],
[
["owner", "reader", "reader", "reader"],
[
["reader", "editor", "administrator", "owner"],
[],
[],
["reader", "editor", "administrator", "owner"],
],
],
[
["owner", "reader", "reader", "owner"],
[
["reader", "editor", "administrator", "owner"],
[],
[],
["reader", "editor", "administrator", "owner"],
],
],
],
)
def test_api_document_accesses_list_authenticated_related_same_user(roles, results):
"""
The maximum role across ancestor documents and set_role_to optionsfor
a given user should be filled as expected.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Create documents structured as a tree
grand_parent = factories.DocumentFactory(link_reach="authenticated")
parent = factories.DocumentFactory(parent=grand_parent)
document = factories.DocumentFactory(parent=parent)
# Create accesses for another user
other_user = factories.UserFactory()
accesses = [
factories.UserDocumentAccessFactory(
document=document, user=user, role=roles[0]
),
factories.UserDocumentAccessFactory(
document=grand_parent, user=other_user, role=roles[1]
),
factories.UserDocumentAccessFactory(
document=parent, user=other_user, role=roles[2]
),
factories.UserDocumentAccessFactory(
document=document, user=other_user, role=roles[3]
),
]
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
assert response.status_code == 200
content = response.json()
assert len(content) == 4
for result in content:
assert (
result["max_ancestors_role"] is None
if result["user"]["id"] == str(user.id)
else choices.RoleChoices.max(roles[1], roles[2])
)
result_dict = {
result["id"]: result["abilities"]["set_role_to"] for result in content
}
assert [result_dict[str(access.id)] for access in accesses] == results
@pytest.mark.parametrize(
"roles,results",
[
[
["administrator", "reader", "reader", "reader"],
[
["reader", "editor", "administrator"],
[],
[],
["reader", "editor", "administrator"],
],
],
[
["owner", "reader", "reader", "reader"],
[
["reader", "editor", "administrator", "owner"],
[],
[],
["reader", "editor", "administrator", "owner"],
],
],
[
["owner", "reader", "reader", "owner"],
[
["reader", "editor", "administrator", "owner"],
[],
[],
["reader", "editor", "administrator", "owner"],
],
],
[
["reader", "reader", "reader", "owner"],
[
["reader", "editor", "administrator", "owner"],
[],
[],
["reader", "editor", "administrator", "owner"],
],
],
[
["reader", "administrator", "reader", "editor"],
[
["reader", "editor", "administrator"],
["reader", "editor", "administrator"],
[],
[],
],
],
[
["editor", "editor", "administrator", "editor"],
[
["reader", "editor", "administrator"],
[],
["editor", "administrator"],
[],
],
],
],
)
def test_api_document_accesses_list_authenticated_related_same_team(
roles, results, mock_user_teams
):
"""
The maximum role across ancestor documents and set_role_to optionsfor
a given team should be filled as expected.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Create documents structured as a tree
grand_parent = factories.DocumentFactory(link_reach="authenticated")
parent = factories.DocumentFactory(parent=grand_parent)
document = factories.DocumentFactory(parent=parent)
mock_user_teams.return_value = ["lasuite", "unknown"]
accesses = [
factories.UserDocumentAccessFactory(
document=document, user=user, role=roles[0]
),
# Create accesses for a team
factories.TeamDocumentAccessFactory(
document=grand_parent, team="lasuite", role=roles[1]
),
factories.TeamDocumentAccessFactory(
document=parent, team="lasuite", role=roles[2]
),
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=roles[3]
),
]
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
assert response.status_code == 200
content = response.json()
assert len(content) == 4
for result in content:
assert (
result["max_ancestors_role"] is None
if result["user"] and result["user"]["id"] == str(user.id)
else choices.RoleChoices.max(roles[1], roles[2])
)
result_dict = {
result["id"]: result["abilities"]["set_role_to"] for result in content
}
assert [result_dict[str(access.id)] for access in accesses] == results
def test_api_document_accesses_retrieve_anonymous():
"""
Anonymous users should not be allowed to retrieve a document access.
@@ -307,7 +574,9 @@ def test_api_document_accesses_retrieve_authenticated_unrelated():
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize("role", models.RoleChoices)
def test_api_document_accesses_retrieve_authenticated_related(
via, role, mock_user_teams
via,
role,
mock_user_teams,
):
"""
A user who is related to a document should be allowed to retrieve the
@@ -333,7 +602,7 @@ def test_api_document_accesses_retrieve_authenticated_related(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
if not role in models.PRIVILEGED_ROLES:
if not role in choices.PRIVILEGED_ROLES:
assert response.status_code == 403
else:
access_user = serializers.UserSerializer(instance=access.user).data
@@ -341,9 +610,16 @@ def test_api_document_accesses_retrieve_authenticated_related(
assert response.status_code == 200
assert response.json() == {
"id": str(access.id),
"document": {
"id": str(access.document_id),
"path": access.document.path,
"depth": access.document.depth,
},
"user": access_user,
"team": "",
"role": access.role,
"max_ancestors_role": None,
"max_role": access.role,
"abilities": access.get_abilities(user),
}
@@ -448,7 +724,9 @@ def test_api_document_accesses_update_authenticated_reader_or_editor(
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize("create_for", VIA)
def test_api_document_accesses_update_administrator_except_owner(
create_for,
via,
mock_user_teams,
mock_reset_connections, # pylint: disable=redefined-outer-name
@@ -481,32 +759,31 @@ def test_api_document_accesses_update_administrator_except_owner(
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(["administrator", "editor", "reader"]),
}
if create_for == USER:
new_values["user_id"] = factories.UserFactory().id
elif create_for == TEAM:
new_values["team"] = "new-team"
for field, value in new_values.items():
new_data = {**old_values, field: value}
if new_data["role"] == old_values["role"]:
with mock_reset_connections(document.id, str(access.user_id)):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
assert response.status_code == 403
else:
with mock_reset_connections(document.id, str(access.user_id)):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
assert response.status_code == 200
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.DocumentAccessSerializer(instance=access).data
if field == "role":
assert updated_values == {**old_values, "role": new_values["role"]}
if field in ["role", "max_role"]:
assert updated_values == {
**old_values,
"role": new_values["role"],
"max_role": new_values["role"],
}
else:
assert updated_values == old_values
@@ -601,7 +878,7 @@ def test_api_document_accesses_update_administrator_to_owner(
for field, value in new_values.items():
new_data = {**old_values, field: value}
# We are not allowed or not really updating the role
if field == "role" or new_data["role"] == old_values["role"]:
if field == "role":
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data,
@@ -624,7 +901,9 @@ def test_api_document_accesses_update_administrator_to_owner(
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize("create_for", VIA)
def test_api_document_accesses_update_owner(
create_for,
via,
mock_user_teams,
mock_reset_connections, # pylint: disable=redefined-outer-name
@@ -655,42 +934,39 @@ def test_api_document_accesses_update_owner(
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.values),
}
if create_for == USER:
new_values["user_id"] = factories.UserFactory().id
elif create_for == TEAM:
new_values["team"] = "new-team"
for field, value in new_values.items():
new_data = {**old_values, field: value}
if (
new_data["role"] == old_values["role"]
): # we are not really updating the role
with mock_reset_connections(document.id, str(access.user_id)):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
assert response.status_code == 403
else:
with mock_reset_connections(document.id, str(access.user_id)):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
assert response.status_code == 200
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.DocumentAccessSerializer(instance=access).data
if field == "role":
assert updated_values == {**old_values, "role": new_values["role"]}
if field in ["role", "max_role"]:
assert updated_values == {
**old_values,
"role": new_values["role"],
"max_role": new_values["role"],
}
else:
assert updated_values == old_values
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_update_owner_self(
def test_api_document_accesses_update_owner_self_root(
via,
mock_user_teams,
mock_reset_connections, # pylint: disable=redefined-outer-name
@@ -751,6 +1027,51 @@ def test_api_document_accesses_update_owner_self(
assert access.role == new_role
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_update_owner_self_child(
via,
mock_user_teams,
mock_reset_connections, # pylint: disable=redefined-outer-name
):
"""
A user who is owner of a document should be allowed to update
their own user access even if they are the only owner in the document,
provided the document is not a root.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
parent = factories.DocumentFactory()
document = factories.DocumentFactory(parent=parent)
access = None
if via == USER:
access = factories.UserDocumentAccessFactory(
document=document, user=user, role="owner"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
access = factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="owner"
)
old_values = serializers.DocumentAccessSerializer(instance=access).data
new_role = random.choice(["administrator", "editor", "reader"])
user_id = str(access.user_id) if via == USER else None
with mock_reset_connections(document.id, user_id):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data={**old_values, "role": new_role},
format="json",
)
assert response.status_code == 200
access.refresh_from_db()
assert access.role == new_role
# Delete
@@ -931,17 +1252,16 @@ def test_api_document_accesses_delete_owners(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert models.DocumentAccess.objects.count() == 1
assert response.status_code == 204
assert models.DocumentAccess.objects.count() == 1
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_delete_owners_last_owner(via, mock_user_teams):
def test_api_document_accesses_delete_owners_last_owner_root(via, mock_user_teams):
"""
It should not be possible to delete the last owner access from a document
It should not be possible to delete the last owner access from a root document
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -964,3 +1284,63 @@ def test_api_document_accesses_delete_owners_last_owner(via, mock_user_teams):
assert response.status_code == 403
assert models.DocumentAccess.objects.count() == 2
def test_api_document_accesses_delete_owners_last_owner_child_user(
mock_reset_connections, # pylint: disable=redefined-outer-name
):
"""
It should be possible to delete the last owner access from a document that is not a root.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
parent = factories.DocumentFactory()
document = factories.DocumentFactory(parent=parent)
access = None
access = factories.UserDocumentAccessFactory(
document=document, user=user, role="owner"
)
assert models.DocumentAccess.objects.count() == 2
with mock_reset_connections(document.id, str(access.user_id)):
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert models.DocumentAccess.objects.count() == 1
@pytest.mark.skip(
reason="Pending fix on https://github.com/suitenumerique/docs/issues/969"
)
def test_api_document_accesses_delete_owners_last_owner_child_team(
mock_user_teams,
mock_reset_connections, # pylint: disable=redefined-outer-name
):
"""
It should be possible to delete the last owner access from a document that
is not a root.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
parent = factories.DocumentFactory()
document = factories.DocumentFactory(parent=parent)
access = None
mock_user_teams.return_value = ["lasuite", "unknown"]
access = factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="owner"
)
assert models.DocumentAccess.objects.count() == 2
with mock_reset_connections(document.id, str(access.user_id)):
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert models.DocumentAccess.objects.count() == 1
@@ -103,32 +103,37 @@ def test_api_document_accesses_create_authenticated_reader_or_editor(
assert not models.DocumentAccess.objects.filter(user=other_user).exists()
@pytest.mark.parametrize("depth", [1, 2, 3])
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_create_authenticated_administrator(via, mock_user_teams):
def test_api_document_accesses_create_authenticated_administrator_share_to_user(
via, depth, mock_user_teams
):
"""
Administrators of a document should be able to create document accesses
except for the "owner" role.
Administrators of a document (direct or by heritage) should be able to create
document accesses except for the "owner" role.
An email should be sent to the accesses to notify them of the adding.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
documents = []
for i in range(depth):
parent = documents[i - 1] if i > 0 else None
documents.append(factories.DocumentFactory(parent=parent))
if via == USER:
factories.UserDocumentAccessFactory(
document=document, user=user, role="administrator"
document=documents[0], user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="administrator"
document=documents[0], team="lasuite", role="administrator"
)
other_user = factories.UserFactory(language="en-us")
# It should not be allowed to create an owner access
document = documents[-1]
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
@@ -140,7 +145,7 @@ def test_api_document_accesses_create_authenticated_administrator(via, mock_user
assert response.status_code == 403
assert response.json() == {
"detail": "Only owners of a resource can assign other users as owners."
"detail": "Only owners of a document can assign other users as owners."
}
# It should be allowed to create a lower access
@@ -165,9 +170,16 @@ def test_api_document_accesses_create_authenticated_administrator(via, mock_user
other_user = serializers.UserSerializer(instance=other_user).data
assert response.json() == {
"abilities": new_document_access.get_abilities(user),
"document": {
"id": str(new_document_access.document_id),
"depth": new_document_access.document.depth,
"path": new_document_access.document.path,
},
"id": str(new_document_access.id),
"team": "",
"max_ancestors_role": None,
"max_role": role,
"role": role,
"team": "",
"user": other_user,
}
assert len(mail.outbox) == 1
@@ -182,28 +194,119 @@ def test_api_document_accesses_create_authenticated_administrator(via, mock_user
assert "docs/" + str(document.id) + "/" in email_content
@pytest.mark.parametrize("depth", [1, 2, 3])
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_create_authenticated_owner(via, mock_user_teams):
def test_api_document_accesses_create_authenticated_administrator_share_to_team(
via, depth, mock_user_teams
):
"""
Owners of a document should be able to create document accesses whatever the role.
Administrators of a document (direct or by heritage) should be able to create
document accesses except for the "owner" role.
An email should be sent to the accesses to notify them of the adding.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
documents = []
for i in range(depth):
parent = documents[i - 1] if i > 0 else None
documents.append(factories.DocumentFactory(parent=parent))
if via == USER:
factories.UserDocumentAccessFactory(
document=documents[0], user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=documents[0], team="lasuite", role="administrator"
)
other_user = factories.UserFactory(language="en-us")
document = documents[-1]
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"team": "new-team",
"role": "owner",
},
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "Only owners of a document can assign other users as owners."
}
# It should be allowed to create a lower access
role = random.choice(
[role[0] for role in models.RoleChoices.choices if role[0] != "owner"]
)
assert len(mail.outbox) == 0
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"team": "new-team",
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.DocumentAccess.objects.filter(team="new-team").count() == 1
new_document_access = models.DocumentAccess.objects.filter(team="new-team").get()
other_user = serializers.UserSerializer(instance=other_user).data
assert response.json() == {
"abilities": new_document_access.get_abilities(user),
"document": {
"id": str(new_document_access.document_id),
"depth": new_document_access.document.depth,
"path": new_document_access.document.path,
},
"id": str(new_document_access.id),
"max_ancestors_role": None,
"max_role": role,
"role": role,
"team": "new-team",
"user": None,
}
assert len(mail.outbox) == 0
@pytest.mark.parametrize("depth", [1, 2, 3])
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_create_authenticated_owner_share_to_user(
via, depth, mock_user_teams
):
"""
Owners of a document (direct or by heritage) should be able to create document accesses
whatever the role. An email should be sent to the accesses to notify them of the adding.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
documents = []
for i in range(depth):
parent = documents[i - 1] if i > 0 else None
documents.append(factories.DocumentFactory(parent=parent))
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
factories.UserDocumentAccessFactory(
document=documents[0], user=user, role="owner"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="owner"
document=documents[0], team="lasuite", role="owner"
)
other_user = factories.UserFactory(language="en-us")
document = documents[-1]
role = random.choice([role[0] for role in models.RoleChoices.choices])
assert len(mail.outbox) == 0
@@ -222,11 +325,18 @@ def test_api_document_accesses_create_authenticated_owner(via, mock_user_teams):
new_document_access = models.DocumentAccess.objects.filter(user=other_user).get()
other_user = serializers.UserSerializer(instance=other_user).data
assert response.json() == {
"id": str(new_document_access.id),
"user": other_user,
"team": "",
"role": role,
"abilities": new_document_access.get_abilities(user),
"document": {
"id": str(new_document_access.document_id),
"depth": new_document_access.document.depth,
"path": new_document_access.document.path,
},
"id": str(new_document_access.id),
"max_ancestors_role": None,
"max_role": role,
"role": role,
"team": "",
"user": other_user,
}
assert len(mail.outbox) == 1
email = mail.outbox[0]
@@ -240,6 +350,71 @@ def test_api_document_accesses_create_authenticated_owner(via, mock_user_teams):
assert "docs/" + str(document.id) + "/" in email_content
@pytest.mark.parametrize("depth", [1, 2, 3])
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_create_authenticated_owner_share_to_team(
via, depth, mock_user_teams
):
"""
Owners of a document (direct or by heritage) should be able to create document accesses
whatever the role. An email should be sent to the accesses to notify them of the adding.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
documents = []
for i in range(depth):
parent = documents[i - 1] if i > 0 else None
documents.append(factories.DocumentFactory(parent=parent))
if via == USER:
factories.UserDocumentAccessFactory(
document=documents[0], user=user, role="owner"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=documents[0], team="lasuite", role="owner"
)
other_user = factories.UserFactory(language="en-us")
document = documents[-1]
role = random.choice([role[0] for role in models.RoleChoices.choices])
assert len(mail.outbox) == 0
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"team": "new-team",
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.DocumentAccess.objects.filter(team="new-team").count() == 1
new_document_access = models.DocumentAccess.objects.filter(team="new-team").get()
other_user = serializers.UserSerializer(instance=other_user).data
assert response.json() == {
"abilities": new_document_access.get_abilities(user),
"document": {
"id": str(new_document_access.document_id),
"path": new_document_access.document.path,
"depth": new_document_access.document.depth,
},
"id": str(new_document_access.id),
"max_ancestors_role": None,
"max_role": role,
"role": role,
"team": "new-team",
"user": None,
}
assert len(mail.outbox) == 0
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_create_email_in_receivers_language(via, mock_user_teams):
"""
@@ -286,11 +461,18 @@ def test_api_document_accesses_create_email_in_receivers_language(via, mock_user
).get()
other_user_data = serializers.UserSerializer(instance=other_user).data
assert response.json() == {
"id": str(new_document_access.id),
"user": other_user_data,
"team": "",
"role": role,
"abilities": new_document_access.get_abilities(user),
"document": {
"id": str(new_document_access.document_id),
"path": new_document_access.document.path,
"depth": new_document_access.document.depth,
},
"id": str(new_document_access.id),
"max_ancestors_role": None,
"max_role": role,
"role": role,
"team": "",
"user": other_user_data,
}
assert len(mail.outbox) == index + 1
email = mail.outbox[index]
@@ -175,8 +175,11 @@ def test_api_documents_ai_transform_authenticated_success(mock_create, reach, ro
{
"role": "system",
"content": (
"Answer the prompt in markdown format. Preserve the language and markdown "
"formatting. Do not provide any other information. Preserve the language."
"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"},
@@ -249,8 +252,11 @@ def test_api_documents_ai_transform_success(mock_create, via, role, mock_user_te
{
"role": "system",
"content": (
"Answer the prompt in markdown format. Preserve the language and markdown "
"formatting. Do not provide any other information. Preserve the language."
"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"},
@@ -48,7 +48,7 @@ def test_api_documents_ask_for_access_create_authenticated():
An email should be sent to document owners and admins to notify them.
"""
owner_user = UserFactory(language="en-us")
admin_user = UserFactory(language="fr-fr")
admin_user = UserFactory(language="en-us")
document = DocumentFactory(
users=[
(owner_user, RoleChoices.OWNER),
@@ -97,6 +97,23 @@ def test_api_documents_ask_for_access_create_authenticated():
assert document.title.lower() in email_subject.lower()
def test_api_documents_ask_for_access_create_authenticated_non_root_document():
"""
Authenticated users should not be able to create a document ask for access on a non-root
document.
"""
parent = DocumentFactory()
child = DocumentFactory(parent=parent)
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(f"/api/v1.0/documents/{child.id}/ask-for-access/")
assert response.status_code == 404
def test_api_documents_ask_for_access_create_authenticated_specific_role():
"""
Authenticated users should be able to create a document ask for access with a specific role.
@@ -196,6 +213,20 @@ def test_api_documents_ask_for_access_list_authenticated():
}
def test_api_documents_ask_for_access_list_authenticated_non_root_document():
"""
Authenticated users should not be able to list document ask for access on a non-root document.
"""
parent = DocumentFactory()
child = DocumentFactory(parent=parent)
client = APIClient()
client.force_login(UserFactory())
response = client.get(f"/api/v1.0/documents/{child.id}/ask-for-access/")
assert response.status_code == 404
def test_api_documents_ask_for_access_list_authenticated_own_request():
"""Authenticated users should be able to list their own document ask for access."""
document = DocumentFactory()
@@ -289,7 +320,7 @@ def test_api_documents_ask_for_access_list_non_owner_or_admin(role):
}
@pytest.mark.parametrize("role", [RoleChoices.OWNER])
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
def test_api_documents_ask_for_access_list_owner_or_admin(role):
"""Owner or admin users should be able to list document ask for access."""
user = UserFactory()
@@ -329,6 +360,23 @@ def test_api_documents_ask_for_access_list_owner_or_admin(role):
}
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
def test_api_documents_ask_for_access_list_admin_non_root_document(role):
"""
Authenticated users should not be able to list document ask for access on a non-root document.
"""
user = UserFactory()
parent = DocumentFactory(users=[(user, role)])
child = DocumentFactory(parent=parent, users=[(user, role)])
DocumentAskForAccessFactory.create_batch(3, document=child, role=RoleChoices.READER)
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/documents/{child.id}/ask-for-access/")
assert response.status_code == 404
## Retrieve
@@ -415,6 +463,28 @@ def test_api_documents_ask_for_access_retrieve_owner_or_admin(role):
}
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
def test_api_documents_ask_for_access_retrieve_authenticated_non_root_document(role):
"""
Authenticated users should not be able to retrieve document ask for access on a non-root
document.
"""
user = UserFactory()
parent = DocumentFactory(users=[(user, role)])
child = DocumentFactory(parent=parent, users=[(user, role)])
document_ask_for_access = DocumentAskForAccessFactory(
document=child, role=RoleChoices.READER
)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{child.id}/ask-for-access/{document_ask_for_access.id}/"
)
assert response.status_code == 404
## Delete
@@ -487,6 +557,28 @@ def test_api_documents_ask_for_access_delete_owner_or_admin(role):
).exists()
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
def test_api_documents_ask_for_access_delete_authenticated_non_root_document(role):
"""
Authenticated users should not be able to delete document ask for access on a non-root
document.
"""
user = UserFactory()
parent = DocumentFactory(users=[(user, role)])
child = DocumentFactory(parent=parent, users=[(user, role)])
document_ask_for_access = DocumentAskForAccessFactory(
document=child, role=RoleChoices.READER
)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{child.id}/ask-for-access/{document_ask_for_access.id}/"
)
assert response.status_code == 404
## Accept
@@ -654,3 +746,25 @@ def test_api_documents_ask_for_access_accept_authenticated_owner_or_admin_update
).exists()
document_access.refresh_from_db()
assert document_access.role == RoleChoices.ADMIN
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
def test_api_documents_ask_for_access_accept_authenticated_non_root_document(role):
"""
Authenticated users should not be able to accept document ask for access on a non-root
document.
"""
user = UserFactory()
parent = DocumentFactory(users=[(user, role)])
child = DocumentFactory(parent=parent, users=[(user, role)])
document_ask_for_access = DocumentAskForAccessFactory(
document=child, role=RoleChoices.READER
)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{child.id}/ask-for-access/{document_ask_for_access.id}/accept/"
)
assert response.status_code == 404
@@ -0,0 +1,318 @@
"""Test the can_edit endpoint in the viewset DocumentViewSet."""
from django.core.cache import cache
import pytest
import responses
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
@responses.activate
@pytest.mark.parametrize("ws_not_connected_ready_only", [True, False])
@pytest.mark.parametrize("role", ["editor", "reader"])
def test_api_documents_can_edit_anonymous(settings, ws_not_connected_ready_only, role):
"""Anonymous users can edit documents when link_role is editor."""
document = factories.DocumentFactory(link_reach="public", link_role=role)
client = APIClient()
session_key = client.session.session_key
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = ws_not_connected_ready_only
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})
response = client.get(f"/api/v1.0/documents/{document.id!s}/can-edit/")
if role == "reader":
assert response.status_code == 401
else:
assert response.status_code == 200
assert response.json() == {"can_edit": True}
assert ws_resp.call_count == (1 if ws_not_connected_ready_only else 0)
@responses.activate
@pytest.mark.parametrize("ws_not_connected_ready_only", [True, False])
def test_api_documents_can_edit_authenticated_no_websocket(
settings, ws_not_connected_ready_only
):
"""
A user not connected to the websocket and no other user have already updated the document,
the document can be updated.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = ws_not_connected_ready_only
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
response = client.get(
f"/api/v1.0/documents/{document.id!s}/can-edit/",
)
assert response.status_code == 200
assert response.json() == {"can_edit": True}
assert ws_resp.call_count == (1 if ws_not_connected_ready_only else 0)
@responses.activate
def test_api_documents_can_edit_authenticated_no_websocket_user_already_editing(
settings,
):
"""
A user not connected to the websocket and another user have already updated the document,
the document can 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")])
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.get(
f"/api/v1.0/documents/{document.id!s}/can-edit/",
)
assert response.status_code == 200
assert response.json() == {"can_edit": False}
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_can_edit_no_websocket_other_user_connected_to_websocket(
settings,
):
"""
A user not connected to the websocket and another user is connected to the websocket,
the document can 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")])
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.get(
f"/api/v1.0/documents/{document.id!s}/can-edit/",
)
assert response.status_code == 200
assert response.json() == {"can_edit": False}
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_can_edit_user_connected_to_websocket(settings):
"""
A user connected to the websocket, the document can be updated.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": True})
assert cache.get(f"docs:no-websocket:{document.id}") is None
response = client.get(
f"/api/v1.0/documents/{document.id!s}/can-edit/",
)
assert response.status_code == 200
assert response.json() == {"can_edit": True}
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_can_edit_websocket_server_unreachable_fallback_to_no_websocket(
settings,
):
"""
When the websocket server is unreachable, the document can be updated like if the user was
not connected to the websocket.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
assert cache.get(f"docs:no-websocket:{document.id}") is None
response = client.get(
f"/api/v1.0/documents/{document.id!s}/can-edit/",
)
assert response.status_code == 200
assert response.json() == {"can_edit": True}
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_can_edit_websocket_server_unreachable_fallback_to_no_websocket_other_users(
settings,
):
"""
When the websocket server is unreachable, the behavior fallback to the no websocket one.
If an other user is already editing, the document can 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")])
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.get(
f"/api/v1.0/documents/{document.id!s}/can-edit/",
)
assert response.status_code == 200
assert response.json() == {"can_edit": False}
assert cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_can_edit_websocket_server_room_not_found(
settings,
):
"""
When the websocket server returns a 404, the document can be updated like if the user was
not connected to the websocket.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=404)
assert cache.get(f"docs:no-websocket:{document.id}") is None
response = client.get(
f"/api/v1.0/documents/{document.id!s}/can-edit/",
)
assert response.status_code == 200
assert response.json() == {"can_edit": True}
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_can_edit_websocket_server_room_not_found_other_already_editing(
settings,
):
"""
When the websocket server returns a 404 and another user is editing the document,
the response should be can-edit=False.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=404)
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
response = client.get(
f"/api/v1.0/documents/{document.id!s}/can-edit/",
)
assert response.status_code == 200
assert response.json() == {"can_edit": False}
assert ws_resp.call_count == 1
@@ -98,7 +98,9 @@ def test_api_documents_children_create_authenticated_success(reach, role, depth)
if i == 0:
document = factories.DocumentFactory(link_reach=reach, link_role=role)
else:
document = factories.DocumentFactory(parent=document, link_role="reader")
document = factories.DocumentFactory(
parent=document, link_reach="restricted"
)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/children/",
@@ -112,7 +114,8 @@ def test_api_documents_children_create_authenticated_success(reach, role, depth)
child = Document.objects.get(id=response.json()["id"])
assert child.title == "my child"
assert child.link_reach == "restricted"
assert child.accesses.filter(role="owner", user=user).exists()
# Access objects on the child are not necessary
assert child.accesses.exists() is False
@pytest.mark.parametrize("depth", [1, 2, 3])
@@ -180,7 +183,8 @@ def test_api_documents_children_create_related_success(role, depth):
child = Document.objects.get(id=response.json()["id"])
assert child.title == "my child"
assert child.link_reach == "restricted"
assert child.accesses.filter(role="owner", user=user).exists()
# Access objects on the child are not necessary
assert child.accesses.exists() is False
def test_api_documents_children_create_authenticated_title_null():
@@ -14,13 +14,18 @@ from core import factories
pytestmark = pytest.mark.django_db
def test_api_documents_children_list_anonymous_public_standalone():
def test_api_documents_children_list_anonymous_public_standalone(
django_assert_num_queries,
):
"""Anonymous users should be allowed to retrieve the children of a public document."""
document = factories.DocumentFactory(link_reach="public")
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1)
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/")
with django_assert_num_queries(8):
APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/")
with django_assert_num_queries(4):
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/")
assert response.status_code == 200
assert response.json() == {
@@ -30,6 +35,10 @@ def test_api_documents_children_list_anonymous_public_standalone():
"results": [
{
"abilities": child1.get_abilities(AnonymousUser()),
"ancestors_link_reach": "public",
"ancestors_link_role": document.link_role,
"computed_link_reach": "public",
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"depth": 2,
@@ -44,10 +53,14 @@ def test_api_documents_children_list_anonymous_public_standalone():
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
{
"abilities": child2.get_abilities(AnonymousUser()),
"ancestors_link_reach": "public",
"ancestors_link_role": document.link_role,
"computed_link_reach": "public",
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"depth": 2,
@@ -62,13 +75,13 @@ def test_api_documents_children_list_anonymous_public_standalone():
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
],
}
def test_api_documents_children_list_anonymous_public_parent():
def test_api_documents_children_list_anonymous_public_parent(django_assert_num_queries):
"""
Anonymous users should be allowed to retrieve the children of a document who
has a public ancestor.
@@ -83,7 +96,10 @@ def test_api_documents_children_list_anonymous_public_parent():
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1)
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/")
with django_assert_num_queries(9):
APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/")
with django_assert_num_queries(5):
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/")
assert response.status_code == 200
assert response.json() == {
@@ -93,6 +109,10 @@ def test_api_documents_children_list_anonymous_public_parent():
"results": [
{
"abilities": child1.get_abilities(AnonymousUser()),
"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),
"depth": 4,
@@ -107,10 +127,14 @@ def test_api_documents_children_list_anonymous_public_parent():
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
{
"abilities": child2.get_abilities(AnonymousUser()),
"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),
"depth": 4,
@@ -125,7 +149,7 @@ def test_api_documents_children_list_anonymous_public_parent():
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
],
}
@@ -149,7 +173,7 @@ def test_api_documents_children_list_anonymous_restricted_or_authenticated(reach
@pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_children_list_authenticated_unrelated_public_or_authenticated(
reach,
reach, django_assert_num_queries
):
"""
Authenticated users should be able to retrieve the children of a public/authenticated
@@ -163,9 +187,13 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/children/",
)
with django_assert_num_queries(9):
client.get(f"/api/v1.0/documents/{document.id!s}/children/")
with django_assert_num_queries(5):
response = client.get(
f"/api/v1.0/documents/{document.id!s}/children/",
)
assert response.status_code == 200
assert response.json() == {
"count": 2,
@@ -174,6 +202,10 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
"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),
"depth": 2,
@@ -188,10 +220,14 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"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),
"depth": 2,
@@ -206,7 +242,7 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
],
}
@@ -214,7 +250,7 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
@pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_children_list_authenticated_public_or_authenticated_parent(
reach,
reach, django_assert_num_queries
):
"""
Authenticated users should be allowed to retrieve the children of a document who
@@ -231,7 +267,11 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1)
response = client.get(f"/api/v1.0/documents/{document.id!s}/children/")
with django_assert_num_queries(10):
client.get(f"/api/v1.0/documents/{document.id!s}/children/")
with django_assert_num_queries(6):
response = client.get(f"/api/v1.0/documents/{document.id!s}/children/")
assert response.status_code == 200
assert response.json() == {
@@ -241,6 +281,10 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
"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),
"depth": 4,
@@ -255,10 +299,14 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
{
"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),
"depth": 4,
@@ -273,13 +321,15 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
],
}
def test_api_documents_children_list_authenticated_unrelated_restricted():
def test_api_documents_children_list_authenticated_unrelated_restricted(
django_assert_num_queries,
):
"""
Authenticated users should not be allowed to retrieve the children of a document that is
restricted and to which they are not related.
@@ -293,16 +343,20 @@ def test_api_documents_children_list_authenticated_unrelated_restricted():
child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/children/",
)
with django_assert_num_queries(2):
response = client.get(
f"/api/v1.0/documents/{document.id!s}/children/",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
def test_api_documents_children_list_authenticated_related_direct():
def test_api_documents_children_list_authenticated_related_direct(
django_assert_num_queries,
):
"""
Authenticated users should be allowed to retrieve the children of a document
to which they are directly related whatever the role.
@@ -319,10 +373,13 @@ def test_api_documents_children_list_authenticated_related_direct():
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/children/",
)
with django_assert_num_queries(9):
response = client.get(
f"/api/v1.0/documents/{document.id!s}/children/",
)
assert response.status_code == 200
link_role = None if document.link_reach == "restricted" else document.link_role
assert response.json() == {
"count": 2,
"next": None,
@@ -330,6 +387,10 @@ def test_api_documents_children_list_authenticated_related_direct():
"results": [
{
"abilities": child1.get_abilities(user),
"ancestors_link_reach": document.link_reach,
"ancestors_link_role": 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),
"depth": 2,
@@ -344,10 +405,14 @@ def test_api_documents_children_list_authenticated_related_direct():
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
"user_role": access.role,
},
{
"abilities": child2.get_abilities(user),
"ancestors_link_reach": document.link_reach,
"ancestors_link_role": 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),
"depth": 2,
@@ -362,13 +427,15 @@ def test_api_documents_children_list_authenticated_related_direct():
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
"user_role": access.role,
},
],
}
def test_api_documents_children_list_authenticated_related_parent():
def test_api_documents_children_list_authenticated_related_parent(
django_assert_num_queries,
):
"""
Authenticated users should be allowed to retrieve the children of a document if they
are related to one of its ancestors whatever the role.
@@ -389,9 +456,11 @@ def test_api_documents_children_list_authenticated_related_parent():
document=grand_parent, user=user
)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/children/",
)
with django_assert_num_queries(10):
response = client.get(
f"/api/v1.0/documents/{document.id!s}/children/",
)
assert response.status_code == 200
assert response.json() == {
"count": 2,
@@ -400,6 +469,10 @@ def test_api_documents_children_list_authenticated_related_parent():
"results": [
{
"abilities": child1.get_abilities(user),
"ancestors_link_reach": "restricted",
"ancestors_link_role": None,
"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),
"depth": 4,
@@ -414,10 +487,14 @@ def test_api_documents_children_list_authenticated_related_parent():
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [grand_parent_access.role],
"user_role": grand_parent_access.role,
},
{
"abilities": child2.get_abilities(user),
"ancestors_link_reach": "restricted",
"ancestors_link_role": None,
"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),
"depth": 4,
@@ -432,13 +509,15 @@ def test_api_documents_children_list_authenticated_related_parent():
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [grand_parent_access.role],
"user_role": grand_parent_access.role,
},
],
}
def test_api_documents_children_list_authenticated_related_child():
def test_api_documents_children_list_authenticated_related_child(
django_assert_num_queries,
):
"""
Authenticated users should not be allowed to retrieve all the children of a document
as a result of being related to one of its children.
@@ -454,16 +533,20 @@ def test_api_documents_children_list_authenticated_related_child():
factories.UserDocumentAccessFactory(document=child1, user=user)
factories.UserDocumentAccessFactory(document=document)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/children/",
)
with django_assert_num_queries(2):
response = client.get(
f"/api/v1.0/documents/{document.id!s}/children/",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
def test_api_documents_children_list_authenticated_related_team_none(mock_user_teams):
def test_api_documents_children_list_authenticated_related_team_none(
mock_user_teams, django_assert_num_queries
):
"""
Authenticated users should not be able to retrieve the children of a restricted document
related to teams in which the user is not.
@@ -480,7 +563,9 @@ def test_api_documents_children_list_authenticated_related_team_none(mock_user_t
factories.TeamDocumentAccessFactory(document=document, team="myteam")
response = client.get(f"/api/v1.0/documents/{document.id!s}/children/")
with django_assert_num_queries(2):
response = client.get(f"/api/v1.0/documents/{document.id!s}/children/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
@@ -488,7 +573,7 @@ def test_api_documents_children_list_authenticated_related_team_none(mock_user_t
def test_api_documents_children_list_authenticated_related_team_members(
mock_user_teams,
mock_user_teams, django_assert_num_queries
):
"""
Authenticated users should be allowed to retrieve the children of a document to which they
@@ -506,7 +591,8 @@ def test_api_documents_children_list_authenticated_related_team_members(
access = factories.TeamDocumentAccessFactory(document=document, team="myteam")
response = client.get(f"/api/v1.0/documents/{document.id!s}/children/")
with django_assert_num_queries(9):
response = client.get(f"/api/v1.0/documents/{document.id!s}/children/")
# pylint: disable=R0801
assert response.status_code == 200
@@ -517,6 +603,10 @@ def test_api_documents_children_list_authenticated_related_team_members(
"results": [
{
"abilities": child1.get_abilities(user),
"ancestors_link_reach": "restricted",
"ancestors_link_role": None,
"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),
"depth": 2,
@@ -531,10 +621,14 @@ def test_api_documents_children_list_authenticated_related_team_members(
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
"user_role": access.role,
},
{
"abilities": child2.get_abilities(user),
"ancestors_link_reach": "restricted",
"ancestors_link_role": None,
"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),
"depth": 2,
@@ -549,7 +643,7 @@ def test_api_documents_children_list_authenticated_related_team_members(
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
"user_role": access.role,
},
],
}
@@ -23,10 +23,10 @@ pytestmark = pytest.mark.django_db
@pytest.fixture
def mock_convert_md():
"""Mock YdocConverter.convert_markdown to return a converted content."""
"""Mock YdocConverter.convert to return a converted content."""
with patch.object(
YdocConverter,
"convert_markdown",
"convert",
return_value="Converted document content",
) as mock:
yield mock
@@ -32,6 +32,10 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
"results": [
{
"abilities": child1.get_abilities(AnonymousUser()),
"ancestors_link_reach": "public",
"ancestors_link_role": document.link_role,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"depth": 2,
@@ -46,10 +50,16 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
{
"abilities": grand_child.get_abilities(AnonymousUser()),
"ancestors_link_reach": "public",
"ancestors_link_role": "editor"
if (child1.link_reach == "public" and child1.link_role == "editor")
else document.link_role,
"computed_link_reach": "public",
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id),
"depth": 3,
@@ -64,10 +74,14 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
"path": grand_child.path,
"title": grand_child.title,
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
{
"abilities": child2.get_abilities(AnonymousUser()),
"ancestors_link_reach": "public",
"ancestors_link_role": document.link_role,
"computed_link_reach": "public",
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"depth": 2,
@@ -82,7 +96,7 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
],
}
@@ -115,6 +129,10 @@ def test_api_documents_descendants_list_anonymous_public_parent():
"results": [
{
"abilities": child1.get_abilities(AnonymousUser()),
"ancestors_link_reach": "public",
"ancestors_link_role": grand_parent.link_role,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"depth": 4,
@@ -129,10 +147,14 @@ def test_api_documents_descendants_list_anonymous_public_parent():
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"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),
"depth": 5,
@@ -147,10 +169,14 @@ def test_api_documents_descendants_list_anonymous_public_parent():
"path": grand_child.path,
"title": grand_child.title,
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"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),
"depth": 4,
@@ -165,7 +191,7 @@ def test_api_documents_descendants_list_anonymous_public_parent():
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
],
}
@@ -201,7 +227,9 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach)
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
child1, child2 = factories.DocumentFactory.create_batch(
2, parent=document, link_reach="restricted"
)
grand_child = factories.DocumentFactory(parent=child1)
factories.UserDocumentAccessFactory(document=child1)
@@ -217,6 +245,10 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
"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),
"depth": 2,
@@ -231,10 +263,14 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"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),
"depth": 3,
@@ -249,10 +285,14 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
"path": grand_child.path,
"title": grand_child.title,
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"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),
"depth": 2,
@@ -267,7 +307,7 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
],
}
@@ -289,7 +329,9 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
grand_parent = factories.DocumentFactory(link_reach=reach)
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
document = factories.DocumentFactory(link_reach="restricted", parent=parent)
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
child1, child2 = factories.DocumentFactory.create_batch(
2, parent=document, link_reach="restricted"
)
grand_child = factories.DocumentFactory(parent=child1)
factories.UserDocumentAccessFactory(document=child1)
@@ -304,6 +346,10 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
"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),
"depth": 4,
@@ -318,10 +364,14 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"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),
"depth": 5,
@@ -336,10 +386,14 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
"path": grand_child.path,
"title": grand_child.title,
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"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),
"depth": 4,
@@ -354,7 +408,7 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
],
}
@@ -414,6 +468,10 @@ def test_api_documents_descendants_list_authenticated_related_direct():
"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),
"depth": 2,
@@ -428,10 +486,14 @@ def test_api_documents_descendants_list_authenticated_related_direct():
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
"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),
"depth": 3,
@@ -446,10 +508,14 @@ def test_api_documents_descendants_list_authenticated_related_direct():
"path": grand_child.path,
"title": grand_child.title,
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
"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),
"depth": 2,
@@ -464,7 +530,7 @@ def test_api_documents_descendants_list_authenticated_related_direct():
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
"user_role": access.role,
},
],
}
@@ -504,6 +570,10 @@ def test_api_documents_descendants_list_authenticated_related_parent():
"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),
"depth": 4,
@@ -518,10 +588,14 @@ def test_api_documents_descendants_list_authenticated_related_parent():
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [grand_parent_access.role],
"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),
"depth": 5,
@@ -536,10 +610,14 @@ def test_api_documents_descendants_list_authenticated_related_parent():
"path": grand_child.path,
"title": grand_child.title,
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [grand_parent_access.role],
"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),
"depth": 4,
@@ -554,7 +632,7 @@ def test_api_documents_descendants_list_authenticated_related_parent():
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [grand_parent_access.role],
"user_role": grand_parent_access.role,
},
],
}
@@ -640,6 +718,10 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
"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),
"depth": 2,
@@ -654,10 +736,14 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
"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),
"depth": 3,
@@ -672,10 +758,14 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
"path": grand_child.path,
"title": grand_child.title,
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
"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),
"depth": 2,
@@ -690,7 +780,7 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
"user_role": access.role,
},
],
}
@@ -14,6 +14,7 @@ from django.utils import timezone
import pycrdt
import pytest
import requests
from freezegun import freeze_time
from rest_framework.test import APIClient
from core import factories, models
@@ -60,7 +61,7 @@ def test_api_documents_duplicate_forbidden():
def test_api_documents_duplicate_anonymous():
"""Anonymous users should not be able to duplicate documents even with read access."""
document = factories.DocumentFactory(link_reach="public")
document = factories.DocumentFactory(link_reach="public", link_role="reader")
response = APIClient().post(f"/api/v1.0/documents/{document.id!s}/duplicate/")
@@ -133,19 +134,21 @@ def test_api_documents_duplicate_success(index):
# Ensure access persists after the owner loses access to the original document
models.DocumentAccess.objects.filter(document=document).delete()
response = client.get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=image_refs[0][1]
)
now = timezone.now()
with freeze_time(now):
response = client.get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=image_refs[0][1]
)
assert response.status_code == 200
assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ")
authorization = response["Authorization"]
assert "AWS4-HMAC-SHA256 Credential=" in authorization
assert (
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
response = requests.get(
@@ -168,14 +171,17 @@ def test_api_documents_duplicate_success(index):
assert response.status_code == 403
def test_api_documents_duplicate_with_accesses():
"""Accesses should be duplicated if the user requests it specifically."""
@pytest.mark.parametrize("role", ["owner", "administrator"])
def test_api_documents_duplicate_with_accesses_admin(role):
"""
Accesses should be duplicated if the user requests it specifically and is owner or admin.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(
users=[user],
users=[(user, role)],
title="document with accesses",
)
user_access = factories.UserDocumentAccessFactory(document=document)
@@ -205,3 +211,85 @@ def test_api_documents_duplicate_with_accesses():
assert duplicated_accesses.get(user=user).role == "owner"
assert duplicated_accesses.get(user=user_access.user).role == user_access.role
assert duplicated_accesses.get(team=team_access.team).role == team_access.role
@pytest.mark.parametrize("role", ["editor", "reader"])
def test_api_documents_duplicate_with_accesses_non_admin(role):
"""
Accesses should not be duplicated if the user requests it specifically and is not owner
or admin.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(
users=[(user, role)],
title="document with accesses",
)
factories.UserDocumentAccessFactory(document=document)
factories.TeamDocumentAccessFactory(document=document)
# Duplicate the document via the API endpoint requesting to duplicate accesses
response = client.post(
f"/api/v1.0/documents/{document.id!s}/duplicate/",
{"with_accesses": True},
format="json",
)
assert response.status_code == 201
duplicated_document = models.Document.objects.get(id=response.json()["id"])
assert duplicated_document.title == "Copy of document with accesses"
assert duplicated_document.content == document.content
assert duplicated_document.link_reach == document.link_reach
assert duplicated_document.link_role == document.link_role
assert duplicated_document.creator == user
assert duplicated_document.duplicated_from == document
assert duplicated_document.attachments == []
# Check that accesses were duplicated and the user who did the duplicate is forced as owner
duplicated_accesses = duplicated_document.accesses
assert duplicated_accesses.count() == 1
assert duplicated_accesses.get(user=user).role == "owner"
@pytest.mark.parametrize("role", ["editor", "reader"])
def test_api_documents_duplicate_non_root_document(role):
"""
Non-root documents can be duplicated but without accesses.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "owner")])
child = factories.DocumentFactory(
parent=document, users=[(user, role)], title="document with accesses"
)
assert child.accesses.count() == 1
# Duplicate the document via the API endpoint requesting to duplicate accesses
response = client.post(
f"/api/v1.0/documents/{child.id!s}/duplicate/",
{"with_accesses": True},
format="json",
)
assert response.status_code == 201
duplicated_document = models.Document.objects.get(id=response.json()["id"])
assert duplicated_document.title == "Copy of document with accesses"
assert duplicated_document.content == child.content
assert duplicated_document.link_reach == child.link_reach
assert duplicated_document.link_role == child.link_role
assert duplicated_document.creator == user
assert duplicated_document.duplicated_from == child
assert duplicated_document.attachments == []
# No access should be created for non root documents
duplicated_accesses = duplicated_document.accesses
assert duplicated_accesses.count() == 0
assert duplicated_document.is_sibling_of(child)
assert duplicated_document.is_child_of(document)
@@ -45,7 +45,10 @@ def test_api_document_favorite_anonymous_user(method, reach):
],
)
def test_api_document_favorite_authenticated_post_allowed(reach, has_role):
"""Authenticated users should be able to mark a document as favorite using POST."""
"""
Authenticated users should be able to mark a document to which they have access
as favorite using POST.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach=reach)
client = APIClient()
@@ -69,7 +72,10 @@ def test_api_document_favorite_authenticated_post_allowed(reach, has_role):
def test_api_document_favorite_authenticated_post_forbidden():
"""Authenticated users should be able to mark a document as favorite using POST."""
"""
Authenticated users should not be allowed to mark a document to which they don't
have access as favorite using POST.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach="restricted")
client = APIClient()
@@ -41,8 +41,8 @@ def test_api_document_favorite_list_authenticated_with_favorite():
client = APIClient()
client.force_login(user)
# User don't have access to this document, let say it had access and this access has been
# removed. It should not be in the favorite list anymore.
# If the user doesn't have access to this document (e.g the user had access
# and this access was removed), it should not be in the favorite list anymore.
factories.DocumentFactory(favorited_by=[user])
document = factories.UserDocumentAccessFactory(
@@ -59,6 +59,10 @@ def test_api_document_favorite_list_authenticated_with_favorite():
"results": [
{
"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),
"content": document.content,
@@ -74,7 +78,7 @@ def test_api_document_favorite_list_authenticated_with_favorite():
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": ["reader"],
"user_role": "reader",
}
],
}
@@ -63,6 +63,10 @@ def test_api_documents_list_format():
assert results[0] == {
"id": str(document.id),
"abilities": document.get_abilities(user),
"ancestors_link_reach": None,
"ancestors_link_role": None,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 1,
@@ -76,7 +80,7 @@ def test_api_documents_list_format():
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
"user_role": access.role,
}
@@ -148,11 +152,11 @@ def test_api_documents_list_authenticated_direct(django_assert_num_queries):
str(child4_with_access.id),
}
with django_assert_num_queries(12):
with django_assert_num_queries(14):
response = client.get("/api/v1.0/documents/")
# nb_accesses should now be cached
with django_assert_num_queries(4):
with django_assert_num_queries(6):
response = client.get("/api/v1.0/documents/")
assert response.status_code == 200
@@ -268,11 +272,11 @@ def test_api_documents_list_authenticated_link_reach_public_or_authenticated(
expected_ids = {str(document1.id), str(document2.id), str(visible_child.id)}
with django_assert_num_queries(10):
with django_assert_num_queries(11):
response = client.get("/api/v1.0/documents/")
# nb_accesses should now be cached
with django_assert_num_queries(4):
with django_assert_num_queries(5):
response = client.get("/api/v1.0/documents/")
assert response.status_code == 200
@@ -312,6 +312,84 @@ def test_api_documents_list_filter_is_favorite_invalid():
assert len(results) == 5
# Filters: is_masked
def test_api_documents_list_filter_is_masked_true():
"""
Authenticated users should be able to filter documents they marked as masked.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(2, users=[user])
masked_documents = factories.DocumentFactory.create_batch(
3, users=[user], masked_by=[user]
)
unmasked_documents = factories.DocumentFactory.create_batch(2, users=[user])
for document in unmasked_documents:
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
response = client.get("/api/v1.0/documents/?is_masked=true")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 3
# Ensure all results are marked as masked by the current user
masked_documents_ids = [str(doc.id) for doc in masked_documents]
for result in results:
assert result["id"] in masked_documents_ids
def test_api_documents_list_filter_is_masked_false():
"""
Authenticated users should be able to filter documents they didn't mark as masked.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(2, users=[user])
masked_documents = factories.DocumentFactory.create_batch(
3, users=[user], masked_by=[user]
)
unmasked_documents = factories.DocumentFactory.create_batch(2, users=[user])
for document in unmasked_documents:
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
response = client.get("/api/v1.0/documents/?is_masked=false")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 4
# Ensure all results are not marked as masked by the current user
masked_documents_ids = [str(doc.id) for doc in masked_documents]
for result in results:
assert result["id"] not in masked_documents_ids
def test_api_documents_list_filter_is_masked_invalid():
"""Filtering with an invalid `is_masked` value should do nothing."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(2, users=[user])
factories.DocumentFactory.create_batch(3, users=[user], masked_by=[user])
unmasked_documents = factories.DocumentFactory.create_batch(2, users=[user])
for document in unmasked_documents:
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
response = client.get("/api/v1.0/documents/?is_masked=invalid")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 7
# Filters: title
@@ -0,0 +1,353 @@
"""Test mask document API endpoint for users in impress's core app."""
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize(
"reach",
[
"restricted",
"authenticated",
"public",
],
)
@pytest.mark.parametrize("method", ["post", "delete"])
def test_api_document_mask_anonymous_user(method, reach):
"""Anonymous users should not be able to mask/unmask documents."""
document = factories.DocumentFactory(link_reach=reach)
response = getattr(APIClient(), method)(
f"/api/v1.0/documents/{document.id!s}/mask/"
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
# Verify in database
assert models.LinkTrace.objects.exists() is False
@pytest.mark.parametrize(
"reach, has_role",
[
["restricted", True],
["authenticated", False],
["authenticated", True],
["public", False],
["public", True],
],
)
def test_api_document_mask_authenticated_post_allowed(reach, has_role):
"""Authenticated users should be able to mask a document to which they have access."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach)
if has_role:
models.DocumentAccess.objects.create(document=document, user=user)
# Try masking the document without a link trace
response = client.post(f"/api/v1.0/documents/{document.id!s}/mask/")
assert response.status_code == 400
assert response.json() == {"detail": "User never accessed this document before."}
assert not models.LinkTrace.objects.filter(document=document, user=user).exists()
models.LinkTrace.objects.create(document=document, user=user)
# Mask document
response = client.post(f"/api/v1.0/documents/{document.id!s}/mask/")
assert response.status_code == 201
assert response.json() == {"detail": "Document was masked"}
assert models.LinkTrace.objects.filter(
document=document, user=user, is_masked=True
).exists()
def test_api_document_mask_authenticated_post_forbidden():
"""
Authenticated users should no be allowed to mask a document
to which they don't have access.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted")
# Try masking
response = client.post(f"/api/v1.0/documents/{document.id!s}/mask/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
# Verify in database
assert (
models.LinkTrace.objects.filter(document=document, user=user).exists() is False
)
@pytest.mark.parametrize(
"reach, has_role",
[
["restricted", True],
["authenticated", False],
["authenticated", True],
["public", False],
["public", True],
],
)
def test_api_document_mask_authenticated_post_already_masked_allowed(reach, has_role):
"""POST should not create duplicate link trace if already marked."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach, masked_by=[user])
if has_role:
models.DocumentAccess.objects.create(document=document, user=user)
# Try masking again
response = client.post(f"/api/v1.0/documents/{document.id!s}/mask/")
assert response.status_code == 200
assert response.json() == {"detail": "Document was already masked"}
assert models.LinkTrace.objects.filter(
document=document, user=user, is_masked=True
).exists()
def test_api_document_mask_authenticated_post_already_masked_forbidden():
"""POST should not create duplicate masks if already marked."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted", masked_by=[user])
# Try masking again
response = client.post(f"/api/v1.0/documents/{document.id!s}/mask/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert models.LinkTrace.objects.filter(document=document, user=user).exists()
@pytest.mark.parametrize(
"reach, has_role",
[
["restricted", True],
["authenticated", False],
["authenticated", True],
["public", False],
["public", True],
],
)
def test_api_document_mask_authenticated_post_unmasked_allowed(reach, has_role):
"""POST should not create duplicate link trace if unmasked."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach)
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
if has_role:
models.DocumentAccess.objects.create(document=document, user=user)
# Try masking again
response = client.post(f"/api/v1.0/documents/{document.id!s}/mask/")
assert response.status_code == 201
assert response.json() == {"detail": "Document was masked"}
assert models.LinkTrace.objects.filter(
document=document, user=user, is_masked=True
).exists()
def test_api_document_mask_authenticated_post_unmasked_forbidden():
"""POST should not create duplicate masks if unmasked."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted")
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
# Try masking again
response = client.post(f"/api/v1.0/documents/{document.id!s}/mask/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert models.LinkTrace.objects.filter(
document=document, user=user, is_masked=False
).exists()
@pytest.mark.parametrize(
"reach, has_role",
[
["restricted", True],
["authenticated", False],
["authenticated", True],
["public", False],
["public", True],
],
)
def test_api_document_mask_authenticated_delete_allowed(reach, has_role):
"""Authenticated users should be able to unmask a document using DELETE."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach, masked_by=[user])
if has_role:
models.DocumentAccess.objects.create(document=document, user=user)
# Unmask document
response = client.delete(f"/api/v1.0/documents/{document.id!s}/mask/")
assert response.status_code == 204
assert response.content == b"" # No body
assert response.text == "" # Empty decoded text
assert "Content-Type" not in response.headers # No Content-Type for 204
assert models.LinkTrace.objects.filter(
document=document, user=user, is_masked=False
).exists()
def test_api_document_mask_authenticated_delete_forbidden():
"""
Authenticated users should not be allowed to unmask a document if
they don't have access to it.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted", masked_by=[user])
# Unmask document
response = client.delete(f"/api/v1.0/documents/{document.id!s}/mask/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert models.LinkTrace.objects.filter(
document=document, user=user, is_masked=True
).exists()
@pytest.mark.parametrize(
"reach, has_role",
[
["restricted", True],
["authenticated", False],
["authenticated", True],
["public", False],
["public", True],
],
)
def test_api_document_mask_authenticated_delete_not_masked_allowed(reach, has_role):
"""DELETE should be idempotent if the document is not masked."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach)
if has_role:
models.DocumentAccess.objects.create(document=document, user=user)
# Try unmasking the document without a link trace
response = client.delete(f"/api/v1.0/documents/{document.id!s}/mask/")
assert response.status_code == 400
assert response.json() == {"detail": "User never accessed this document before."}
assert not models.LinkTrace.objects.filter(document=document, user=user).exists()
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
# Unmask document
response = client.delete(f"/api/v1.0/documents/{document.id!s}/mask/")
assert response.status_code == 200
assert response.json() == {"detail": "Document was already not masked"}
assert models.LinkTrace.objects.filter(
document=document, user=user, is_masked=False
).exists()
def test_api_document_mask_authenticated_delete_not_masked_forbidden():
"""DELETE should be idempotent if the document is not masked."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted")
# Try to unmask when no entry exists
response = client.delete(f"/api/v1.0/documents/{document.id!s}/mask/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert (
models.LinkTrace.objects.filter(document=document, user=user).exists() is False
)
@pytest.mark.parametrize(
"reach, has_role",
[
["restricted", True],
["authenticated", False],
["authenticated", True],
["public", False],
["public", True],
],
)
def test_api_document_mask_authenticated_post_unmark_then_mark_again_allowed(
reach, has_role
):
"""A user should be able to mask, unmask, and mask a document again."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach)
if has_role:
models.DocumentAccess.objects.create(document=document, user=user)
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
url = f"/api/v1.0/documents/{document.id!s}/mask/"
# Mask document
response = client.post(url)
assert response.status_code == 201
# Unmask document
response = client.delete(url)
assert response.status_code == 204
assert response.content == b"" # No body
assert response.text == "" # Empty decoded text
assert "Content-Type" not in response.headers # No Content-Type for 204
# Mask document again
response = client.post(url)
assert response.status_code == 201
assert response.json() == {"detail": "Document was masked"}
assert models.LinkTrace.objects.filter(
document=document, user=user, is_masked=True
).exists()
@@ -12,6 +12,7 @@ from django.utils import timezone
import pytest
import requests
from freezegun import freeze_time
from rest_framework.test import APIClient
from core import factories, models
@@ -52,9 +53,11 @@ def test_api_documents_media_auth_anonymous_public():
factories.DocumentFactory(id=document_id, link_reach="public", attachments=[key])
original_url = f"http://localhost/media/{key:s}"
response = APIClient().get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
now = timezone.now()
with freeze_time(now):
response = APIClient().get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
@@ -64,7 +67,7 @@ def test_api_documents_media_auth_anonymous_public():
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
@@ -167,9 +170,11 @@ def test_api_documents_media_auth_anonymous_attachments():
parent = factories.DocumentFactory(link_reach="public")
factories.DocumentFactory(parent=parent, link_reach="restricted", attachments=[key])
response = APIClient().get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
)
now = timezone.now()
with freeze_time(now):
response = APIClient().get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
)
assert response.status_code == 200
@@ -179,7 +184,7 @@ def test_api_documents_media_auth_anonymous_attachments():
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
@@ -221,9 +226,11 @@ def test_api_documents_media_auth_authenticated_public_or_authenticated(reach):
factories.DocumentFactory(id=document_id, link_reach=reach, attachments=[key])
response = client.get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
)
now = timezone.now()
with freeze_time(now):
response = client.get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
)
assert response.status_code == 200
@@ -233,7 +240,7 @@ def test_api_documents_media_auth_authenticated_public_or_authenticated(reach):
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
@@ -307,9 +314,11 @@ def test_api_documents_media_auth_related(via, mock_user_teams):
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
response = client.get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
)
now = timezone.now()
with freeze_time(now):
response = client.get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
)
assert response.status_code == 200
@@ -319,7 +328,7 @@ def test_api_documents_media_auth_related(via, mock_user_teams):
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
@@ -373,10 +382,12 @@ def test_api_documents_media_auth_missing_status_metadata():
factories.DocumentFactory(id=document_id, link_reach="public", attachments=[key])
now = timezone.now()
original_url = f"http://localhost/media/{key:s}"
response = APIClient().get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
with freeze_time(now):
response = APIClient().get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
@@ -386,7 +397,7 @@ def test_api_documents_media_auth_missing_status_metadata():
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
@@ -124,8 +124,8 @@ def test_api_documents_move_authenticated_target_roles_mocked(
target_role, target_parent_role, position
):
"""
Authenticated users with insufficient permissions on the target document (or its
parent depending on the position chosen), should not be allowed to move documents.
Only authenticated users with sufficient permissions on the target document (or its
parent depending on the position chosen), should be allowed to move documents.
"""
user = factories.UserFactory()
@@ -208,6 +208,107 @@ def test_api_documents_move_authenticated_target_roles_mocked(
assert document.is_root() is True
def test_api_documents_move_authenticated_no_owner_user_and_team():
"""
Moving a document with no owner to the root of the tree should automatically declare
the owner of the previous root of the document as owner of the document itself.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
parent_owner = factories.UserFactory()
parent = factories.DocumentFactory(
users=[(parent_owner, "owner")], teams=[("lasuite", "owner")]
)
# A document with no owner
document = factories.DocumentFactory(parent=parent, users=[(user, "administrator")])
child = factories.DocumentFactory(parent=document)
target = factories.DocumentFactory()
response = client.post(
f"/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."}
assert list(target.get_siblings()) == [document, parent, target]
document.refresh_from_db()
assert list(document.get_children()) == [child]
assert document.accesses.count() == 3
assert document.accesses.get(user__isnull=False, role="owner").user == parent_owner
assert document.accesses.get(user__isnull=True, role="owner").team == "lasuite"
assert document.accesses.get(role="administrator").user == user
def test_api_documents_move_authenticated_no_owner_same_user():
"""
Moving a document should not fail if the user moving a document with no owner was
at the same time owner of the previous root and has a role on the document being moved.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
parent = factories.DocumentFactory(
users=[(user, "owner")], teams=[("lasuite", "owner")]
)
# A document with no owner
document = factories.DocumentFactory(parent=parent, users=[(user, "reader")])
child = factories.DocumentFactory(parent=document)
target = factories.DocumentFactory()
response = client.post(
f"/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."}
assert list(target.get_siblings()) == [document, parent, target]
document.refresh_from_db()
assert list(document.get_children()) == [child]
assert document.accesses.count() == 2
assert document.accesses.get(user__isnull=False, role="owner").user == user
assert document.accesses.get(user__isnull=True, role="owner").team == "lasuite"
def test_api_documents_move_authenticated_no_owner_same_team():
"""
Moving a document should not fail if the team that is owner of the document root was
already declared on the document with a different role.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
parent = factories.DocumentFactory(teams=[("lasuite", "owner")])
# A document with no owner but same team
document = factories.DocumentFactory(
parent=parent, users=[(user, "administrator")], teams=[("lasuite", "reader")]
)
child = factories.DocumentFactory(parent=document)
target = factories.DocumentFactory()
response = client.post(
f"/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."}
assert list(target.get_siblings()) == [document, parent, target]
document.refresh_from_db()
assert list(document.get_children()) == [child]
assert document.accesses.count() == 2
assert document.accesses.get(user__isnull=False, role="administrator").user == user
assert document.accesses.get(user__isnull=True, role="owner").team == "lasuite"
def test_api_documents_move_authenticated_deleted_document():
"""
It should not be possible to move a deleted document or its descendants, even
@@ -1,6 +1,7 @@
"""
Tests for Documents API endpoint in impress's core app: retrieve
"""
# pylint: disable=too-many-lines
import random
from datetime import timedelta
@@ -11,7 +12,7 @@ from django.utils import timezone
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core import choices, factories, models
pytestmark = pytest.mark.django_db
@@ -31,13 +32,14 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"ai_transform": False,
"ai_translate": False,
"attachment_upload": document.link_role == "editor",
"can_edit": document.link_role == "editor",
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"cors_proxy": True,
"descendants": True,
"destroy": False,
"duplicate": True,
"duplicate": False,
# Anonymous user can't favorite a document even with read access
"favorite": False,
"invite_owner": False,
@@ -45,8 +47,9 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": ["reader", "editor"],
"restricted": None,
},
"mask": False,
"media_auth": True,
"media_check": True,
"move": False,
@@ -59,6 +62,10 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"versions_list": False,
"versions_retrieve": False,
},
"ancestors_link_reach": None,
"ancestors_link_role": None,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
@@ -73,7 +80,7 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
}
@@ -91,6 +98,7 @@ def test_api_documents_retrieve_anonymous_public_parent():
assert response.status_code == 200
links = document.get_ancestors().values("link_reach", "link_role")
links_definition = choices.get_equivalent_link_definition(links)
assert response.json() == {
"id": str(document.id),
"abilities": {
@@ -99,18 +107,22 @@ def test_api_documents_retrieve_anonymous_public_parent():
"ai_transform": False,
"ai_translate": False,
"attachment_upload": grand_parent.link_role == "editor",
"can_edit": grand_parent.link_role == "editor",
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"descendants": True,
"cors_proxy": True,
"destroy": False,
"duplicate": True,
"duplicate": False,
# Anonymous user can't favorite a document even with read access
"favorite": False,
"invite_owner": False,
"link_configuration": False,
"link_select_options": models.LinkReachChoices.get_select_options(links),
"link_select_options": models.LinkReachChoices.get_select_options(
**links_definition
),
"mask": False,
"media_auth": True,
"media_check": True,
"move": False,
@@ -123,6 +135,10 @@ def test_api_documents_retrieve_anonymous_public_parent():
"versions_list": False,
"versions_retrieve": False,
},
"ancestors_link_reach": "public",
"ancestors_link_role": grand_parent.link_role,
"computed_link_reach": "public",
"computed_link_role": grand_parent.link_role,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
@@ -137,7 +153,7 @@ def test_api_documents_retrieve_anonymous_public_parent():
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
}
@@ -196,6 +212,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"ai_transform": document.link_role == "editor",
"ai_translate": document.link_role == "editor",
"attachment_upload": document.link_role == "editor",
"can_edit": document.link_role == "editor",
"children_create": document.link_role == "editor",
"children_list": True,
"collaboration_auth": True,
@@ -209,8 +226,9 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": ["reader", "editor"],
"restricted": None,
},
"mask": True,
"media_auth": True,
"media_check": True,
"move": False,
@@ -223,6 +241,10 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"versions_list": False,
"versions_retrieve": False,
},
"ancestors_link_reach": None,
"ancestors_link_role": None,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
@@ -237,7 +259,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
}
assert (
models.LinkTrace.objects.filter(document=document, user=user).exists() is True
@@ -263,6 +285,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
assert response.status_code == 200
links = document.get_ancestors().values("link_reach", "link_role")
links_definition = choices.get_equivalent_link_definition(links)
assert response.json() == {
"id": str(document.id),
"abilities": {
@@ -271,6 +294,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
"ai_transform": grand_parent.link_role == "editor",
"ai_translate": grand_parent.link_role == "editor",
"attachment_upload": grand_parent.link_role == "editor",
"can_edit": grand_parent.link_role == "editor",
"children_create": grand_parent.link_role == "editor",
"children_list": True,
"collaboration_auth": True,
@@ -281,10 +305,13 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
"favorite": True,
"invite_owner": False,
"link_configuration": False,
"link_select_options": models.LinkReachChoices.get_select_options(links),
"link_select_options": models.LinkReachChoices.get_select_options(
**links_definition
),
"mask": True,
"move": False,
"media_auth": True,
"media_check": True,
"move": False,
"partial_update": grand_parent.link_role == "editor",
"restore": False,
"retrieve": True,
@@ -294,6 +321,10 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
"versions_list": False,
"versions_retrieve": False,
},
"ancestors_link_reach": reach,
"ancestors_link_role": grand_parent.link_role,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
@@ -308,7 +339,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
}
@@ -404,6 +435,10 @@ def test_api_documents_retrieve_authenticated_related_direct():
assert response.json() == {
"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,
"content": document.content,
"creator": str(document.creator.id),
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
@@ -418,7 +453,7 @@ def test_api_documents_retrieve_authenticated_related_direct():
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
"user_role": access.role,
}
@@ -444,6 +479,7 @@ def test_api_documents_retrieve_authenticated_related_parent():
)
assert response.status_code == 200
links = document.get_ancestors().values("link_reach", "link_role")
link_definition = choices.get_equivalent_link_definition(links)
assert response.json() == {
"id": str(document.id),
"abilities": {
@@ -452,6 +488,7 @@ def test_api_documents_retrieve_authenticated_related_parent():
"ai_transform": access.role != "reader",
"ai_translate": access.role != "reader",
"attachment_upload": access.role != "reader",
"can_edit": access.role != "reader",
"children_create": access.role != "reader",
"children_list": True,
"collaboration_auth": True,
@@ -462,7 +499,10 @@ def test_api_documents_retrieve_authenticated_related_parent():
"favorite": True,
"invite_owner": access.role == "owner",
"link_configuration": access.role in ["administrator", "owner"],
"link_select_options": models.LinkReachChoices.get_select_options(links),
"link_select_options": models.LinkReachChoices.get_select_options(
**link_definition
),
"mask": True,
"media_auth": True,
"media_check": True,
"move": access.role in ["administrator", "owner"],
@@ -475,6 +515,10 @@ def test_api_documents_retrieve_authenticated_related_parent():
"versions_list": True,
"versions_retrieve": True,
},
"ancestors_link_reach": "restricted",
"ancestors_link_role": None,
"computed_link_reach": "restricted",
"computed_link_role": None,
"content": document.content,
"creator": str(document.creator.id),
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
@@ -489,7 +533,7 @@ def test_api_documents_retrieve_authenticated_related_parent():
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
"user_role": access.role,
}
@@ -585,16 +629,16 @@ def test_api_documents_retrieve_authenticated_related_team_none(mock_user_teams)
@pytest.mark.parametrize(
"teams,roles",
"teams,role",
[
[["readers"], ["reader"]],
[["unknown", "readers"], ["reader"]],
[["editors"], ["editor"]],
[["unknown", "editors"], ["editor"]],
[["readers"], "reader"],
[["unknown", "readers"], "reader"],
[["editors"], "editor"],
[["unknown", "editors"], "editor"],
],
)
def test_api_documents_retrieve_authenticated_related_team_members(
teams, roles, mock_user_teams
teams, role, mock_user_teams
):
"""
Authenticated users should be allowed to retrieve a document to which they
@@ -627,6 +671,10 @@ def test_api_documents_retrieve_authenticated_related_team_members(
assert response.json() == {
"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,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
@@ -641,20 +689,20 @@ def test_api_documents_retrieve_authenticated_related_team_members(
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": roles,
"user_role": role,
}
@pytest.mark.parametrize(
"teams,roles",
"teams,role",
[
[["administrators"], ["administrator"]],
[["editors", "administrators"], ["administrator", "editor"]],
[["unknown", "administrators"], ["administrator"]],
[["administrators"], "administrator"],
[["editors", "administrators"], "administrator"],
[["unknown", "administrators"], "administrator"],
],
)
def test_api_documents_retrieve_authenticated_related_team_administrators(
teams, roles, mock_user_teams
teams, role, mock_user_teams
):
"""
Authenticated users should be allowed to retrieve a document to which they
@@ -689,6 +737,10 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
assert response.json() == {
"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,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
@@ -703,21 +755,21 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": roles,
"user_role": role,
}
@pytest.mark.parametrize(
"teams,roles",
"teams,role",
[
[["owners"], ["owner"]],
[["owners", "administrators"], ["owner", "administrator"]],
[["members", "administrators", "owners"], ["owner", "administrator"]],
[["unknown", "owners"], ["owner"]],
[["owners"], "owner"],
[["owners", "administrators"], "owner"],
[["members", "administrators", "owners"], "owner"],
[["unknown", "owners"], "owner"],
],
)
def test_api_documents_retrieve_authenticated_related_team_owners(
teams, roles, mock_user_teams
teams, role, mock_user_teams
):
"""
Authenticated users should be allowed to retrieve a restricted document to which
@@ -751,6 +803,10 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
assert response.json() == {
"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,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
@@ -765,11 +821,11 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": roles,
"user_role": role,
}
def test_api_documents_retrieve_user_roles(django_assert_max_num_queries):
def test_api_documents_retrieve_user_role(django_assert_max_num_queries):
"""
Roles should be annotated on querysets taking into account all documents ancestors.
"""
@@ -792,15 +848,14 @@ def test_api_documents_retrieve_user_roles(django_assert_max_num_queries):
factories.UserDocumentAccessFactory(document=parent, user=user),
factories.UserDocumentAccessFactory(document=document, user=user),
)
expected_roles = {access.role for access in accesses}
expected_role = choices.RoleChoices.max(*[access.role for access in accesses])
with django_assert_max_num_queries(14):
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 200
user_roles = response.json()["user_roles"]
assert set(user_roles) == expected_roles
assert response.json()["user_role"] == expected_role
def test_api_documents_retrieve_numqueries_with_link_trace(django_assert_num_queries):
@@ -75,6 +75,7 @@ def test_api_documents_trashbin_format():
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"can_edit": True,
"children_create": True,
"children_list": True,
"collaboration_auth": True,
@@ -88,8 +89,9 @@ def test_api_documents_trashbin_format():
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": ["reader", "editor"],
"restricted": None,
},
"mask": True,
"media_auth": True,
"media_check": True,
"move": False, # Can't move a deleted document
@@ -102,6 +104,10 @@ def test_api_documents_trashbin_format():
"versions_list": True,
"versions_retrieve": True,
},
"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,
@@ -114,7 +120,7 @@ def test_api_documents_trashbin_format():
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": ["owner"],
"user_role": "owner",
}
@@ -32,13 +32,19 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
assert response.status_code == 200
assert response.json() == {
"abilities": parent.get_abilities(AnonymousUser()),
"ancestors_link_reach": parent.ancestors_link_reach,
"ancestors_link_role": parent.ancestors_link_role,
"children": [
{
"abilities": document.get_abilities(AnonymousUser()),
"children": [
{
"abilities": child.get_abilities(AnonymousUser()),
"ancestors_link_reach": child.ancestors_link_reach,
"ancestors_link_role": child.ancestors_link_role,
"children": [],
"computed_link_reach": child.computed_link_reach,
"computed_link_role": child.computed_link_role,
"created_at": child.created_at.isoformat().replace(
"+00:00", "Z"
),
@@ -57,9 +63,13 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
"updated_at": child.updated_at.isoformat().replace(
"+00:00", "Z"
),
"user_roles": [],
"user_role": None,
},
],
"ancestors_link_reach": document.ancestors_link_reach,
"ancestors_link_role": document.ancestors_link_role,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 2,
@@ -74,11 +84,15 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
{
"abilities": sibling1.get_abilities(AnonymousUser()),
"ancestors_link_reach": sibling1.ancestors_link_reach,
"ancestors_link_role": sibling1.ancestors_link_role,
"children": [],
"computed_link_reach": sibling1.computed_link_reach,
"computed_link_role": sibling1.computed_link_role,
"created_at": sibling1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(sibling1.creator.id),
"depth": 2,
@@ -93,11 +107,15 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
"path": sibling1.path,
"title": sibling1.title,
"updated_at": sibling1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
{
"abilities": sibling2.get_abilities(AnonymousUser()),
"ancestors_link_reach": sibling2.ancestors_link_reach,
"ancestors_link_role": sibling2.ancestors_link_role,
"children": [],
"computed_link_reach": sibling2.computed_link_reach,
"computed_link_role": sibling2.computed_link_role,
"created_at": sibling2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(sibling2.creator.id),
"depth": 2,
@@ -112,9 +130,11 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
"path": sibling2.path,
"title": sibling2.title,
"updated_at": sibling2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
],
"computed_link_reach": parent.computed_link_reach,
"computed_link_role": parent.computed_link_role,
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(parent.creator.id),
"depth": 1,
@@ -129,7 +149,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
"path": parent.path,
"title": parent.title,
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
}
@@ -163,18 +183,28 @@ def test_api_documents_tree_list_anonymous_public_parent():
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/tree/")
assert response.status_code == 200
assert response.json() == {
expected_tree = {
"abilities": grand_parent.get_abilities(AnonymousUser()),
"ancestors_link_reach": grand_parent.ancestors_link_reach,
"ancestors_link_role": grand_parent.ancestors_link_role,
"children": [
{
"abilities": parent.get_abilities(AnonymousUser()),
"ancestors_link_reach": parent.ancestors_link_reach,
"ancestors_link_role": parent.ancestors_link_role,
"children": [
{
"abilities": document.get_abilities(AnonymousUser()),
"ancestors_link_reach": document.ancestors_link_reach,
"ancestors_link_role": document.ancestors_link_role,
"children": [
{
"abilities": child.get_abilities(AnonymousUser()),
"ancestors_link_reach": child.ancestors_link_reach,
"ancestors_link_role": child.ancestors_link_role,
"children": [],
"computed_link_reach": child.computed_link_reach,
"computed_link_role": child.computed_link_role,
"created_at": child.created_at.isoformat().replace(
"+00:00", "Z"
),
@@ -193,9 +223,11 @@ def test_api_documents_tree_list_anonymous_public_parent():
"updated_at": child.updated_at.isoformat().replace(
"+00:00", "Z"
),
"user_roles": [],
"user_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"
),
@@ -214,11 +246,15 @@ def test_api_documents_tree_list_anonymous_public_parent():
"updated_at": document.updated_at.isoformat().replace(
"+00:00", "Z"
),
"user_roles": [],
"user_role": None,
},
{
"abilities": document_sibling.get_abilities(AnonymousUser()),
"ancestors_link_reach": document_sibling.ancestors_link_reach,
"ancestors_link_role": document_sibling.ancestors_link_role,
"children": [],
"computed_link_reach": document_sibling.computed_link_reach,
"computed_link_role": document_sibling.computed_link_role,
"created_at": document_sibling.created_at.isoformat().replace(
"+00:00", "Z"
),
@@ -237,9 +273,11 @@ def test_api_documents_tree_list_anonymous_public_parent():
"updated_at": document_sibling.updated_at.isoformat().replace(
"+00:00", "Z"
),
"user_roles": [],
"user_role": None,
},
],
"computed_link_reach": parent.computed_link_reach,
"computed_link_role": parent.computed_link_role,
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(parent.creator.id),
"depth": 3,
@@ -254,11 +292,15 @@ def test_api_documents_tree_list_anonymous_public_parent():
"path": parent.path,
"title": parent.title,
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
{
"abilities": parent_sibling.get_abilities(AnonymousUser()),
"ancestors_link_reach": parent_sibling.ancestors_link_reach,
"ancestors_link_role": parent_sibling.ancestors_link_role,
"children": [],
"computed_link_reach": parent_sibling.computed_link_reach,
"computed_link_role": parent_sibling.computed_link_role,
"created_at": parent_sibling.created_at.isoformat().replace(
"+00:00", "Z"
),
@@ -277,9 +319,11 @@ def test_api_documents_tree_list_anonymous_public_parent():
"updated_at": parent_sibling.updated_at.isoformat().replace(
"+00:00", "Z"
),
"user_roles": [],
"user_role": None,
},
],
"computed_link_reach": grand_parent.computed_link_reach,
"computed_link_role": grand_parent.computed_link_role,
"created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_parent.creator.id),
"depth": 2,
@@ -294,8 +338,9 @@ def test_api_documents_tree_list_anonymous_public_parent():
"path": grand_parent.path,
"title": grand_parent.title,
"updated_at": grand_parent.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
}
assert response.json() == expected_tree
@pytest.mark.parametrize("reach", ["restricted", "authenticated"])
@@ -341,13 +386,21 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
assert response.status_code == 200
assert response.json() == {
"abilities": parent.get_abilities(user),
"ancestors_link_reach": None,
"ancestors_link_role": None,
"children": [
{
"abilities": document.get_abilities(user),
"ancestors_link_reach": document.ancestors_link_reach,
"ancestors_link_role": document.ancestors_link_role,
"children": [
{
"abilities": child.get_abilities(user),
"ancestors_link_reach": child.ancestors_link_reach,
"ancestors_link_role": child.ancestors_link_role,
"children": [],
"computed_link_reach": child.computed_link_reach,
"computed_link_role": child.computed_link_role,
"created_at": child.created_at.isoformat().replace(
"+00:00", "Z"
),
@@ -366,9 +419,11 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
"updated_at": child.updated_at.isoformat().replace(
"+00:00", "Z"
),
"user_roles": [],
"user_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": 2,
@@ -383,11 +438,15 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
{
"abilities": sibling.get_abilities(user),
"ancestors_link_reach": sibling.ancestors_link_reach,
"ancestors_link_role": sibling.ancestors_link_role,
"children": [],
"computed_link_reach": sibling.computed_link_reach,
"computed_link_role": sibling.computed_link_role,
"created_at": sibling.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(sibling.creator.id),
"depth": 2,
@@ -402,9 +461,11 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
"path": sibling.path,
"title": sibling.title,
"updated_at": sibling.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
],
"computed_link_reach": parent.computed_link_reach,
"computed_link_role": parent.computed_link_role,
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(parent.creator.id),
"depth": 1,
@@ -419,7 +480,7 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
"path": parent.path,
"title": parent.title,
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
}
@@ -460,16 +521,26 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
assert response.status_code == 200
assert response.json() == {
"abilities": grand_parent.get_abilities(user),
"ancestors_link_reach": grand_parent.ancestors_link_reach,
"ancestors_link_role": grand_parent.ancestors_link_role,
"children": [
{
"abilities": parent.get_abilities(user),
"ancestors_link_reach": parent.ancestors_link_reach,
"ancestors_link_role": parent.ancestors_link_role,
"children": [
{
"abilities": document.get_abilities(user),
"ancestors_link_reach": document.ancestors_link_reach,
"ancestors_link_role": document.ancestors_link_role,
"children": [
{
"abilities": child.get_abilities(user),
"ancestors_link_reach": child.ancestors_link_reach,
"ancestors_link_role": child.ancestors_link_role,
"children": [],
"computed_link_reach": child.computed_link_reach,
"computed_link_role": child.computed_link_role,
"created_at": child.created_at.isoformat().replace(
"+00:00", "Z"
),
@@ -488,9 +559,11 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
"updated_at": child.updated_at.isoformat().replace(
"+00:00", "Z"
),
"user_roles": [],
"user_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"
),
@@ -509,11 +582,15 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
"updated_at": document.updated_at.isoformat().replace(
"+00:00", "Z"
),
"user_roles": [],
"user_role": None,
},
{
"abilities": document_sibling.get_abilities(user),
"ancestors_link_reach": document_sibling.ancestors_link_reach,
"ancestors_link_role": document_sibling.ancestors_link_role,
"children": [],
"computed_link_reach": document_sibling.computed_link_reach,
"computed_link_role": document_sibling.computed_link_role,
"created_at": document_sibling.created_at.isoformat().replace(
"+00:00", "Z"
),
@@ -532,9 +609,11 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
"updated_at": document_sibling.updated_at.isoformat().replace(
"+00:00", "Z"
),
"user_roles": [],
"user_role": None,
},
],
"computed_link_reach": parent.computed_link_reach,
"computed_link_role": parent.computed_link_role,
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(parent.creator.id),
"depth": 3,
@@ -549,11 +628,15 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
"path": parent.path,
"title": parent.title,
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
{
"abilities": parent_sibling.get_abilities(user),
"ancestors_link_reach": parent_sibling.ancestors_link_reach,
"ancestors_link_role": parent_sibling.ancestors_link_role,
"children": [],
"computed_link_reach": parent_sibling.computed_link_reach,
"computed_link_role": parent_sibling.computed_link_role,
"created_at": parent_sibling.created_at.isoformat().replace(
"+00:00", "Z"
),
@@ -572,9 +655,11 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
"updated_at": parent_sibling.updated_at.isoformat().replace(
"+00:00", "Z"
),
"user_roles": [],
"user_role": None,
},
],
"computed_link_reach": grand_parent.computed_link_reach,
"computed_link_role": grand_parent.computed_link_role,
"created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_parent.creator.id),
"depth": 2,
@@ -589,7 +674,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
"path": grand_parent.path,
"title": grand_parent.title,
"updated_at": grand_parent.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
}
@@ -639,13 +724,21 @@ def test_api_documents_tree_list_authenticated_related_direct():
assert response.status_code == 200
assert response.json() == {
"abilities": parent.get_abilities(user),
"ancestors_link_reach": parent.ancestors_link_reach,
"ancestors_link_role": parent.ancestors_link_role,
"children": [
{
"abilities": document.get_abilities(user),
"ancestors_link_reach": document.ancestors_link_reach,
"ancestors_link_role": document.ancestors_link_role,
"children": [
{
"abilities": child.get_abilities(user),
"ancestors_link_reach": child.ancestors_link_reach,
"ancestors_link_role": child.ancestors_link_role,
"children": [],
"computed_link_reach": child.computed_link_reach,
"computed_link_role": child.computed_link_role,
"created_at": child.created_at.isoformat().replace(
"+00:00", "Z"
),
@@ -664,9 +757,11 @@ def test_api_documents_tree_list_authenticated_related_direct():
"updated_at": child.updated_at.isoformat().replace(
"+00:00", "Z"
),
"user_roles": [access.role],
"user_role": access.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),
"depth": 2,
@@ -681,11 +776,15 @@ def test_api_documents_tree_list_authenticated_related_direct():
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
"user_role": access.role,
},
{
"abilities": sibling.get_abilities(user),
"ancestors_link_reach": sibling.ancestors_link_reach,
"ancestors_link_role": sibling.ancestors_link_role,
"children": [],
"computed_link_reach": sibling.computed_link_reach,
"computed_link_role": sibling.computed_link_role,
"created_at": sibling.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(sibling.creator.id),
"depth": 2,
@@ -700,9 +799,11 @@ def test_api_documents_tree_list_authenticated_related_direct():
"path": sibling.path,
"title": sibling.title,
"updated_at": sibling.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
"user_role": access.role,
},
],
"computed_link_reach": parent.computed_link_reach,
"computed_link_role": parent.computed_link_role,
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(parent.creator.id),
"depth": 1,
@@ -717,7 +818,7 @@ def test_api_documents_tree_list_authenticated_related_direct():
"path": parent.path,
"title": parent.title,
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
"user_role": access.role,
}
@@ -762,16 +863,26 @@ def test_api_documents_tree_list_authenticated_related_parent():
assert response.status_code == 200
assert response.json() == {
"abilities": grand_parent.get_abilities(user),
"ancestors_link_reach": grand_parent.ancestors_link_reach,
"ancestors_link_role": grand_parent.ancestors_link_role,
"children": [
{
"abilities": parent.get_abilities(user),
"ancestors_link_reach": parent.ancestors_link_reach,
"ancestors_link_role": parent.ancestors_link_role,
"children": [
{
"abilities": document.get_abilities(user),
"ancestors_link_reach": document.ancestors_link_reach,
"ancestors_link_role": document.ancestors_link_role,
"children": [
{
"abilities": child.get_abilities(user),
"ancestors_link_reach": child.ancestors_link_reach,
"ancestors_link_role": child.ancestors_link_role,
"computed_link_reach": child.computed_link_reach,
"children": [],
"computed_link_role": child.computed_link_role,
"created_at": child.created_at.isoformat().replace(
"+00:00", "Z"
),
@@ -790,9 +901,11 @@ def test_api_documents_tree_list_authenticated_related_parent():
"updated_at": child.updated_at.isoformat().replace(
"+00:00", "Z"
),
"user_roles": [access.role],
"user_role": access.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"
),
@@ -811,11 +924,15 @@ def test_api_documents_tree_list_authenticated_related_parent():
"updated_at": document.updated_at.isoformat().replace(
"+00:00", "Z"
),
"user_roles": [access.role],
"user_role": access.role,
},
{
"abilities": document_sibling.get_abilities(user),
"ancestors_link_reach": document_sibling.ancestors_link_reach,
"ancestors_link_role": document_sibling.ancestors_link_role,
"children": [],
"computed_link_reach": document_sibling.computed_link_reach,
"computed_link_role": document_sibling.computed_link_role,
"created_at": document_sibling.created_at.isoformat().replace(
"+00:00", "Z"
),
@@ -834,9 +951,11 @@ def test_api_documents_tree_list_authenticated_related_parent():
"updated_at": document_sibling.updated_at.isoformat().replace(
"+00:00", "Z"
),
"user_roles": [access.role],
"user_role": access.role,
},
],
"computed_link_reach": parent.computed_link_reach,
"computed_link_role": parent.computed_link_role,
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(parent.creator.id),
"depth": 3,
@@ -851,11 +970,15 @@ def test_api_documents_tree_list_authenticated_related_parent():
"path": parent.path,
"title": parent.title,
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
"user_role": access.role,
},
{
"abilities": parent_sibling.get_abilities(user),
"ancestors_link_reach": parent_sibling.ancestors_link_reach,
"ancestors_link_role": parent_sibling.ancestors_link_role,
"children": [],
"computed_link_reach": parent_sibling.computed_link_reach,
"computed_link_role": parent_sibling.computed_link_role,
"created_at": parent_sibling.created_at.isoformat().replace(
"+00:00", "Z"
),
@@ -874,9 +997,11 @@ def test_api_documents_tree_list_authenticated_related_parent():
"updated_at": parent_sibling.updated_at.isoformat().replace(
"+00:00", "Z"
),
"user_roles": [access.role],
"user_role": access.role,
},
],
"computed_link_reach": grand_parent.computed_link_reach,
"computed_link_role": grand_parent.computed_link_role,
"created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_parent.creator.id),
"depth": 2,
@@ -891,7 +1016,7 @@ def test_api_documents_tree_list_authenticated_related_parent():
"path": grand_parent.path,
"title": grand_parent.title,
"updated_at": grand_parent.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
"user_role": access.role,
}
@@ -949,13 +1074,21 @@ def test_api_documents_tree_list_authenticated_related_team_members(
assert response.status_code == 200
assert response.json() == {
"abilities": parent.get_abilities(user),
"ancestors_link_reach": None,
"ancestors_link_role": None,
"children": [
{
"abilities": document.get_abilities(user),
"ancestors_link_reach": "restricted",
"ancestors_link_role": None,
"children": [
{
"abilities": child.get_abilities(user),
"ancestors_link_reach": "restricted",
"ancestors_link_role": None,
"children": [],
"computed_link_reach": child.computed_link_reach,
"computed_link_role": child.computed_link_role,
"created_at": child.created_at.isoformat().replace(
"+00:00", "Z"
),
@@ -974,9 +1107,11 @@ def test_api_documents_tree_list_authenticated_related_team_members(
"updated_at": child.updated_at.isoformat().replace(
"+00:00", "Z"
),
"user_roles": [access.role],
"user_role": access.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),
"depth": 2,
@@ -991,11 +1126,15 @@ def test_api_documents_tree_list_authenticated_related_team_members(
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
"user_role": access.role,
},
{
"abilities": sibling.get_abilities(user),
"ancestors_link_reach": "restricted",
"ancestors_link_role": None,
"children": [],
"computed_link_reach": sibling.computed_link_reach,
"computed_link_role": sibling.computed_link_role,
"created_at": sibling.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(sibling.creator.id),
"depth": 2,
@@ -1010,9 +1149,11 @@ def test_api_documents_tree_list_authenticated_related_team_members(
"path": sibling.path,
"title": sibling.title,
"updated_at": sibling.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
"user_role": access.role,
},
],
"computed_link_reach": parent.computed_link_reach,
"computed_link_role": parent.computed_link_role,
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(parent.creator.id),
"depth": 1,
@@ -1027,5 +1168,5 @@ def test_api_documents_tree_list_authenticated_related_team_members(
"path": parent.path,
"title": parent.title,
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
"user_role": access.role,
}
@@ -5,8 +5,10 @@ Tests for Documents API endpoint in impress's core app: update
import random
from django.contrib.auth.models import AnonymousUser
from django.core.cache import cache
import pytest
import responses
from rest_framework.test import APIClient
from core import factories, models
@@ -44,6 +46,7 @@ def test_api_documents_update_anonymous_forbidden(reach, role, via_parent):
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = True
response = APIClient().put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
@@ -90,8 +93,9 @@ def test_api_documents_update_authenticated_unrelated_forbidden(
old_document_values = serializers.DocumentSerializer(instance=document).data
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
instance=factories.DocumentFactory(),
).data
new_document_values["websocket"] = True
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
@@ -141,8 +145,9 @@ def test_api_documents_update_anonymous_or_authenticated_unrelated(
old_document_values = serializers.DocumentSerializer(instance=document).data
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
instance=factories.DocumentFactory(),
).data
new_document_values["websocket"] = True
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
@@ -155,6 +160,10 @@ def test_api_documents_update_anonymous_or_authenticated_unrelated(
for key, value in document_values.items():
if key in [
"id",
"ancestors_link_reach",
"ancestors_link_role",
"computed_link_reach",
"computed_link_role",
"accesses",
"created_at",
"creator",
@@ -206,6 +215,7 @@ def test_api_documents_update_authenticated_reader(via, via_parent, mock_user_te
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = True
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
@@ -258,6 +268,7 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = True
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
@@ -270,6 +281,10 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
for key, value in document_values.items():
if key in [
"id",
"ancestors_link_reach",
"ancestors_link_role",
"computed_link_reach",
"computed_link_role",
"created_at",
"creator",
"depth",
@@ -287,6 +302,359 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
assert value == new_document_values[key]
@responses.activate
def test_api_documents_update_authenticated_no_websocket(settings):
"""
When a user updates 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_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = False
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
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
format="json",
)
assert response.status_code == 200
assert cache.get(f"docs:no-websocket:{document.id}") == session_key
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_update_authenticated_no_websocket_user_already_editing(settings):
"""
When a user updates 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_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = False
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.put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
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_update_no_websocket_other_user_connected_to_websocket(settings):
"""
When a user updates 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_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = False
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.put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
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_update_user_connected_to_websocket(settings):
"""
When a user updates the document, 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_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = False
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
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
format="json",
)
assert response.status_code == 200
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_update_websocket_server_unreachable_fallback_to_no_websocket(
settings,
):
"""
When the websocket server is unreachable, the document should be updated like if the user was
not connected to the websocket.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = False
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
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
format="json",
)
assert response.status_code == 200
assert cache.get(f"docs:no-websocket:{document.id}") == session_key
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_update_websocket_server_unreachable_fallback_to_no_websocket_other_users(
settings,
):
"""
When the websocket server is unreachable, the behavior fallback to the no websocket one.
If an other user is already editing, 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_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = False
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.put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
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_update_websocket_server_room_not_found_fallback_to_no_websocket_other_users(
settings,
):
"""
When the WebSocket server does not have the room created, the logic should fallback to
no-WebSocket. If another user is already editing, the update must be denied.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = False
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.put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
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_update_force_websocket_param_to_true(settings):
"""
When the websocket parameter is set to true, the document should be updated without any check.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = True
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
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
format="json",
)
assert response.status_code == 200
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 0
@responses.activate
def test_api_documents_update_feature_flag_disabled(settings):
"""
When the feature flag is disabled, the document should be updated without any check.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = False
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
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
format="json",
)
assert response.status_code == 200
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_update_administrator_or_owner_of_another(via, mock_user_teams):
"""
@@ -317,6 +685,7 @@ def test_api_documents_update_administrator_or_owner_of_another(via, mock_user_t
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = True
response = client.put(
f"/api/v1.0/documents/{other_document.id!s}/",
new_document_values,
@@ -50,7 +50,7 @@ def test_api_documents_update_new_attachment_keys_anonymous(django_assert_num_qu
with django_assert_num_queries(11):
response = APIClient().put(
f"/api/v1.0/documents/{document.id!s}/",
{"content": get_ydoc_with_mages(image_keys)},
{"content": get_ydoc_with_mages(image_keys), "websocket": True},
format="json",
)
assert response.status_code == 200
@@ -63,7 +63,7 @@ def test_api_documents_update_new_attachment_keys_anonymous(django_assert_num_qu
with django_assert_num_queries(7):
response = APIClient().put(
f"/api/v1.0/documents/{document.id!s}/",
{"content": get_ydoc_with_mages(image_keys[:2])},
{"content": get_ydoc_with_mages(image_keys[:2]), "websocket": True},
format="json",
)
assert response.status_code == 200
@@ -48,12 +48,7 @@ def test_api_template_accesses_list_authenticated_unrelated():
f"/api/v1.0/templates/{template.id!s}/accesses/",
)
assert response.status_code == 200
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
assert response.json() == []
@pytest.mark.parametrize("via", VIA)
@@ -96,8 +91,8 @@ def test_api_template_accesses_list_authenticated_related(via, mock_user_teams):
assert response.status_code == 200
content = response.json()
assert len(content["results"]) == 3
assert sorted(content["results"], key=lambda x: x["id"]) == sorted(
assert len(content) == 3
assert sorted(content, key=lambda x: x["id"]) == sorted(
[
{
"id": str(user_access.id),
@@ -133,7 +133,7 @@ def test_api_template_accesses_create_authenticated_administrator(via, mock_user
assert response.status_code == 403
assert response.json() == {
"detail": "Only owners of a resource can assign other users as owners."
"detail": "Only owners of a template can assign other users as owners."
}
# It should be allowed to create a lower access
+1 -1
View File
@@ -186,7 +186,7 @@ def test_api_users_list_query_short_queries():
"""
Queries shorter than 5 characters should return an empty result set.
"""
user = factories.UserFactory()
user = factories.UserFactory(email="paul@example.com")
client = APIClient()
client.force_login(user)
@@ -123,16 +123,22 @@ def test_models_document_access_get_abilities_for_owner_of_self_allowed():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["administrator", "editor", "reader"],
"set_role_to": ["reader", "editor", "administrator", "owner"],
}
def test_models_document_access_get_abilities_for_owner_of_self_last():
def test_models_document_access_get_abilities_for_owner_of_self_last_on_root(
django_assert_num_queries,
):
"""
Check abilities of self access for the owner of a document when there is only one owner left.
Check abilities of self access for the owner of a root document when there
is only one owner left.
"""
access = factories.UserDocumentAccessFactory(role="owner")
abilities = access.get_abilities(access.user)
with django_assert_num_queries(2):
abilities = access.get_abilities(access.user)
assert abilities == {
"destroy": False,
"retrieve": True,
@@ -142,6 +148,28 @@ def test_models_document_access_get_abilities_for_owner_of_self_last():
}
def test_models_document_access_get_abilities_for_owner_of_self_last_on_child(
django_assert_num_queries,
):
"""
Check abilities of self access for the owner of a child document when there
is only one owner left.
"""
parent = factories.DocumentFactory()
access = factories.UserDocumentAccessFactory(document__parent=parent, role="owner")
with django_assert_num_queries(1):
abilities = access.get_abilities(access.user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator", "owner"],
}
def test_models_document_access_get_abilities_for_owner_of_owner():
"""Check abilities of owner access for the owner of a document."""
access = factories.UserDocumentAccessFactory(role="owner")
@@ -155,7 +183,7 @@ def test_models_document_access_get_abilities_for_owner_of_owner():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["administrator", "editor", "reader"],
"set_role_to": ["reader", "editor", "administrator", "owner"],
}
@@ -172,7 +200,7 @@ def test_models_document_access_get_abilities_for_owner_of_administrator():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["owner", "editor", "reader"],
"set_role_to": ["reader", "editor", "administrator", "owner"],
}
@@ -189,7 +217,7 @@ def test_models_document_access_get_abilities_for_owner_of_editor():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["owner", "administrator", "reader"],
"set_role_to": ["reader", "editor", "administrator", "owner"],
}
@@ -206,7 +234,7 @@ def test_models_document_access_get_abilities_for_owner_of_reader():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["owner", "administrator", "editor"],
"set_role_to": ["reader", "editor", "administrator", "owner"],
}
@@ -243,7 +271,7 @@ def test_models_document_access_get_abilities_for_administrator_of_administrator
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["editor", "reader"],
"set_role_to": ["reader", "editor", "administrator"],
}
@@ -260,7 +288,7 @@ def test_models_document_access_get_abilities_for_administrator_of_editor():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["administrator", "reader"],
"set_role_to": ["reader", "editor", "administrator"],
}
@@ -277,7 +305,7 @@ def test_models_document_access_get_abilities_for_administrator_of_reader():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["administrator", "editor"],
"set_role_to": ["reader", "editor", "administrator"],
}
@@ -400,12 +428,12 @@ def test_models_document_access_get_abilities_for_reader_of_reader_user(
def test_models_document_access_get_abilities_preset_role(django_assert_num_queries):
"""No query is done if the role is preset, e.g., with a query annotation."""
"""No query is done if user roles are preset on the document, e.g., with a query annotation."""
access = factories.UserDocumentAccessFactory(role="reader")
user = factories.UserDocumentAccessFactory(
document=access.document, role="reader"
).user
access.user_roles = ["reader"]
access.set_user_roles_tuple(None, "reader")
with django_assert_num_queries(0):
abilities = access.get_abilities(user)
+124 -158
View File
@@ -155,6 +155,7 @@ def test_models_documents_get_abilities_forbidden(
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"can_edit": False,
"children_create": False,
"children_list": False,
"collaboration_auth": False,
@@ -164,6 +165,7 @@ def test_models_documents_get_abilities_forbidden(
"duplicate": False,
"favorite": False,
"invite_owner": False,
"mask": False,
"media_auth": False,
"media_check": False,
"move": False,
@@ -171,7 +173,7 @@ def test_models_documents_get_abilities_forbidden(
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": ["reader", "editor"],
"restricted": None,
},
"partial_update": False,
"restore": False,
@@ -216,21 +218,23 @@ def test_models_documents_get_abilities_reader(
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"can_edit": False,
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"descendants": True,
"cors_proxy": True,
"destroy": False,
"duplicate": True,
"duplicate": is_authenticated,
"favorite": is_authenticated,
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": ["reader", "editor"],
"restricted": None,
},
"mask": is_authenticated,
"media_auth": True,
"media_check": True,
"move": False,
@@ -252,7 +256,7 @@ def test_models_documents_get_abilities_reader(
assert all(
value is False
for key, value in document.get_abilities(user).items()
if key != "link_select_options"
if key not in ["link_select_options", "ancestors_links_definition"]
)
@@ -279,21 +283,23 @@ def test_models_documents_get_abilities_editor(
"ai_transform": is_authenticated,
"ai_translate": is_authenticated,
"attachment_upload": True,
"can_edit": True,
"children_create": is_authenticated,
"children_list": True,
"collaboration_auth": True,
"descendants": True,
"cors_proxy": True,
"destroy": False,
"duplicate": True,
"duplicate": is_authenticated,
"favorite": is_authenticated,
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": ["reader", "editor"],
"restricted": None,
},
"mask": is_authenticated,
"media_auth": True,
"media_check": True,
"move": False,
@@ -314,7 +320,7 @@ def test_models_documents_get_abilities_editor(
assert all(
value is False
for key, value in document.get_abilities(user).items()
if key != "link_select_options"
if key not in ["link_select_options", "ancestors_links_definition"]
)
@@ -331,6 +337,7 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"can_edit": True,
"children_create": True,
"children_list": True,
"collaboration_auth": True,
@@ -344,8 +351,9 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": ["reader", "editor"],
"restricted": None,
},
"mask": True,
"media_auth": True,
"media_check": True,
"move": True,
@@ -380,6 +388,7 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"can_edit": True,
"children_create": True,
"children_list": True,
"collaboration_auth": True,
@@ -393,8 +402,9 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": ["reader", "editor"],
"restricted": None,
},
"mask": True,
"media_auth": True,
"media_check": True,
"move": True,
@@ -415,7 +425,7 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
assert all(
value is False
for key, value in document.get_abilities(user).items()
if key != "link_select_options"
if key not in ["link_select_options", "ancestors_links_definition"]
)
@@ -432,6 +442,7 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"can_edit": True,
"children_create": True,
"children_list": True,
"collaboration_auth": True,
@@ -445,8 +456,9 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": ["reader", "editor"],
"restricted": None,
},
"mask": True,
"media_auth": True,
"media_check": True,
"move": False,
@@ -467,7 +479,7 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
assert all(
value is False
for key, value in document.get_abilities(user).items()
if key != "link_select_options"
if key not in ["link_select_options", "ancestors_links_definition"]
)
@@ -491,6 +503,7 @@ def test_models_documents_get_abilities_reader_user(
"ai_transform": access_from_link and ai_access_setting != "restricted",
"ai_translate": access_from_link and ai_access_setting != "restricted",
"attachment_upload": access_from_link,
"can_edit": access_from_link,
"children_create": access_from_link,
"children_list": True,
"collaboration_auth": True,
@@ -504,8 +517,9 @@ def test_models_documents_get_abilities_reader_user(
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": ["reader", "editor"],
"restricted": None,
},
"mask": True,
"media_auth": True,
"media_check": True,
"move": False,
@@ -528,7 +542,7 @@ def test_models_documents_get_abilities_reader_user(
assert all(
value is False
for key, value in document.get_abilities(user).items()
if key != "link_select_options"
if key not in ["link_select_options", "ancestors_links_definition"]
)
@@ -548,6 +562,7 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"can_edit": False,
"children_create": False,
"children_list": True,
"collaboration_auth": True,
@@ -561,8 +576,9 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": ["reader", "editor"],
"restricted": None,
},
"mask": True,
"media_auth": True,
"media_check": True,
"move": False,
@@ -1176,184 +1192,134 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
@pytest.mark.parametrize(
"ancestors_links, select_options",
"reach, role, select_options",
[
# One ancestor
(
[{"link_reach": "public", "link_role": "reader"}],
"public",
"reader",
{
"restricted": ["editor"],
"public": ["reader", "editor"],
},
),
("public", "editor", {"public": ["editor"]}),
(
"authenticated",
"reader",
{
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
},
),
(
"authenticated",
"editor",
{"authenticated": ["editor"], "public": ["editor"]},
),
(
"restricted",
"reader",
{
"restricted": None,
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
},
),
(
"restricted",
"editor",
{
"restricted": None,
"authenticated": ["editor"],
"public": ["reader", "editor"],
"public": ["editor"],
},
),
([{"link_reach": "public", "link_role": "editor"}], {"public": ["editor"]}),
# Edge cases
(
[{"link_reach": "authenticated", "link_role": "reader"}],
"public",
None,
{
"restricted": ["editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
},
),
(
[{"link_reach": "authenticated", "link_role": "editor"}],
{"authenticated": ["editor"], "public": ["reader", "editor"]},
),
(
[{"link_reach": "restricted", "link_role": "reader"}],
{
"restricted": ["reader", "editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
},
),
(
[{"link_reach": "restricted", "link_role": "editor"}],
{
"restricted": ["editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
},
),
# Multiple ancestors with different roles
(
[
{"link_reach": "public", "link_role": "reader"},
{"link_reach": "public", "link_role": "editor"},
],
{"public": ["editor"]},
),
(
[
{"link_reach": "authenticated", "link_role": "reader"},
{"link_reach": "authenticated", "link_role": "editor"},
],
{"authenticated": ["editor"], "public": ["reader", "editor"]},
),
(
[
{"link_reach": "restricted", "link_role": "reader"},
{"link_reach": "restricted", "link_role": "editor"},
],
{
"restricted": ["editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
},
),
# Multiple ancestors with different reaches
(
[
{"link_reach": "authenticated", "link_role": "reader"},
{"link_reach": "public", "link_role": "reader"},
],
{
"restricted": ["editor"],
"authenticated": ["editor"],
"public": ["reader", "editor"],
},
),
(
[
{"link_reach": "restricted", "link_role": "reader"},
{"link_reach": "authenticated", "link_role": "reader"},
{"link_reach": "public", "link_role": "reader"},
],
{
"restricted": ["editor"],
"authenticated": ["editor"],
"public": ["reader", "editor"],
},
),
# Multiple ancestors with mixed reaches and roles
(
[
{"link_reach": "authenticated", "link_role": "editor"},
{"link_reach": "public", "link_role": "reader"},
],
{"authenticated": ["editor"], "public": ["reader", "editor"]},
),
(
[
{"link_reach": "authenticated", "link_role": "reader"},
{"link_reach": "public", "link_role": "editor"},
],
{"public": ["editor"]},
),
(
[
{"link_reach": "restricted", "link_role": "editor"},
{"link_reach": "authenticated", "link_role": "reader"},
],
{
"restricted": ["editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
},
),
(
[
{"link_reach": "restricted", "link_role": "reader"},
{"link_reach": "authenticated", "link_role": "editor"},
],
{"authenticated": ["editor"], "public": ["reader", "editor"]},
),
# No ancestors (edge case)
(
[],
None,
"reader",
{
"public": ["reader", "editor"],
"authenticated": ["reader", "editor"],
"restricted": ["reader", "editor"],
"restricted": None,
},
),
(
None,
None,
{
"public": ["reader", "editor"],
"authenticated": ["reader", "editor"],
"restricted": None,
},
),
],
)
def test_models_documents_get_select_options(ancestors_links, select_options):
def test_models_documents_get_select_options(reach, role, select_options):
"""Validate that the "get_select_options" method operates as expected."""
assert models.LinkReachChoices.get_select_options(ancestors_links) == select_options
assert models.LinkReachChoices.get_select_options(reach, role) == select_options
def test_models_documents_compute_ancestors_links_no_highest_readable():
"""Test the compute_ancestors_links method."""
document = factories.DocumentFactory(link_reach="public")
assert document.compute_ancestors_links(user=AnonymousUser()) == []
def test_models_documents_compute_ancestors_links_highest_readable(
def test_models_documents_compute_ancestors_links_paths_mapping_single(
django_assert_num_queries,
):
"""Test the compute_ancestors_links method."""
"""Test the compute_ancestors_links_paths_mapping method on a single document."""
document = factories.DocumentFactory(link_reach="public")
with django_assert_num_queries(1):
assert document.compute_ancestors_links_paths_mapping() == {
document.path: [{"link_reach": "public", "link_role": document.link_role}]
}
def test_models_documents_compute_ancestors_links_paths_mapping_structure(
django_assert_num_queries,
):
"""Test the compute_ancestors_links_paths_mapping method on a tree of documents."""
user = factories.UserFactory()
other_user = factories.UserFactory()
root = factories.DocumentFactory(
link_reach="restricted", link_role="reader", users=[user]
)
factories.DocumentFactory(
parent=root, link_reach="public", link_role="reader", users=[user]
)
child2 = factories.DocumentFactory(
root = factories.DocumentFactory(link_reach="restricted", users=[user])
document = factories.DocumentFactory(
parent=root,
link_reach="authenticated",
link_role="editor",
users=[user, other_user],
)
child3 = factories.DocumentFactory(
parent=child2,
sibling = factories.DocumentFactory(parent=root, link_reach="public", users=[user])
child = factories.DocumentFactory(
parent=document,
link_reach="authenticated",
link_role="reader",
users=[user, other_user],
)
with django_assert_num_queries(2):
assert child3.compute_ancestors_links(user=user) == [
{"link_reach": root.link_reach, "link_role": root.link_role},
{"link_reach": child2.link_reach, "link_role": child2.link_role},
]
# Child
with django_assert_num_queries(1):
assert child.compute_ancestors_links_paths_mapping() == {
root.path: [{"link_reach": "restricted", "link_role": root.link_role}],
document.path: [
{"link_reach": "restricted", "link_role": root.link_role},
{"link_reach": document.link_reach, "link_role": document.link_role},
],
child.path: [
{"link_reach": "restricted", "link_role": root.link_role},
{"link_reach": document.link_reach, "link_role": document.link_role},
{"link_reach": child.link_reach, "link_role": child.link_role},
],
}
with django_assert_num_queries(2):
assert child3.compute_ancestors_links(user=other_user) == [
{"link_reach": child2.link_reach, "link_role": child2.link_role},
]
# Sibling
with django_assert_num_queries(1):
assert sibling.compute_ancestors_links_paths_mapping() == {
root.path: [{"link_reach": "restricted", "link_role": root.link_role}],
sibling.path: [
{"link_reach": "restricted", "link_role": root.link_role},
{"link_reach": sibling.link_reach, "link_role": sibling.link_role},
],
}
@@ -1,13 +1,12 @@
"""Test converter services."""
from base64 import b64decode
from unittest.mock import MagicMock, patch
import pytest
import requests
from core.services.converter_services import (
InvalidResponseError,
MissingContentError,
ServiceUnavailableError,
ValidationError,
YdocConverter,
@@ -18,18 +17,18 @@ def test_auth_header(settings):
"""Test authentication header generation."""
settings.Y_PROVIDER_API_KEY = "test-key"
converter = YdocConverter()
assert converter.auth_header == "test-key"
assert converter.auth_header == "Bearer test-key"
def test_convert_markdown_empty_text():
def test_convert_empty_text():
"""Should raise ValidationError when text is empty."""
converter = YdocConverter()
with pytest.raises(ValidationError, match="Input text cannot be empty"):
converter.convert_markdown("")
converter.convert("")
@patch("requests.post")
def test_convert_markdown_service_unavailable(mock_post):
def test_convert_service_unavailable(mock_post):
"""Should raise ServiceUnavailableError when service is unavailable."""
converter = YdocConverter()
@@ -39,11 +38,11 @@ def test_convert_markdown_service_unavailable(mock_post):
ServiceUnavailableError,
match="Failed to connect to conversion service",
):
converter.convert_markdown("test text")
converter.convert("test text")
@patch("requests.post")
def test_convert_markdown_http_error(mock_post):
def test_convert_http_error(mock_post):
"""Should raise ServiceUnavailableError when HTTP error occurs."""
converter = YdocConverter()
@@ -55,46 +54,11 @@ def test_convert_markdown_http_error(mock_post):
ServiceUnavailableError,
match="Failed to connect to conversion service",
):
converter.convert_markdown("test text")
converter.convert("test text")
@patch("requests.post")
def test_convert_markdown_invalid_json_response(mock_post):
"""Should raise InvalidResponseError when response is not valid JSON."""
converter = YdocConverter()
mock_response = MagicMock()
mock_response.json.side_effect = ValueError("Invalid JSON")
mock_post.return_value = mock_response
with pytest.raises(
InvalidResponseError,
match="Could not parse conversion service response",
):
converter.convert_markdown("test text")
@patch("requests.post")
def test_convert_markdown_missing_content_field(mock_post, settings):
"""Should raise MissingContentError when response is missing required field."""
settings.CONVERSION_API_CONTENT_FIELD = "expected_field"
converter = YdocConverter()
mock_response = MagicMock()
mock_response.json.return_value = {"wrong_field": "content"}
mock_post.return_value = mock_response
with pytest.raises(
MissingContentError,
match="Response missing required field: expected_field",
):
converter.convert_markdown("test text")
@patch("requests.post")
def test_convert_markdown_full_integration(mock_post, settings):
def test_convert_full_integration(mock_post, settings):
"""Test full integration with all settings."""
settings.Y_PROVIDER_API_BASE_URL = "http://test.com/"
@@ -105,20 +69,21 @@ def test_convert_markdown_full_integration(mock_post, settings):
converter = YdocConverter()
expected_content = {"converted": "content"}
expected_content = b"converted content"
mock_response = MagicMock()
mock_response.json.return_value = {"content": expected_content}
mock_response.content = expected_content
mock_post.return_value = mock_response
result = converter.convert_markdown("test markdown")
result = converter.convert("test markdown")
assert b64decode(result) == expected_content
assert result == expected_content
mock_post.assert_called_once_with(
"http://test.com/conversion-endpoint/",
json={"content": "test markdown"},
data="test markdown",
headers={
"Authorization": "test-key",
"Content-Type": "application/json",
"Authorization": "Bearer test-key",
"Content-Type": "text/markdown",
},
timeout=5,
verify=False,
@@ -126,7 +91,7 @@ def test_convert_markdown_full_integration(mock_post, settings):
@patch("requests.post")
def test_convert_markdown_timeout(mock_post):
def test_convert_timeout(mock_post):
"""Should raise ServiceUnavailableError when request times out."""
converter = YdocConverter()
@@ -136,12 +101,12 @@ def test_convert_markdown_timeout(mock_post):
ServiceUnavailableError,
match="Failed to connect to conversion service",
):
converter.convert_markdown("test text")
converter.convert("test text")
def test_convert_markdown_none_input():
def test_convert_none_input():
"""Should raise ValidationError when input is None."""
converter = YdocConverter()
with pytest.raises(ValidationError, match="Input text cannot be empty"):
converter.convert_markdown(None)
converter.convert(None)
+9 -7
View File
@@ -291,6 +291,7 @@ class Base(Configuration):
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"core.middleware.ForceSessionMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"dockerflow.django.middleware.DockerflowMiddleware",
"csp.middleware.CSPMiddleware",
@@ -480,6 +481,7 @@ class Base(Configuration):
SESSION_COOKIE_AGE = values.PositiveIntegerValue(
default=60 * 60 * 12, environ_name="SESSION_COOKIE_AGE", environ_prefix=None
)
SESSION_COOKIE_NAME = "docs_sessionid"
# OIDC - Authorization Code Flow
OIDC_CREATE_USER = values.BooleanValue(
@@ -639,7 +641,7 @@ class Base(Configuration):
# Conversion endpoint
CONVERSION_API_ENDPOINT = values.Value(
default="convert-markdown",
default="convert",
environ_name="CONVERSION_API_ENDPOINT",
environ_prefix=None,
)
@@ -659,6 +661,12 @@ class Base(Configuration):
environ_prefix=None,
)
NO_WEBSOCKET_CACHE_TIMEOUT = values.Value(
default=120,
environ_name="NO_WEBSOCKET_CACHE_TIMEOUT",
environ_prefix=None,
)
# Logging
# We want to make it easy to log to console but by default we log production
# to Sentry and don't want to log to console.
@@ -853,15 +861,9 @@ class Development(Base):
CSRF_TRUSTED_ORIGINS = ["http://localhost:8072", "http://localhost:3000"]
DEBUG = True
SESSION_COOKIE_NAME = "impress_sessionid"
USE_SWAGGER = True
SESSION_CACHE_ALIAS = "session"
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.dummy.DummyCache",
},
"session": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": values.Value(
"redis://redis:6379/2",
+192 -165
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-05-22 12:09+0000\n"
"PO-Revision-Date: 2025-05-22 14:16\n"
"POT-Creation-Date: 2025-07-08 15:21+0000\n"
"PO-Revision-Date: 2025-07-18 10:25\n"
"Last-Translator: \n"
"Language-Team: Breton\n"
"Language: br_FR\n"
@@ -46,31 +46,61 @@ msgstr "Me eo an aozer"
msgid "Favorite"
msgstr "Sinedoù"
#: build/lib/core/api/serializers.py:446 core/api/serializers.py:446
#: build/lib/core/api/serializers.py:467 core/api/serializers.py:467
msgid "A new document was created on your behalf!"
msgstr "Ur restr nevez a zo bet krouet ganeoc'h!"
#: build/lib/core/api/serializers.py:450 core/api/serializers.py:450
#: build/lib/core/api/serializers.py:471 core/api/serializers.py:471
msgid "You have been granted ownership of a new document:"
msgstr "C'hwi zo bet disklaeriet perc'henn ur restr nevez:"
#: build/lib/core/api/serializers.py:586 core/api/serializers.py:586
#: build/lib/core/api/serializers.py:608 core/api/serializers.py:608
msgid "Body"
msgstr "Korf"
#: build/lib/core/api/serializers.py:589 core/api/serializers.py:589
#: build/lib/core/api/serializers.py:611 core/api/serializers.py:611
msgid "Body type"
msgstr "Doare korf"
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
#: build/lib/core/api/serializers.py:617 core/api/serializers.py:617
msgid "Format"
msgstr "Stumm"
#: build/lib/core/api/viewsets.py:967 core/api/viewsets.py:967
#: build/lib/core/api/viewsets.py:943 core/api/viewsets.py:943
#, python-brace-format
msgid "copy of {title}"
msgstr "eilenn {title}"
#: build/lib/core/choices.py:35 build/lib/core/choices.py:42 core/choices.py:35
#: core/choices.py:42
msgid "Reader"
msgstr "Lenner"
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
#: core/choices.py:43
msgid "Editor"
msgstr "Embanner"
#: build/lib/core/choices.py:44 core/choices.py:44
msgid "Administrator"
msgstr "Merour"
#: build/lib/core/choices.py:45 core/choices.py:45
msgid "Owner"
msgstr "Perc'henn"
#: build/lib/core/choices.py:56 core/choices.py:56
msgid "Restricted"
msgstr "Strishaet"
#: build/lib/core/choices.py:60 core/choices.py:60
msgid "Authenticated"
msgstr "Anavezet"
#: build/lib/core/choices.py:62 core/choices.py:62
msgid "Public"
msgstr "Publik"
#: build/lib/core/enums.py:36 core/enums.py:36
msgid "First child"
msgstr "Bugel kentañ"
@@ -81,11 +111,11 @@ msgstr "Bugel diwezhañ"
#: build/lib/core/enums.py:38 core/enums.py:38
msgid "First sibling"
msgstr ""
msgstr "Breur pe c'hoar kentañ"
#: build/lib/core/enums.py:39 core/enums.py:39
msgid "Last sibling"
msgstr ""
msgstr "Liamm diwezhañ"
#: build/lib/core/enums.py:40 core/enums.py:40
msgid "Left"
@@ -95,296 +125,293 @@ msgstr "Kleiz"
msgid "Right"
msgstr "Dehoù"
#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56
#: core/models.py:63
msgid "Reader"
msgstr "Lenner"
#: build/lib/core/models.py:57 build/lib/core/models.py:64 core/models.py:57
#: core/models.py:64
msgid "Editor"
msgstr ""
#: build/lib/core/models.py:65 core/models.py:65
msgid "Administrator"
msgstr "Merour"
#: build/lib/core/models.py:66 core/models.py:66
msgid "Owner"
msgstr "Perc'henn"
#: build/lib/core/models.py:77 core/models.py:77
msgid "Restricted"
msgstr ""
#: build/lib/core/models.py:81 core/models.py:81
msgid "Authenticated"
msgstr ""
#: build/lib/core/models.py:83 core/models.py:83
msgid "Public"
msgstr "Publik"
#: build/lib/core/models.py:154 core/models.py:154
#: build/lib/core/models.py:79 core/models.py:79
msgid "id"
msgstr "id"
#: build/lib/core/models.py:155 core/models.py:155
#: build/lib/core/models.py:80 core/models.py:80
msgid "primary key for the record as UUID"
msgstr ""
msgstr "alc'hwez kentañ evit an enrollañ evel UIID"
#: build/lib/core/models.py:161 core/models.py:161
#: build/lib/core/models.py:86 core/models.py:86
msgid "created on"
msgstr "krouet d'ar/al"
#: build/lib/core/models.py:162 core/models.py:162
#: build/lib/core/models.py:87 core/models.py:87
msgid "date and time at which a record was created"
msgstr ""
msgstr "deiziad hag eurvezh krouidigezh an enrolladenn"
#: build/lib/core/models.py:167 core/models.py:167
#: build/lib/core/models.py:92 core/models.py:92
msgid "updated on"
msgstr "hizivaet d'ar/al"
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:93 core/models.py:93
msgid "date and time at which a record was last updated"
msgstr ""
msgstr "deiziad hag eurvezh m'eo bet hizivaet an enrolladenn"
#: build/lib/core/models.py:204 core/models.py:204
#: build/lib/core/models.py:129 core/models.py:129
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
msgstr ""
msgstr "N'hon eus kavet implijer ebet gant an isstrollad-mañ met ar postel a zo liammet ouzh un implijer enrollet."
#: build/lib/core/models.py:217 core/models.py:217
#: build/lib/core/models.py:142 core/models.py:142
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr ""
msgstr "Ebarzhit un isstrollad mat. An talvoud-mañ a c'hall enderc'hel lizhiri, sifroù hag arouezioù @/./+/-/_/: hepken."
#: build/lib/core/models.py:223 core/models.py:223
#: build/lib/core/models.py:148 core/models.py:148
msgid "sub"
msgstr ""
msgstr "isstrollad"
#: build/lib/core/models.py:225 core/models.py:225
#: build/lib/core/models.py:150 core/models.py:150
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr ""
msgstr "Rekis. 255 arouezenn pe nebeutoc'h. Lizhiri, sifroù hag arouezioù @/./+/-/_/: hepken."
#: build/lib/core/models.py:234 core/models.py:234
#: build/lib/core/models.py:159 core/models.py:159
msgid "full name"
msgstr "anv klok"
#: build/lib/core/models.py:235 core/models.py:235
#: build/lib/core/models.py:160 core/models.py:160
msgid "short name"
msgstr "anv berr"
#: build/lib/core/models.py:237 core/models.py:237
#: build/lib/core/models.py:162 core/models.py:162
msgid "identity email address"
msgstr ""
msgstr "postel identelezh"
#: build/lib/core/models.py:242 core/models.py:242
#: build/lib/core/models.py:167 core/models.py:167
msgid "admin email address"
msgstr ""
msgstr "postel ar merour"
#: build/lib/core/models.py:249 core/models.py:249
#: build/lib/core/models.py:174 core/models.py:174
msgid "language"
msgstr "yezh"
#: build/lib/core/models.py:250 core/models.py:250
#: build/lib/core/models.py:175 core/models.py:175
msgid "The language in which the user wants to see the interface."
msgstr ""
msgstr "Ar yezh en deus c'hoant da welet an implijer an etrefas enni."
#: build/lib/core/models.py:258 core/models.py:258
#: build/lib/core/models.py:183 core/models.py:183
msgid "The timezone in which the user wants to see times."
msgstr ""
msgstr "Ar gwerzhid-eur en deus c'hoant da welet an implijer an eur drezañ."
#: build/lib/core/models.py:261 core/models.py:261
#: build/lib/core/models.py:186 core/models.py:186
msgid "device"
msgstr "trevnad"
#: build/lib/core/models.py:263 core/models.py:263
#: build/lib/core/models.py:188 core/models.py:188
msgid "Whether the user is a device or a real user."
msgstr ""
msgstr "Pe vefe an implijer un aparailh pe un implijer gwirion."
#: build/lib/core/models.py:266 core/models.py:266
#: build/lib/core/models.py:191 core/models.py:191
msgid "staff status"
msgstr ""
msgstr "statud ar skipailh"
#: build/lib/core/models.py:268 core/models.py:268
#: build/lib/core/models.py:193 core/models.py:193
msgid "Whether the user can log into this admin site."
msgstr ""
msgstr "Ma c'hall an implijer kevreañ ouzh al lec'hienn verañ-mañ."
#: build/lib/core/models.py:271 core/models.py:271
#: build/lib/core/models.py:196 core/models.py:196
msgid "active"
msgstr ""
msgstr "oberiant"
#: build/lib/core/models.py:274 core/models.py:274
#: build/lib/core/models.py:199 core/models.py:199
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
msgstr "Ma rank bezañ tretet an implijer-mañ evel oberiant. Diziuzit an dra-mañ e-plas dilemel kontoù."
#: build/lib/core/models.py:286 core/models.py:286
#: build/lib/core/models.py:211 core/models.py:211
msgid "user"
msgstr "implijer"
#: build/lib/core/models.py:287 core/models.py:287
#: build/lib/core/models.py:212 core/models.py:212
msgid "users"
msgstr "implijerien"
#: build/lib/core/models.py:470 build/lib/core/models.py:1155
#: core/models.py:470 core/models.py:1155
#: build/lib/core/models.py:368 build/lib/core/models.py:1281
#: core/models.py:368 core/models.py:1281
msgid "title"
msgstr "titl"
#: build/lib/core/models.py:471 core/models.py:471
#: build/lib/core/models.py:369 core/models.py:369
msgid "excerpt"
msgstr ""
msgstr "bomm"
#: build/lib/core/models.py:519 core/models.py:519
#: build/lib/core/models.py:418 core/models.py:418
msgid "Document"
msgstr ""
msgstr "Teul"
#: build/lib/core/models.py:520 core/models.py:520
#: build/lib/core/models.py:419 core/models.py:419
msgid "Documents"
msgstr ""
msgstr "Teulioù"
#: build/lib/core/models.py:532 build/lib/core/models.py:873 core/models.py:532
#: core/models.py:873
#: build/lib/core/models.py:431 build/lib/core/models.py:820 core/models.py:431
#: core/models.py:820
msgid "Untitled Document"
msgstr ""
msgstr "Teuliad hep titl"
#: build/lib/core/models.py:908 core/models.py:908
#: build/lib/core/models.py:855 core/models.py:855
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
msgstr "{name} en deus rannet un teul ganeoc'h!"
#: build/lib/core/models.py:912 core/models.py:912
#: build/lib/core/models.py:859 core/models.py:859
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
msgstr "{name} en deus pedet ac'hanoc'h gant ar rol \"{role}\" war an teul da-heul:"
#: build/lib/core/models.py:918 core/models.py:918
#: build/lib/core/models.py:865 core/models.py:865
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
msgstr "{name} en deus rannet un teul ganeoc'h: {title}"
#: build/lib/core/models.py:1016 core/models.py:1016
#: build/lib/core/models.py:964 core/models.py:964
msgid "Document/user link trace"
msgstr ""
msgstr "Roud liamm an teuliad/an implijer"
#: build/lib/core/models.py:1017 core/models.py:1017
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link traces"
msgstr ""
msgstr "Roudoù liamm an teuliad/an implijer"
#: build/lib/core/models.py:971 core/models.py:971
msgid "A link trace already exists for this document/user."
msgstr "Ur roud liamm a zo dija evit an teul/an implijer."
#: build/lib/core/models.py:994 core/models.py:994
msgid "Document favorite"
msgstr "Teuliad muiañ-karet"
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorites"
msgstr "Teuliadoù muiañ-karet"
#: build/lib/core/models.py:1001 core/models.py:1001
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "An teul-mañ a zo un teul muiañ karet gant an implijer-mañ."
#: build/lib/core/models.py:1023 core/models.py:1023
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:1046 core/models.py:1046
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:1047 core/models.py:1047
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1053 core/models.py:1053
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1075 core/models.py:1075
msgid "Document/user relation"
msgstr ""
msgstr "Liamm teul/implijer"
#: build/lib/core/models.py:1076 core/models.py:1076
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relations"
msgstr ""
msgstr "Liammoù teul/implijer"
#: build/lib/core/models.py:1082 core/models.py:1082
#: build/lib/core/models.py:1030 core/models.py:1030
msgid "This user is already in this document."
msgstr ""
msgstr "An implijer-mañ a zo dija en teul-mañ."
#: build/lib/core/models.py:1088 core/models.py:1088
#: build/lib/core/models.py:1036 core/models.py:1036
msgid "This team is already in this document."
msgstr ""
msgstr "Ar skipailh-mañ a zo dija en teul-mañ."
#: build/lib/core/models.py:1094 build/lib/core/models.py:1242
#: core/models.py:1094 core/models.py:1242
#: build/lib/core/models.py:1042 build/lib/core/models.py:1367
#: core/models.py:1042 core/models.py:1367
msgid "Either user or team must be set, not both."
msgstr ""
msgstr "An implijer pe ar skipailh a rank bezañ termenet, ket an daou avat."
#: build/lib/core/models.py:1156 core/models.py:1156
#: build/lib/core/models.py:1188 core/models.py:1188
msgid "Document ask for access"
msgstr "Goulenn tizhout an teul"
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for accesses"
msgstr "Goulennoù tizhout an teul"
#: build/lib/core/models.py:1195 core/models.py:1195
msgid "This user has already asked for access to this document."
msgstr "An implijer en deus goulennet tizhout an teul-mañ."
#: build/lib/core/models.py:1260 core/models.py:1260
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} en defe c'hoant da dizhout an teul-mañ!"
#: build/lib/core/models.py:1264 core/models.py:1264
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} en defe c'hoant da dizhout an teul da-heul:"
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} en defe c'hoant da dizhout an teul: {title}"
#: build/lib/core/models.py:1282 core/models.py:1282
msgid "description"
msgstr ""
msgstr "deskrivadur"
#: build/lib/core/models.py:1157 core/models.py:1157
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "code"
msgstr ""
msgstr "kod"
#: build/lib/core/models.py:1158 core/models.py:1158
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1160 core/models.py:1160
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "public"
msgstr "publik"
#: build/lib/core/models.py:1162 core/models.py:1162
#: build/lib/core/models.py:1288 core/models.py:1288
msgid "Whether this template is public for anyone to use."
msgstr ""
msgstr "M'eo foran ar patrom-mañ hag implijus gant n'eus forzh piv."
#: build/lib/core/models.py:1168 core/models.py:1168
#: build/lib/core/models.py:1294 core/models.py:1294
msgid "Template"
msgstr "Patrom"
#: build/lib/core/models.py:1169 core/models.py:1169
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Templates"
msgstr "Patromoù"
#: build/lib/core/models.py:1223 core/models.py:1223
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Template/user relation"
msgstr ""
msgstr "Liamm patrom/implijer"
#: build/lib/core/models.py:1224 core/models.py:1224
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relations"
msgstr ""
msgstr "Liammoù patrom/implijer"
#: build/lib/core/models.py:1230 core/models.py:1230
#: build/lib/core/models.py:1355 core/models.py:1355
msgid "This user is already in this template."
msgstr ""
msgstr "An implijer-mañ a zo dija er patrom-mañ."
#: build/lib/core/models.py:1236 core/models.py:1236
#: build/lib/core/models.py:1361 core/models.py:1361
msgid "This team is already in this template."
msgstr ""
msgstr "Ar skipailh-mañ a zo dija er patrom-mañ."
#: build/lib/core/models.py:1259 core/models.py:1259
#: build/lib/core/models.py:1438 core/models.py:1438
msgid "email address"
msgstr ""
msgstr "postel"
#: build/lib/core/models.py:1278 core/models.py:1278
#: build/lib/core/models.py:1457 core/models.py:1457
msgid "Document invitation"
msgstr ""
msgstr "Pedadenn d'un teul"
#: build/lib/core/models.py:1279 core/models.py:1279
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitations"
msgstr ""
msgstr "Pedadennoù d'un teul"
#: build/lib/core/models.py:1299 core/models.py:1299
#: build/lib/core/models.py:1478 core/models.py:1478
msgid "This email is already associated to a registered user."
msgstr ""
msgstr "Ar postel-mañ a zo liammet ouzh un implijer enskrivet."
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/html/template.html:162
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr ""
msgstr "Logo ar postel"
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
#: core/templates/mail/html/template.html:209
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr "Digeriñ"
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
#: core/templates/mail/html/template.html:226
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr ""
msgstr " Docs, hoc'h ostilh nevez ret-holl evit aozañ, rannañ ha kenlabourat war an teulioù e skipailh. "
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#: core/templates/mail/html/template.html:233
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr ""
msgstr " Kinniget gant %(brandname)s "
+135 -108
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-05-22 12:09+0000\n"
"PO-Revision-Date: 2025-05-22 14:16\n"
"POT-Creation-Date: 2025-07-08 15:21+0000\n"
"PO-Revision-Date: 2025-07-18 10:25\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de_DE\n"
@@ -46,31 +46,61 @@ msgstr "Ersteller bin ich"
msgid "Favorite"
msgstr "Favorit"
#: build/lib/core/api/serializers.py:446 core/api/serializers.py:446
#: build/lib/core/api/serializers.py:467 core/api/serializers.py:467
msgid "A new document was created on your behalf!"
msgstr "Ein neues Dokument wurde in Ihrem Namen erstellt!"
#: build/lib/core/api/serializers.py:450 core/api/serializers.py:450
#: build/lib/core/api/serializers.py:471 core/api/serializers.py:471
msgid "You have been granted ownership of a new document:"
msgstr "Sie sind Besitzer eines neuen Dokuments:"
#: build/lib/core/api/serializers.py:586 core/api/serializers.py:586
#: build/lib/core/api/serializers.py:608 core/api/serializers.py:608
msgid "Body"
msgstr "Inhalt"
#: build/lib/core/api/serializers.py:589 core/api/serializers.py:589
#: build/lib/core/api/serializers.py:611 core/api/serializers.py:611
msgid "Body type"
msgstr "Typ"
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
#: build/lib/core/api/serializers.py:617 core/api/serializers.py:617
msgid "Format"
msgstr "Format"
#: build/lib/core/api/viewsets.py:967 core/api/viewsets.py:967
#: build/lib/core/api/viewsets.py:943 core/api/viewsets.py:943
#, python-brace-format
msgid "copy of {title}"
msgstr "Kopie von {title}"
#: build/lib/core/choices.py:35 build/lib/core/choices.py:42 core/choices.py:35
#: core/choices.py:42
msgid "Reader"
msgstr "Lesen"
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
#: core/choices.py:43
msgid "Editor"
msgstr "Bearbeiten"
#: build/lib/core/choices.py:44 core/choices.py:44
msgid "Administrator"
msgstr "Administrator"
#: build/lib/core/choices.py:45 core/choices.py:45
msgid "Owner"
msgstr "Besitzer"
#: build/lib/core/choices.py:56 core/choices.py:56
msgid "Restricted"
msgstr "Beschränkt"
#: build/lib/core/choices.py:60 core/choices.py:60
msgid "Authenticated"
msgstr "Authentifiziert"
#: build/lib/core/choices.py:62 core/choices.py:62
msgid "Public"
msgstr "Öffentlich"
#: build/lib/core/enums.py:36 core/enums.py:36
msgid "First child"
msgstr "Erstes Unterelement"
@@ -95,295 +125,292 @@ msgstr "Links"
msgid "Right"
msgstr "Rechts"
#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56
#: core/models.py:63
msgid "Reader"
msgstr "Lesen"
#: build/lib/core/models.py:57 build/lib/core/models.py:64 core/models.py:57
#: core/models.py:64
msgid "Editor"
msgstr "Bearbeiten"
#: build/lib/core/models.py:65 core/models.py:65
msgid "Administrator"
msgstr "Administrator"
#: build/lib/core/models.py:66 core/models.py:66
msgid "Owner"
msgstr "Besitzer"
#: build/lib/core/models.py:77 core/models.py:77
msgid "Restricted"
msgstr "Beschränkt"
#: build/lib/core/models.py:81 core/models.py:81
msgid "Authenticated"
msgstr "Authentifiziert"
#: build/lib/core/models.py:83 core/models.py:83
msgid "Public"
msgstr "Öffentlich"
#: build/lib/core/models.py:154 core/models.py:154
#: build/lib/core/models.py:79 core/models.py:79
msgid "id"
msgstr "id"
#: build/lib/core/models.py:155 core/models.py:155
#: build/lib/core/models.py:80 core/models.py:80
msgid "primary key for the record as UUID"
msgstr "primärer Schlüssel für den Datensatz als UUID"
#: build/lib/core/models.py:161 core/models.py:161
#: build/lib/core/models.py:86 core/models.py:86
msgid "created on"
msgstr "Erstellt"
#: build/lib/core/models.py:162 core/models.py:162
#: build/lib/core/models.py:87 core/models.py:87
msgid "date and time at which a record was created"
msgstr "Datum und Uhrzeit, an dem ein Datensatz erstellt wurde"
#: build/lib/core/models.py:167 core/models.py:167
#: build/lib/core/models.py:92 core/models.py:92
msgid "updated on"
msgstr "Aktualisiert"
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:93 core/models.py:93
msgid "date and time at which a record was last updated"
msgstr "Datum und Uhrzeit, an dem zuletzt aktualisiert wurde"
#: build/lib/core/models.py:204 core/models.py:204
#: build/lib/core/models.py:129 core/models.py:129
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
msgstr "Wir konnten keinen Benutzer mit diesem Abo finden, aber die E-Mail-Adresse ist bereits einem registrierten Benutzer zugeordnet."
#: build/lib/core/models.py:217 core/models.py:217
#: build/lib/core/models.py:142 core/models.py:142
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr "Geben Sie eine gültige Unterseite ein. Dieser Wert darf nur Buchstaben, Zahlen und die @/./+/-/_/: Zeichen enthalten."
#: build/lib/core/models.py:223 core/models.py:223
#: build/lib/core/models.py:148 core/models.py:148
msgid "sub"
msgstr "unter"
#: build/lib/core/models.py:225 core/models.py:225
#: build/lib/core/models.py:150 core/models.py:150
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr "Erforderlich. 255 Zeichen oder weniger. Buchstaben, Zahlen und die Zeichen @/./+/-/_/:"
#: build/lib/core/models.py:234 core/models.py:234
#: build/lib/core/models.py:159 core/models.py:159
msgid "full name"
msgstr "Name"
#: build/lib/core/models.py:235 core/models.py:235
#: build/lib/core/models.py:160 core/models.py:160
msgid "short name"
msgstr "Kurzbezeichnung"
#: build/lib/core/models.py:237 core/models.py:237
#: build/lib/core/models.py:162 core/models.py:162
msgid "identity email address"
msgstr "Identitäts-E-Mail-Adresse"
#: build/lib/core/models.py:242 core/models.py:242
#: build/lib/core/models.py:167 core/models.py:167
msgid "admin email address"
msgstr "Admin E-Mail-Adresse"
#: build/lib/core/models.py:249 core/models.py:249
#: build/lib/core/models.py:174 core/models.py:174
msgid "language"
msgstr "Sprache"
#: build/lib/core/models.py:250 core/models.py:250
#: build/lib/core/models.py:175 core/models.py:175
msgid "The language in which the user wants to see the interface."
msgstr "Die Sprache, in der der Benutzer die Benutzeroberfläche sehen möchte."
#: build/lib/core/models.py:258 core/models.py:258
#: build/lib/core/models.py:183 core/models.py:183
msgid "The timezone in which the user wants to see times."
msgstr "Die Zeitzone, in der der Nutzer Zeiten sehen möchte."
#: build/lib/core/models.py:261 core/models.py:261
#: build/lib/core/models.py:186 core/models.py:186
msgid "device"
msgstr "Gerät"
#: build/lib/core/models.py:263 core/models.py:263
#: build/lib/core/models.py:188 core/models.py:188
msgid "Whether the user is a device or a real user."
msgstr "Ob der Benutzer ein Gerät oder ein echter Benutzer ist."
#: build/lib/core/models.py:266 core/models.py:266
#: build/lib/core/models.py:191 core/models.py:191
msgid "staff status"
msgstr "Status des Teammitgliedes"
#: build/lib/core/models.py:268 core/models.py:268
#: build/lib/core/models.py:193 core/models.py:193
msgid "Whether the user can log into this admin site."
msgstr "Gibt an, ob der Benutzer sich in diese Admin-Seite einloggen kann."
#: build/lib/core/models.py:271 core/models.py:271
#: build/lib/core/models.py:196 core/models.py:196
msgid "active"
msgstr "aktiviert"
#: build/lib/core/models.py:274 core/models.py:274
#: build/lib/core/models.py:199 core/models.py:199
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."
#: build/lib/core/models.py:286 core/models.py:286
#: build/lib/core/models.py:211 core/models.py:211
msgid "user"
msgstr "Benutzer"
#: build/lib/core/models.py:287 core/models.py:287
#: build/lib/core/models.py:212 core/models.py:212
msgid "users"
msgstr "Benutzer"
#: build/lib/core/models.py:470 build/lib/core/models.py:1155
#: core/models.py:470 core/models.py:1155
#: build/lib/core/models.py:368 build/lib/core/models.py:1281
#: core/models.py:368 core/models.py:1281
msgid "title"
msgstr "Titel"
#: build/lib/core/models.py:471 core/models.py:471
#: build/lib/core/models.py:369 core/models.py:369
msgid "excerpt"
msgstr "Auszug"
#: build/lib/core/models.py:519 core/models.py:519
#: build/lib/core/models.py:418 core/models.py:418
msgid "Document"
msgstr "Dokument"
#: build/lib/core/models.py:520 core/models.py:520
#: build/lib/core/models.py:419 core/models.py:419
msgid "Documents"
msgstr "Dokumente"
#: build/lib/core/models.py:532 build/lib/core/models.py:873 core/models.py:532
#: core/models.py:873
#: build/lib/core/models.py:431 build/lib/core/models.py:820 core/models.py:431
#: core/models.py:820
msgid "Untitled Document"
msgstr "Unbenanntes Dokument"
#: build/lib/core/models.py:908 core/models.py:908
#: build/lib/core/models.py:855 core/models.py:855
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} hat ein Dokument mit Ihnen geteilt!"
#: build/lib/core/models.py:912 core/models.py:912
#: build/lib/core/models.py:859 core/models.py:859
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} hat Sie mit der Rolle \"{role}\" zu folgendem Dokument eingeladen:"
#: build/lib/core/models.py:918 core/models.py:918
#: build/lib/core/models.py:865 core/models.py:865
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} hat ein Dokument mit Ihnen geteilt: {title}"
#: build/lib/core/models.py:1016 core/models.py:1016
#: build/lib/core/models.py:964 core/models.py:964
msgid "Document/user link trace"
msgstr "Dokument/Benutzer Linkverfolgung"
#: build/lib/core/models.py:1017 core/models.py:1017
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link traces"
msgstr "Dokument/Benutzer Linkverfolgung"
#: build/lib/core/models.py:1023 core/models.py:1023
#: build/lib/core/models.py:971 core/models.py:971
msgid "A link trace already exists for this document/user."
msgstr "Für dieses Dokument/ diesen Benutzer ist bereits eine Linkverfolgung vorhanden."
#: build/lib/core/models.py:1046 core/models.py:1046
#: build/lib/core/models.py:994 core/models.py:994
msgid "Document favorite"
msgstr "Dokumentenfavorit"
#: build/lib/core/models.py:1047 core/models.py:1047
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorites"
msgstr "Dokumentfavoriten"
#: build/lib/core/models.py:1053 core/models.py:1053
#: build/lib/core/models.py:1001 core/models.py:1001
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Dieses Dokument ist bereits durch den gleichen Benutzer favorisiert worden."
#: build/lib/core/models.py:1075 core/models.py:1075
#: build/lib/core/models.py:1023 core/models.py:1023
msgid "Document/user relation"
msgstr "Dokument/Benutzerbeziehung"
#: build/lib/core/models.py:1076 core/models.py:1076
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relations"
msgstr "Dokument/Benutzerbeziehungen"
#: build/lib/core/models.py:1082 core/models.py:1082
#: build/lib/core/models.py:1030 core/models.py:1030
msgid "This user is already in this document."
msgstr "Dieser Benutzer befindet sich bereits in diesem Dokument."
#: build/lib/core/models.py:1088 core/models.py:1088
#: build/lib/core/models.py:1036 core/models.py:1036
msgid "This team is already in this document."
msgstr "Dieses Team befindet sich bereits in diesem Dokument."
#: build/lib/core/models.py:1094 build/lib/core/models.py:1242
#: core/models.py:1094 core/models.py:1242
#: build/lib/core/models.py:1042 build/lib/core/models.py:1367
#: core/models.py:1042 core/models.py:1367
msgid "Either user or team must be set, not both."
msgstr "Benutzer oder Team müssen gesetzt werden, nicht beides."
#: build/lib/core/models.py:1156 core/models.py:1156
#: build/lib/core/models.py:1188 core/models.py:1188
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1195 core/models.py:1195
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1260 core/models.py:1260
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1264 core/models.py:1264
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1282 core/models.py:1282
msgid "description"
msgstr "Beschreibung"
#: build/lib/core/models.py:1157 core/models.py:1157
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "code"
msgstr "Code"
#: build/lib/core/models.py:1158 core/models.py:1158
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "css"
msgstr "CSS"
#: build/lib/core/models.py:1160 core/models.py:1160
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "public"
msgstr "öffentlich"
#: build/lib/core/models.py:1162 core/models.py:1162
#: build/lib/core/models.py:1288 core/models.py:1288
msgid "Whether this template is public for anyone to use."
msgstr "Ob diese Vorlage für jedermann öffentlich ist."
#: build/lib/core/models.py:1168 core/models.py:1168
#: build/lib/core/models.py:1294 core/models.py:1294
msgid "Template"
msgstr "Vorlage"
#: build/lib/core/models.py:1169 core/models.py:1169
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Templates"
msgstr "Vorlagen"
#: build/lib/core/models.py:1223 core/models.py:1223
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Template/user relation"
msgstr "Vorlage/Benutzer-Beziehung"
#: build/lib/core/models.py:1224 core/models.py:1224
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relations"
msgstr "Vorlage/Benutzerbeziehungen"
#: build/lib/core/models.py:1230 core/models.py:1230
#: build/lib/core/models.py:1355 core/models.py:1355
msgid "This user is already in this template."
msgstr "Dieser Benutzer ist bereits in dieser Vorlage."
#: build/lib/core/models.py:1236 core/models.py:1236
#: build/lib/core/models.py:1361 core/models.py:1361
msgid "This team is already in this template."
msgstr "Dieses Team ist bereits in diesem Template."
#: build/lib/core/models.py:1259 core/models.py:1259
#: build/lib/core/models.py:1438 core/models.py:1438
msgid "email address"
msgstr "E-Mail-Adresse"
#: build/lib/core/models.py:1278 core/models.py:1278
#: build/lib/core/models.py:1457 core/models.py:1457
msgid "Document invitation"
msgstr "Einladung zum Dokument"
#: build/lib/core/models.py:1279 core/models.py:1279
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitations"
msgstr "Dokumenteinladungen"
#: build/lib/core/models.py:1299 core/models.py:1299
#: build/lib/core/models.py:1478 core/models.py:1478
msgid "This email is already associated to a registered user."
msgstr "Diese E-Mail ist bereits einem registrierten Benutzer zugeordnet."
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/html/template.html:162
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr "Logo-E-Mail"
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
#: core/templates/mail/html/template.html:209
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr "Öffnen"
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
#: core/templates/mail/html/template.html:226
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr " Docs, Ihr neues unentbehrliches Werkzeug für die Organisation, den Austausch und die Zusammenarbeit in Ihren Dokumenten als Team. "
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#: core/templates/mail/html/template.html:233
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr " Erstellt von %(brandname)s "
+135 -108
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-05-22 12:09+0000\n"
"PO-Revision-Date: 2025-05-22 14:16\n"
"POT-Creation-Date: 2025-07-08 15:21+0000\n"
"PO-Revision-Date: 2025-07-18 10:25\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
@@ -46,31 +46,61 @@ msgstr ""
msgid "Favorite"
msgstr ""
#: build/lib/core/api/serializers.py:446 core/api/serializers.py:446
#: build/lib/core/api/serializers.py:467 core/api/serializers.py:467
msgid "A new document was created on your behalf!"
msgstr ""
#: build/lib/core/api/serializers.py:450 core/api/serializers.py:450
#: build/lib/core/api/serializers.py:471 core/api/serializers.py:471
msgid "You have been granted ownership of a new document:"
msgstr ""
#: build/lib/core/api/serializers.py:586 core/api/serializers.py:586
#: build/lib/core/api/serializers.py:608 core/api/serializers.py:608
msgid "Body"
msgstr ""
#: build/lib/core/api/serializers.py:589 core/api/serializers.py:589
#: build/lib/core/api/serializers.py:611 core/api/serializers.py:611
msgid "Body type"
msgstr ""
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
#: build/lib/core/api/serializers.py:617 core/api/serializers.py:617
msgid "Format"
msgstr ""
#: build/lib/core/api/viewsets.py:967 core/api/viewsets.py:967
#: build/lib/core/api/viewsets.py:943 core/api/viewsets.py:943
#, python-brace-format
msgid "copy of {title}"
msgstr ""
#: build/lib/core/choices.py:35 build/lib/core/choices.py:42 core/choices.py:35
#: core/choices.py:42
msgid "Reader"
msgstr ""
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
#: core/choices.py:43
msgid "Editor"
msgstr ""
#: build/lib/core/choices.py:44 core/choices.py:44
msgid "Administrator"
msgstr ""
#: build/lib/core/choices.py:45 core/choices.py:45
msgid "Owner"
msgstr ""
#: build/lib/core/choices.py:56 core/choices.py:56
msgid "Restricted"
msgstr ""
#: build/lib/core/choices.py:60 core/choices.py:60
msgid "Authenticated"
msgstr ""
#: build/lib/core/choices.py:62 core/choices.py:62
msgid "Public"
msgstr ""
#: build/lib/core/enums.py:36 core/enums.py:36
msgid "First child"
msgstr ""
@@ -95,295 +125,292 @@ msgstr ""
msgid "Right"
msgstr ""
#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56
#: core/models.py:63
msgid "Reader"
msgstr ""
#: build/lib/core/models.py:57 build/lib/core/models.py:64 core/models.py:57
#: core/models.py:64
msgid "Editor"
msgstr ""
#: build/lib/core/models.py:65 core/models.py:65
msgid "Administrator"
msgstr ""
#: build/lib/core/models.py:66 core/models.py:66
msgid "Owner"
msgstr ""
#: build/lib/core/models.py:77 core/models.py:77
msgid "Restricted"
msgstr ""
#: build/lib/core/models.py:81 core/models.py:81
msgid "Authenticated"
msgstr ""
#: build/lib/core/models.py:83 core/models.py:83
msgid "Public"
msgstr ""
#: build/lib/core/models.py:154 core/models.py:154
#: build/lib/core/models.py:79 core/models.py:79
msgid "id"
msgstr ""
#: build/lib/core/models.py:155 core/models.py:155
#: build/lib/core/models.py:80 core/models.py:80
msgid "primary key for the record as UUID"
msgstr ""
#: build/lib/core/models.py:161 core/models.py:161
#: build/lib/core/models.py:86 core/models.py:86
msgid "created on"
msgstr ""
#: build/lib/core/models.py:162 core/models.py:162
#: build/lib/core/models.py:87 core/models.py:87
msgid "date and time at which a record was created"
msgstr ""
#: build/lib/core/models.py:167 core/models.py:167
#: build/lib/core/models.py:92 core/models.py:92
msgid "updated on"
msgstr ""
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:93 core/models.py:93
msgid "date and time at which a record was last updated"
msgstr ""
#: build/lib/core/models.py:204 core/models.py:204
#: build/lib/core/models.py:129 core/models.py:129
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
msgstr ""
#: build/lib/core/models.py:217 core/models.py:217
#: build/lib/core/models.py:142 core/models.py:142
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr ""
#: build/lib/core/models.py:223 core/models.py:223
#: build/lib/core/models.py:148 core/models.py:148
msgid "sub"
msgstr ""
#: build/lib/core/models.py:225 core/models.py:225
#: build/lib/core/models.py:150 core/models.py:150
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr ""
#: build/lib/core/models.py:234 core/models.py:234
#: build/lib/core/models.py:159 core/models.py:159
msgid "full name"
msgstr ""
#: build/lib/core/models.py:235 core/models.py:235
#: build/lib/core/models.py:160 core/models.py:160
msgid "short name"
msgstr ""
#: build/lib/core/models.py:237 core/models.py:237
#: build/lib/core/models.py:162 core/models.py:162
msgid "identity email address"
msgstr ""
#: build/lib/core/models.py:242 core/models.py:242
#: build/lib/core/models.py:167 core/models.py:167
msgid "admin email address"
msgstr ""
#: build/lib/core/models.py:249 core/models.py:249
#: build/lib/core/models.py:174 core/models.py:174
msgid "language"
msgstr ""
#: build/lib/core/models.py:250 core/models.py:250
#: build/lib/core/models.py:175 core/models.py:175
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/core/models.py:258 core/models.py:258
#: build/lib/core/models.py:183 core/models.py:183
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/core/models.py:261 core/models.py:261
#: build/lib/core/models.py:186 core/models.py:186
msgid "device"
msgstr ""
#: build/lib/core/models.py:263 core/models.py:263
#: build/lib/core/models.py:188 core/models.py:188
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/core/models.py:266 core/models.py:266
#: build/lib/core/models.py:191 core/models.py:191
msgid "staff status"
msgstr ""
#: build/lib/core/models.py:268 core/models.py:268
#: build/lib/core/models.py:193 core/models.py:193
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/core/models.py:271 core/models.py:271
#: build/lib/core/models.py:196 core/models.py:196
msgid "active"
msgstr ""
#: build/lib/core/models.py:274 core/models.py:274
#: build/lib/core/models.py:199 core/models.py:199
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: build/lib/core/models.py:286 core/models.py:286
#: build/lib/core/models.py:211 core/models.py:211
msgid "user"
msgstr ""
#: build/lib/core/models.py:287 core/models.py:287
#: build/lib/core/models.py:212 core/models.py:212
msgid "users"
msgstr ""
#: build/lib/core/models.py:470 build/lib/core/models.py:1155
#: core/models.py:470 core/models.py:1155
#: build/lib/core/models.py:368 build/lib/core/models.py:1281
#: core/models.py:368 core/models.py:1281
msgid "title"
msgstr ""
#: build/lib/core/models.py:471 core/models.py:471
#: build/lib/core/models.py:369 core/models.py:369
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:519 core/models.py:519
#: build/lib/core/models.py:418 core/models.py:418
msgid "Document"
msgstr ""
#: build/lib/core/models.py:520 core/models.py:520
#: build/lib/core/models.py:419 core/models.py:419
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:532 build/lib/core/models.py:873 core/models.py:532
#: core/models.py:873
#: build/lib/core/models.py:431 build/lib/core/models.py:820 core/models.py:431
#: core/models.py:820
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:908 core/models.py:908
#: build/lib/core/models.py:855 core/models.py:855
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:912 core/models.py:912
#: build/lib/core/models.py:859 core/models.py:859
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:918 core/models.py:918
#: build/lib/core/models.py:865 core/models.py:865
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:1016 core/models.py:1016
#: build/lib/core/models.py:964 core/models.py:964
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:1017 core/models.py:1017
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:1023 core/models.py:1023
#: build/lib/core/models.py:971 core/models.py:971
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:1046 core/models.py:1046
#: build/lib/core/models.py:994 core/models.py:994
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:1047 core/models.py:1047
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1053 core/models.py:1053
#: build/lib/core/models.py:1001 core/models.py:1001
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1075 core/models.py:1075
#: build/lib/core/models.py:1023 core/models.py:1023
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1076 core/models.py:1076
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1082 core/models.py:1082
#: build/lib/core/models.py:1030 core/models.py:1030
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1088 core/models.py:1088
#: build/lib/core/models.py:1036 core/models.py:1036
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1094 build/lib/core/models.py:1242
#: core/models.py:1094 core/models.py:1242
#: build/lib/core/models.py:1042 build/lib/core/models.py:1367
#: core/models.py:1042 core/models.py:1367
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1156 core/models.py:1156
#: build/lib/core/models.py:1188 core/models.py:1188
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1195 core/models.py:1195
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1260 core/models.py:1260
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1264 core/models.py:1264
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1282 core/models.py:1282
msgid "description"
msgstr ""
#: build/lib/core/models.py:1157 core/models.py:1157
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "code"
msgstr ""
#: build/lib/core/models.py:1158 core/models.py:1158
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "css"
msgstr ""
#: build/lib/core/models.py:1160 core/models.py:1160
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "public"
msgstr ""
#: build/lib/core/models.py:1162 core/models.py:1162
#: build/lib/core/models.py:1288 core/models.py:1288
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1168 core/models.py:1168
#: build/lib/core/models.py:1294 core/models.py:1294
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1169 core/models.py:1169
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1223 core/models.py:1223
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1224 core/models.py:1224
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1230 core/models.py:1230
#: build/lib/core/models.py:1355 core/models.py:1355
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1236 core/models.py:1236
#: build/lib/core/models.py:1361 core/models.py:1361
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1259 core/models.py:1259
#: build/lib/core/models.py:1438 core/models.py:1438
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1278 core/models.py:1278
#: build/lib/core/models.py:1457 core/models.py:1457
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1279 core/models.py:1279
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1299 core/models.py:1299
#: build/lib/core/models.py:1478 core/models.py:1478
msgid "This email is already associated to a registered user."
msgstr ""
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/html/template.html:162
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr ""
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
#: core/templates/mail/html/template.html:209
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr ""
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
#: core/templates/mail/html/template.html:226
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr ""
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#: core/templates/mail/html/template.html:233
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr ""
+135 -108
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-05-22 12:09+0000\n"
"PO-Revision-Date: 2025-05-22 14:16\n"
"POT-Creation-Date: 2025-07-08 15:21+0000\n"
"PO-Revision-Date: 2025-07-18 10:25\n"
"Last-Translator: \n"
"Language-Team: Spanish\n"
"Language: es_ES\n"
@@ -46,31 +46,61 @@ msgstr "Yo soy el creador"
msgid "Favorite"
msgstr "Favorito"
#: build/lib/core/api/serializers.py:446 core/api/serializers.py:446
#: build/lib/core/api/serializers.py:467 core/api/serializers.py:467
msgid "A new document was created on your behalf!"
msgstr "¡Un nuevo documento se ha creado por ti!"
#: build/lib/core/api/serializers.py:450 core/api/serializers.py:450
#: build/lib/core/api/serializers.py:471 core/api/serializers.py:471
msgid "You have been granted ownership of a new document:"
msgstr "Se le ha concedido la propiedad de un nuevo documento :"
#: build/lib/core/api/serializers.py:586 core/api/serializers.py:586
#: build/lib/core/api/serializers.py:608 core/api/serializers.py:608
msgid "Body"
msgstr "Cuerpo"
#: build/lib/core/api/serializers.py:589 core/api/serializers.py:589
#: build/lib/core/api/serializers.py:611 core/api/serializers.py:611
msgid "Body type"
msgstr "Tipo de Cuerpo"
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
#: build/lib/core/api/serializers.py:617 core/api/serializers.py:617
msgid "Format"
msgstr "Formato"
#: build/lib/core/api/viewsets.py:967 core/api/viewsets.py:967
#: build/lib/core/api/viewsets.py:943 core/api/viewsets.py:943
#, python-brace-format
msgid "copy of {title}"
msgstr "copia de {title}"
#: build/lib/core/choices.py:35 build/lib/core/choices.py:42 core/choices.py:35
#: core/choices.py:42
msgid "Reader"
msgstr "Lector"
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
#: core/choices.py:43
msgid "Editor"
msgstr "Editor"
#: build/lib/core/choices.py:44 core/choices.py:44
msgid "Administrator"
msgstr "Administrador"
#: build/lib/core/choices.py:45 core/choices.py:45
msgid "Owner"
msgstr "Propietario"
#: build/lib/core/choices.py:56 core/choices.py:56
msgid "Restricted"
msgstr "Restringido"
#: build/lib/core/choices.py:60 core/choices.py:60
msgid "Authenticated"
msgstr "Autentificado"
#: build/lib/core/choices.py:62 core/choices.py:62
msgid "Public"
msgstr "Público"
#: build/lib/core/enums.py:36 core/enums.py:36
msgid "First child"
msgstr "Primer nodo"
@@ -95,295 +125,292 @@ msgstr "Izquierda"
msgid "Right"
msgstr "Derecha"
#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56
#: core/models.py:63
msgid "Reader"
msgstr "Lector"
#: build/lib/core/models.py:57 build/lib/core/models.py:64 core/models.py:57
#: core/models.py:64
msgid "Editor"
msgstr "Editor"
#: build/lib/core/models.py:65 core/models.py:65
msgid "Administrator"
msgstr "Administrador"
#: build/lib/core/models.py:66 core/models.py:66
msgid "Owner"
msgstr "Propietario"
#: build/lib/core/models.py:77 core/models.py:77
msgid "Restricted"
msgstr "Restringido"
#: build/lib/core/models.py:81 core/models.py:81
msgid "Authenticated"
msgstr "Autentificado"
#: build/lib/core/models.py:83 core/models.py:83
msgid "Public"
msgstr "Público"
#: build/lib/core/models.py:154 core/models.py:154
#: build/lib/core/models.py:79 core/models.py:79
msgid "id"
msgstr "id"
#: build/lib/core/models.py:155 core/models.py:155
#: build/lib/core/models.py:80 core/models.py:80
msgid "primary key for the record as UUID"
msgstr "clave primaria para el registro como UUID"
#: build/lib/core/models.py:161 core/models.py:161
#: build/lib/core/models.py:86 core/models.py:86
msgid "created on"
msgstr "creado el"
#: build/lib/core/models.py:162 core/models.py:162
#: build/lib/core/models.py:87 core/models.py:87
msgid "date and time at which a record was created"
msgstr "fecha y hora en la que se creó un registro"
#: build/lib/core/models.py:167 core/models.py:167
#: build/lib/core/models.py:92 core/models.py:92
msgid "updated on"
msgstr "actualizado el"
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:93 core/models.py:93
msgid "date and time at which a record was last updated"
msgstr "fecha y hora en la que un registro fue actualizado por última vez"
#: build/lib/core/models.py:204 core/models.py:204
#: build/lib/core/models.py:129 core/models.py:129
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
msgstr "No se ha podido encontrar un usuario con este sub (UUID), pero el correo electrónico ya está asociado con un usuario."
#: build/lib/core/models.py:217 core/models.py:217
#: build/lib/core/models.py:142 core/models.py:142
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr "Introduzca un sub (UUID) válido. Este valor solo puede contener letras, números y los siguientes caracteres @/./+/-/_/:"
#: build/lib/core/models.py:223 core/models.py:223
#: build/lib/core/models.py:148 core/models.py:148
msgid "sub"
msgstr "sub (UUID)"
#: build/lib/core/models.py:225 core/models.py:225
#: build/lib/core/models.py:150 core/models.py:150
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr "Requerido. 255 caracteres o menos. Letras, números y los siguientes caracteres @/./+/-/_/: solamente."
#: build/lib/core/models.py:234 core/models.py:234
#: build/lib/core/models.py:159 core/models.py:159
msgid "full name"
msgstr "nombre completo"
#: build/lib/core/models.py:235 core/models.py:235
#: build/lib/core/models.py:160 core/models.py:160
msgid "short name"
msgstr "nombre abreviado"
#: build/lib/core/models.py:237 core/models.py:237
#: build/lib/core/models.py:162 core/models.py:162
msgid "identity email address"
msgstr "correo electrónico de identidad"
#: build/lib/core/models.py:242 core/models.py:242
#: build/lib/core/models.py:167 core/models.py:167
msgid "admin email address"
msgstr "correo electrónico del administrador"
#: build/lib/core/models.py:249 core/models.py:249
#: build/lib/core/models.py:174 core/models.py:174
msgid "language"
msgstr "idioma"
#: build/lib/core/models.py:250 core/models.py:250
#: build/lib/core/models.py:175 core/models.py:175
msgid "The language in which the user wants to see the interface."
msgstr "El idioma en el que el usuario desea ver la interfaz."
#: build/lib/core/models.py:258 core/models.py:258
#: build/lib/core/models.py:183 core/models.py:183
msgid "The timezone in which the user wants to see times."
msgstr "La zona horaria en la que el usuario quiere ver los tiempos."
#: build/lib/core/models.py:261 core/models.py:261
#: build/lib/core/models.py:186 core/models.py:186
msgid "device"
msgstr "dispositivo"
#: build/lib/core/models.py:263 core/models.py:263
#: build/lib/core/models.py:188 core/models.py:188
msgid "Whether the user is a device or a real user."
msgstr "Si el usuario es un dispositivo o un usuario real."
#: build/lib/core/models.py:266 core/models.py:266
#: build/lib/core/models.py:191 core/models.py:191
msgid "staff status"
msgstr "rol en el equipo"
#: build/lib/core/models.py:268 core/models.py:268
#: build/lib/core/models.py:193 core/models.py:193
msgid "Whether the user can log into this admin site."
msgstr "Si el usuario puede iniciar sesión en esta página web de administración."
#: build/lib/core/models.py:271 core/models.py:271
#: build/lib/core/models.py:196 core/models.py:196
msgid "active"
msgstr "activo"
#: build/lib/core/models.py:274 core/models.py:274
#: build/lib/core/models.py:199 core/models.py:199
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."
#: build/lib/core/models.py:286 core/models.py:286
#: build/lib/core/models.py:211 core/models.py:211
msgid "user"
msgstr "usuario"
#: build/lib/core/models.py:287 core/models.py:287
#: build/lib/core/models.py:212 core/models.py:212
msgid "users"
msgstr "usuarios"
#: build/lib/core/models.py:470 build/lib/core/models.py:1155
#: core/models.py:470 core/models.py:1155
#: build/lib/core/models.py:368 build/lib/core/models.py:1281
#: core/models.py:368 core/models.py:1281
msgid "title"
msgstr "título"
#: build/lib/core/models.py:471 core/models.py:471
#: build/lib/core/models.py:369 core/models.py:369
msgid "excerpt"
msgstr "resumen"
#: build/lib/core/models.py:519 core/models.py:519
#: build/lib/core/models.py:418 core/models.py:418
msgid "Document"
msgstr "Documento"
#: build/lib/core/models.py:520 core/models.py:520
#: build/lib/core/models.py:419 core/models.py:419
msgid "Documents"
msgstr "Documentos"
#: build/lib/core/models.py:532 build/lib/core/models.py:873 core/models.py:532
#: core/models.py:873
#: build/lib/core/models.py:431 build/lib/core/models.py:820 core/models.py:431
#: core/models.py:820
msgid "Untitled Document"
msgstr "Documento sin título"
#: build/lib/core/models.py:908 core/models.py:908
#: build/lib/core/models.py:855 core/models.py:855
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "¡{name} ha compartido un documento contigo!"
#: build/lib/core/models.py:912 core/models.py:912
#: build/lib/core/models.py:859 core/models.py:859
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "Te ha invitado {name} al siguiente documento con el rol \"{role}\" :"
#: build/lib/core/models.py:918 core/models.py:918
#: build/lib/core/models.py:865 core/models.py:865
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} ha compartido un documento contigo: {title}"
#: build/lib/core/models.py:1016 core/models.py:1016
#: build/lib/core/models.py:964 core/models.py:964
msgid "Document/user link trace"
msgstr "Traza del enlace de documento/usuario"
#: build/lib/core/models.py:1017 core/models.py:1017
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link traces"
msgstr "Trazas del enlace de documento/usuario"
#: build/lib/core/models.py:1023 core/models.py:1023
#: build/lib/core/models.py:971 core/models.py:971
msgid "A link trace already exists for this document/user."
msgstr "Ya existe una traza de enlace para este documento/usuario."
#: build/lib/core/models.py:1046 core/models.py:1046
#: build/lib/core/models.py:994 core/models.py:994
msgid "Document favorite"
msgstr "Documento favorito"
#: build/lib/core/models.py:1047 core/models.py:1047
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorites"
msgstr "Documentos favoritos"
#: build/lib/core/models.py:1053 core/models.py:1053
#: build/lib/core/models.py:1001 core/models.py:1001
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Este documento ya ha sido marcado como favorito por el usuario."
#: build/lib/core/models.py:1075 core/models.py:1075
#: build/lib/core/models.py:1023 core/models.py:1023
msgid "Document/user relation"
msgstr "Relación documento/usuario"
#: build/lib/core/models.py:1076 core/models.py:1076
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relations"
msgstr "Relaciones documento/usuario"
#: build/lib/core/models.py:1082 core/models.py:1082
#: build/lib/core/models.py:1030 core/models.py:1030
msgid "This user is already in this document."
msgstr "Este usuario ya forma parte del documento."
#: build/lib/core/models.py:1088 core/models.py:1088
#: build/lib/core/models.py:1036 core/models.py:1036
msgid "This team is already in this document."
msgstr "Este equipo ya forma parte del documento."
#: build/lib/core/models.py:1094 build/lib/core/models.py:1242
#: core/models.py:1094 core/models.py:1242
#: build/lib/core/models.py:1042 build/lib/core/models.py:1367
#: core/models.py:1042 core/models.py:1367
msgid "Either user or team must be set, not both."
msgstr "Debe establecerse un usuario o un equipo, no ambos."
#: build/lib/core/models.py:1156 core/models.py:1156
#: build/lib/core/models.py:1188 core/models.py:1188
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1195 core/models.py:1195
msgid "This user has already asked for access to this document."
msgstr "Este usuario ya ha solicitado acceso a este documento."
#: build/lib/core/models.py:1260 core/models.py:1260
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "¡{name} desea acceder a un documento!"
#: build/lib/core/models.py:1264 core/models.py:1264
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1282 core/models.py:1282
msgid "description"
msgstr "descripción"
#: build/lib/core/models.py:1157 core/models.py:1157
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "code"
msgstr "código"
#: build/lib/core/models.py:1158 core/models.py:1158
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1160 core/models.py:1160
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "public"
msgstr "público"
#: build/lib/core/models.py:1162 core/models.py:1162
#: build/lib/core/models.py:1288 core/models.py:1288
msgid "Whether this template is public for anyone to use."
msgstr "Si esta plantilla es pública para que cualquiera la utilice."
#: build/lib/core/models.py:1168 core/models.py:1168
#: build/lib/core/models.py:1294 core/models.py:1294
msgid "Template"
msgstr "Plantilla"
#: build/lib/core/models.py:1169 core/models.py:1169
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Templates"
msgstr "Plantillas"
#: build/lib/core/models.py:1223 core/models.py:1223
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Template/user relation"
msgstr "Relación plantilla/usuario"
#: build/lib/core/models.py:1224 core/models.py:1224
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relations"
msgstr "Relaciones plantilla/usuario"
#: build/lib/core/models.py:1230 core/models.py:1230
#: build/lib/core/models.py:1355 core/models.py:1355
msgid "This user is already in this template."
msgstr "Este usuario ya forma parte de la plantilla."
#: build/lib/core/models.py:1236 core/models.py:1236
#: build/lib/core/models.py:1361 core/models.py:1361
msgid "This team is already in this template."
msgstr "Este equipo ya se encuentra en esta plantilla."
#: build/lib/core/models.py:1259 core/models.py:1259
#: build/lib/core/models.py:1438 core/models.py:1438
msgid "email address"
msgstr "dirección de correo electrónico"
#: build/lib/core/models.py:1278 core/models.py:1278
#: build/lib/core/models.py:1457 core/models.py:1457
msgid "Document invitation"
msgstr "Invitación al documento"
#: build/lib/core/models.py:1279 core/models.py:1279
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitations"
msgstr "Invitaciones a documentos"
#: build/lib/core/models.py:1299 core/models.py:1299
#: build/lib/core/models.py:1478 core/models.py:1478
msgid "This email is already associated to a registered user."
msgstr "Este correo electrónico está asociado a un usuario registrado."
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/html/template.html:162
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr "Logo de correo electrónico"
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
#: core/templates/mail/html/template.html:209
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr "Abrir"
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
#: core/templates/mail/html/template.html:226
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr "Docs, su nueva herramienta esencial para organizar, compartir y colaborar en sus documentos como equipo."
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#: core/templates/mail/html/template.html:233
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr " Presentado por %(brandname)s "
+135 -108
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-05-22 12:09+0000\n"
"PO-Revision-Date: 2025-05-22 14:16\n"
"POT-Creation-Date: 2025-07-08 15:21+0000\n"
"PO-Revision-Date: 2025-07-18 10:25\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
@@ -46,31 +46,61 @@ msgstr "Je suis l'auteur"
msgid "Favorite"
msgstr "Favoris"
#: build/lib/core/api/serializers.py:446 core/api/serializers.py:446
#: build/lib/core/api/serializers.py:467 core/api/serializers.py:467
msgid "A new document was created on your behalf!"
msgstr "Un nouveau document a été créé pour vous !"
#: build/lib/core/api/serializers.py:450 core/api/serializers.py:450
#: build/lib/core/api/serializers.py:471 core/api/serializers.py:471
msgid "You have been granted ownership of a new document:"
msgstr "Vous avez été déclaré propriétaire d'un nouveau document :"
#: build/lib/core/api/serializers.py:586 core/api/serializers.py:586
#: build/lib/core/api/serializers.py:608 core/api/serializers.py:608
msgid "Body"
msgstr "Corps"
#: build/lib/core/api/serializers.py:589 core/api/serializers.py:589
#: build/lib/core/api/serializers.py:611 core/api/serializers.py:611
msgid "Body type"
msgstr "Type de corps"
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
#: build/lib/core/api/serializers.py:617 core/api/serializers.py:617
msgid "Format"
msgstr "Format"
#: build/lib/core/api/viewsets.py:967 core/api/viewsets.py:967
#: build/lib/core/api/viewsets.py:943 core/api/viewsets.py:943
#, python-brace-format
msgid "copy of {title}"
msgstr "copie de {title}"
#: build/lib/core/choices.py:35 build/lib/core/choices.py:42 core/choices.py:35
#: core/choices.py:42
msgid "Reader"
msgstr "Lecteur"
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
#: core/choices.py:43
msgid "Editor"
msgstr "Éditeur"
#: build/lib/core/choices.py:44 core/choices.py:44
msgid "Administrator"
msgstr "Administrateur"
#: build/lib/core/choices.py:45 core/choices.py:45
msgid "Owner"
msgstr "Propriétaire"
#: build/lib/core/choices.py:56 core/choices.py:56
msgid "Restricted"
msgstr "Restreint"
#: build/lib/core/choices.py:60 core/choices.py:60
msgid "Authenticated"
msgstr "Authentifié"
#: build/lib/core/choices.py:62 core/choices.py:62
msgid "Public"
msgstr "Public"
#: build/lib/core/enums.py:36 core/enums.py:36
msgid "First child"
msgstr "Premier enfant"
@@ -95,295 +125,292 @@ msgstr "Gauche"
msgid "Right"
msgstr "Droite"
#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56
#: core/models.py:63
msgid "Reader"
msgstr "Lecteur"
#: build/lib/core/models.py:57 build/lib/core/models.py:64 core/models.py:57
#: core/models.py:64
msgid "Editor"
msgstr "Éditeur"
#: build/lib/core/models.py:65 core/models.py:65
msgid "Administrator"
msgstr "Administrateur"
#: build/lib/core/models.py:66 core/models.py:66
msgid "Owner"
msgstr "Propriétaire"
#: build/lib/core/models.py:77 core/models.py:77
msgid "Restricted"
msgstr "Restreint"
#: build/lib/core/models.py:81 core/models.py:81
msgid "Authenticated"
msgstr "Authentifié"
#: build/lib/core/models.py:83 core/models.py:83
msgid "Public"
msgstr "Public"
#: build/lib/core/models.py:154 core/models.py:154
#: build/lib/core/models.py:79 core/models.py:79
msgid "id"
msgstr "identifiant/id"
#: build/lib/core/models.py:155 core/models.py:155
#: build/lib/core/models.py:80 core/models.py:80
msgid "primary key for the record as UUID"
msgstr "clé primaire pour l'enregistrement en tant que UUID"
#: build/lib/core/models.py:161 core/models.py:161
#: build/lib/core/models.py:86 core/models.py:86
msgid "created on"
msgstr "créé le"
#: build/lib/core/models.py:162 core/models.py:162
#: build/lib/core/models.py:87 core/models.py:87
msgid "date and time at which a record was created"
msgstr "date et heure de création de l'enregistrement"
#: build/lib/core/models.py:167 core/models.py:167
#: build/lib/core/models.py:92 core/models.py:92
msgid "updated on"
msgstr "mis à jour le"
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:93 core/models.py:93
msgid "date and time at which a record was last updated"
msgstr "date et heure de la dernière mise à jour de l'enregistrement"
#: build/lib/core/models.py:204 core/models.py:204
#: build/lib/core/models.py:129 core/models.py:129
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
msgstr "Nous n'avons pas pu trouver un utilisateur avec ce sous-groupe mais l'e-mail est déjà associé à un utilisateur enregistré."
#: build/lib/core/models.py:217 core/models.py:217
#: build/lib/core/models.py:142 core/models.py:142
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr "Saisissez un sous-groupe valide. Cette valeur ne peut contenir que des lettres, des chiffres et les caractères @/./+/-/_/: uniquement."
#: build/lib/core/models.py:223 core/models.py:223
#: build/lib/core/models.py:148 core/models.py:148
msgid "sub"
msgstr "sous-groupe"
#: build/lib/core/models.py:225 core/models.py:225
#: build/lib/core/models.py:150 core/models.py:150
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr "Obligatoire. 255 caractères ou moins. Lettres, chiffres et caractères @/./+/-/_/: uniquement."
#: build/lib/core/models.py:234 core/models.py:234
#: build/lib/core/models.py:159 core/models.py:159
msgid "full name"
msgstr "nom complet"
#: build/lib/core/models.py:235 core/models.py:235
#: build/lib/core/models.py:160 core/models.py:160
msgid "short name"
msgstr "nom court"
#: build/lib/core/models.py:237 core/models.py:237
#: build/lib/core/models.py:162 core/models.py:162
msgid "identity email address"
msgstr "adresse e-mail d'identité"
#: build/lib/core/models.py:242 core/models.py:242
#: build/lib/core/models.py:167 core/models.py:167
msgid "admin email address"
msgstr "adresse e-mail de l'administrateur"
#: build/lib/core/models.py:249 core/models.py:249
#: build/lib/core/models.py:174 core/models.py:174
msgid "language"
msgstr "langue"
#: build/lib/core/models.py:250 core/models.py:250
#: build/lib/core/models.py:175 core/models.py:175
msgid "The language in which the user wants to see the interface."
msgstr "La langue dans laquelle l'utilisateur veut voir l'interface."
#: build/lib/core/models.py:258 core/models.py:258
#: build/lib/core/models.py:183 core/models.py:183
msgid "The timezone in which the user wants to see times."
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
#: build/lib/core/models.py:261 core/models.py:261
#: build/lib/core/models.py:186 core/models.py:186
msgid "device"
msgstr "appareil"
#: build/lib/core/models.py:263 core/models.py:263
#: build/lib/core/models.py:188 core/models.py:188
msgid "Whether the user is a device or a real user."
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
#: build/lib/core/models.py:266 core/models.py:266
#: build/lib/core/models.py:191 core/models.py:191
msgid "staff status"
msgstr "statut d'équipe"
#: build/lib/core/models.py:268 core/models.py:268
#: build/lib/core/models.py:193 core/models.py:193
msgid "Whether the user can log into this admin site."
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
#: build/lib/core/models.py:271 core/models.py:271
#: build/lib/core/models.py:196 core/models.py:196
msgid "active"
msgstr "actif"
#: build/lib/core/models.py:274 core/models.py:274
#: build/lib/core/models.py:199 core/models.py:199
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."
#: build/lib/core/models.py:286 core/models.py:286
#: build/lib/core/models.py:211 core/models.py:211
msgid "user"
msgstr "utilisateur"
#: build/lib/core/models.py:287 core/models.py:287
#: build/lib/core/models.py:212 core/models.py:212
msgid "users"
msgstr "utilisateurs"
#: build/lib/core/models.py:470 build/lib/core/models.py:1155
#: core/models.py:470 core/models.py:1155
#: build/lib/core/models.py:368 build/lib/core/models.py:1281
#: core/models.py:368 core/models.py:1281
msgid "title"
msgstr "titre"
#: build/lib/core/models.py:471 core/models.py:471
#: build/lib/core/models.py:369 core/models.py:369
msgid "excerpt"
msgstr "extrait"
#: build/lib/core/models.py:519 core/models.py:519
#: build/lib/core/models.py:418 core/models.py:418
msgid "Document"
msgstr "Document"
#: build/lib/core/models.py:520 core/models.py:520
#: build/lib/core/models.py:419 core/models.py:419
msgid "Documents"
msgstr "Documents"
#: build/lib/core/models.py:532 build/lib/core/models.py:873 core/models.py:532
#: core/models.py:873
#: build/lib/core/models.py:431 build/lib/core/models.py:820 core/models.py:431
#: core/models.py:820
msgid "Untitled Document"
msgstr "Document sans titre"
#: build/lib/core/models.py:908 core/models.py:908
#: build/lib/core/models.py:855 core/models.py:855
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} a partagé un document avec vous!"
#: build/lib/core/models.py:912 core/models.py:912
#: build/lib/core/models.py:859 core/models.py:859
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} vous a invité avec le rôle \"{role}\" sur le document suivant :"
#: build/lib/core/models.py:918 core/models.py:918
#: build/lib/core/models.py:865 core/models.py:865
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} a partagé un document avec vous : {title}"
#: build/lib/core/models.py:1016 core/models.py:1016
#: build/lib/core/models.py:964 core/models.py:964
msgid "Document/user link trace"
msgstr "Trace du lien document/utilisateur"
#: build/lib/core/models.py:1017 core/models.py:1017
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link traces"
msgstr "Traces du lien document/utilisateur"
#: build/lib/core/models.py:1023 core/models.py:1023
#: build/lib/core/models.py:971 core/models.py:971
msgid "A link trace already exists for this document/user."
msgstr "Une trace de lien existe déjà pour ce document/utilisateur."
#: build/lib/core/models.py:1046 core/models.py:1046
#: build/lib/core/models.py:994 core/models.py:994
msgid "Document favorite"
msgstr "Document favori"
#: build/lib/core/models.py:1047 core/models.py:1047
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorites"
msgstr "Documents favoris"
#: build/lib/core/models.py:1053 core/models.py:1053
#: build/lib/core/models.py:1001 core/models.py:1001
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Ce document est déjà un favori de cet utilisateur."
#: build/lib/core/models.py:1075 core/models.py:1075
#: build/lib/core/models.py:1023 core/models.py:1023
msgid "Document/user relation"
msgstr "Relation document/utilisateur"
#: build/lib/core/models.py:1076 core/models.py:1076
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relations"
msgstr "Relations document/utilisateur"
#: build/lib/core/models.py:1082 core/models.py:1082
#: build/lib/core/models.py:1030 core/models.py:1030
msgid "This user is already in this document."
msgstr "Cet utilisateur est déjà dans ce document."
#: build/lib/core/models.py:1088 core/models.py:1088
#: build/lib/core/models.py:1036 core/models.py:1036
msgid "This team is already in this document."
msgstr "Cette équipe est déjà dans ce document."
#: build/lib/core/models.py:1094 build/lib/core/models.py:1242
#: core/models.py:1094 core/models.py:1242
#: build/lib/core/models.py:1042 build/lib/core/models.py:1367
#: core/models.py:1042 core/models.py:1367
msgid "Either user or team must be set, not both."
msgstr "L'utilisateur ou l'équipe doivent être définis, pas les deux."
#: build/lib/core/models.py:1156 core/models.py:1156
#: build/lib/core/models.py:1188 core/models.py:1188
msgid "Document ask for access"
msgstr "Demande d'accès au document"
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for accesses"
msgstr "Demande d'accès au document"
#: build/lib/core/models.py:1195 core/models.py:1195
msgid "This user has already asked for access to this document."
msgstr "Cet utilisateur a déjà demandé l'accès à ce document."
#: build/lib/core/models.py:1260 core/models.py:1260
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr "{name} souhaiterait accéder au document suivant !"
#: build/lib/core/models.py:1264 core/models.py:1264
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr "{name} souhaiterait accéder au document suivant :"
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr "{name} demande l'accès au document : {title}"
#: build/lib/core/models.py:1282 core/models.py:1282
msgid "description"
msgstr "description"
#: build/lib/core/models.py:1157 core/models.py:1157
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "code"
msgstr "code"
#: build/lib/core/models.py:1158 core/models.py:1158
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "css"
msgstr "CSS"
#: build/lib/core/models.py:1160 core/models.py:1160
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "public"
msgstr "public"
#: build/lib/core/models.py:1162 core/models.py:1162
#: build/lib/core/models.py:1288 core/models.py:1288
msgid "Whether this template is public for anyone to use."
msgstr "Si ce modèle est public, utilisable par n'importe qui."
#: build/lib/core/models.py:1168 core/models.py:1168
#: build/lib/core/models.py:1294 core/models.py:1294
msgid "Template"
msgstr "Modèle"
#: build/lib/core/models.py:1169 core/models.py:1169
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Templates"
msgstr "Modèles"
#: build/lib/core/models.py:1223 core/models.py:1223
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Template/user relation"
msgstr "Relation modèle/utilisateur"
#: build/lib/core/models.py:1224 core/models.py:1224
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relations"
msgstr "Relations modèle/utilisateur"
#: build/lib/core/models.py:1230 core/models.py:1230
#: build/lib/core/models.py:1355 core/models.py:1355
msgid "This user is already in this template."
msgstr "Cet utilisateur est déjà dans ce modèle."
#: build/lib/core/models.py:1236 core/models.py:1236
#: build/lib/core/models.py:1361 core/models.py:1361
msgid "This team is already in this template."
msgstr "Cette équipe est déjà modèle."
#: build/lib/core/models.py:1259 core/models.py:1259
#: build/lib/core/models.py:1438 core/models.py:1438
msgid "email address"
msgstr "adresse e-mail"
#: build/lib/core/models.py:1278 core/models.py:1278
#: build/lib/core/models.py:1457 core/models.py:1457
msgid "Document invitation"
msgstr "Invitation à un document"
#: build/lib/core/models.py:1279 core/models.py:1279
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitations"
msgstr "Invitations à un document"
#: build/lib/core/models.py:1299 core/models.py:1299
#: build/lib/core/models.py:1478 core/models.py:1478
msgid "This email is already associated to a registered user."
msgstr "Cette adresse email est déjà associée à un utilisateur inscrit."
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/html/template.html:162
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr "Logo de l'e-mail"
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
#: core/templates/mail/html/template.html:209
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr "Ouvrir"
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
#: core/templates/mail/html/template.html:226
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr " Docs, votre nouvel outil incontournable pour organiser, partager et collaborer sur vos documents en équipe. "
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#: core/templates/mail/html/template.html:233
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr " Proposé par %(brandname)s "
+135 -108
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-05-22 12:09+0000\n"
"PO-Revision-Date: 2025-05-22 14:16\n"
"POT-Creation-Date: 2025-07-08 15:21+0000\n"
"PO-Revision-Date: 2025-07-18 10:25\n"
"Last-Translator: \n"
"Language-Team: Italian\n"
"Language: it_IT\n"
@@ -46,31 +46,61 @@ msgstr "Il creatore sono io"
msgid "Favorite"
msgstr "Preferiti"
#: build/lib/core/api/serializers.py:446 core/api/serializers.py:446
#: build/lib/core/api/serializers.py:467 core/api/serializers.py:467
msgid "A new document was created on your behalf!"
msgstr "Un nuovo documento è stato creato a tuo nome!"
#: build/lib/core/api/serializers.py:450 core/api/serializers.py:450
#: build/lib/core/api/serializers.py:471 core/api/serializers.py:471
msgid "You have been granted ownership of a new document:"
msgstr "Sei ora proprietario di un nuovo documento:"
#: build/lib/core/api/serializers.py:586 core/api/serializers.py:586
#: build/lib/core/api/serializers.py:608 core/api/serializers.py:608
msgid "Body"
msgstr "Corpo"
#: build/lib/core/api/serializers.py:589 core/api/serializers.py:589
#: build/lib/core/api/serializers.py:611 core/api/serializers.py:611
msgid "Body type"
msgstr ""
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
#: build/lib/core/api/serializers.py:617 core/api/serializers.py:617
msgid "Format"
msgstr "Formato"
#: build/lib/core/api/viewsets.py:967 core/api/viewsets.py:967
#: build/lib/core/api/viewsets.py:943 core/api/viewsets.py:943
#, python-brace-format
msgid "copy of {title}"
msgstr "copia di {title}"
#: build/lib/core/choices.py:35 build/lib/core/choices.py:42 core/choices.py:35
#: core/choices.py:42
msgid "Reader"
msgstr "Lettore"
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
#: core/choices.py:43
msgid "Editor"
msgstr "Editor"
#: build/lib/core/choices.py:44 core/choices.py:44
msgid "Administrator"
msgstr "Amministratore"
#: build/lib/core/choices.py:45 core/choices.py:45
msgid "Owner"
msgstr "Proprietario"
#: build/lib/core/choices.py:56 core/choices.py:56
msgid "Restricted"
msgstr "Limitato"
#: build/lib/core/choices.py:60 core/choices.py:60
msgid "Authenticated"
msgstr "Autenticato"
#: build/lib/core/choices.py:62 core/choices.py:62
msgid "Public"
msgstr "Pubblico"
#: build/lib/core/enums.py:36 core/enums.py:36
msgid "First child"
msgstr ""
@@ -95,295 +125,292 @@ msgstr "Sinistra"
msgid "Right"
msgstr "Destra"
#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56
#: core/models.py:63
msgid "Reader"
msgstr "Lettore"
#: build/lib/core/models.py:57 build/lib/core/models.py:64 core/models.py:57
#: core/models.py:64
msgid "Editor"
msgstr "Editor"
#: build/lib/core/models.py:65 core/models.py:65
msgid "Administrator"
msgstr "Amministratore"
#: build/lib/core/models.py:66 core/models.py:66
msgid "Owner"
msgstr "Proprietario"
#: build/lib/core/models.py:77 core/models.py:77
msgid "Restricted"
msgstr "Limitato"
#: build/lib/core/models.py:81 core/models.py:81
msgid "Authenticated"
msgstr "Autenticato"
#: build/lib/core/models.py:83 core/models.py:83
msgid "Public"
msgstr "Pubblico"
#: build/lib/core/models.py:154 core/models.py:154
#: build/lib/core/models.py:79 core/models.py:79
msgid "id"
msgstr "Id"
#: build/lib/core/models.py:155 core/models.py:155
#: build/lib/core/models.py:80 core/models.py:80
msgid "primary key for the record as UUID"
msgstr "chiave primaria per il record come UUID"
#: build/lib/core/models.py:161 core/models.py:161
#: build/lib/core/models.py:86 core/models.py:86
msgid "created on"
msgstr "creato il"
#: build/lib/core/models.py:162 core/models.py:162
#: build/lib/core/models.py:87 core/models.py:87
msgid "date and time at which a record was created"
msgstr "data e ora in cui è stato creato un record"
#: build/lib/core/models.py:167 core/models.py:167
#: build/lib/core/models.py:92 core/models.py:92
msgid "updated on"
msgstr "aggiornato il"
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:93 core/models.py:93
msgid "date and time at which a record was last updated"
msgstr "data e ora in cui lultimo record è stato aggiornato"
#: build/lib/core/models.py:204 core/models.py:204
#: build/lib/core/models.py:129 core/models.py:129
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
msgstr ""
#: build/lib/core/models.py:217 core/models.py:217
#: build/lib/core/models.py:142 core/models.py:142
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr ""
#: build/lib/core/models.py:223 core/models.py:223
#: build/lib/core/models.py:148 core/models.py:148
msgid "sub"
msgstr ""
#: build/lib/core/models.py:225 core/models.py:225
#: build/lib/core/models.py:150 core/models.py:150
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr "Richiesto. 255 caratteri o meno. Solo lettere, numeri e @/./+/-/_/: caratteri."
#: build/lib/core/models.py:234 core/models.py:234
#: build/lib/core/models.py:159 core/models.py:159
msgid "full name"
msgstr "nome completo"
#: build/lib/core/models.py:235 core/models.py:235
#: build/lib/core/models.py:160 core/models.py:160
msgid "short name"
msgstr "nome"
#: build/lib/core/models.py:237 core/models.py:237
#: build/lib/core/models.py:162 core/models.py:162
msgid "identity email address"
msgstr "indirizzo email di identità"
#: build/lib/core/models.py:242 core/models.py:242
#: build/lib/core/models.py:167 core/models.py:167
msgid "admin email address"
msgstr "Indirizzo email dell'amministratore"
#: build/lib/core/models.py:249 core/models.py:249
#: build/lib/core/models.py:174 core/models.py:174
msgid "language"
msgstr "lingua"
#: build/lib/core/models.py:250 core/models.py:250
#: build/lib/core/models.py:175 core/models.py:175
msgid "The language in which the user wants to see the interface."
msgstr "La lingua in cui l'utente vuole vedere l'interfaccia."
#: build/lib/core/models.py:258 core/models.py:258
#: build/lib/core/models.py:183 core/models.py:183
msgid "The timezone in which the user wants to see times."
msgstr "Il fuso orario in cui l'utente vuole vedere gli orari."
#: build/lib/core/models.py:261 core/models.py:261
#: build/lib/core/models.py:186 core/models.py:186
msgid "device"
msgstr "dispositivo"
#: build/lib/core/models.py:263 core/models.py:263
#: build/lib/core/models.py:188 core/models.py:188
msgid "Whether the user is a device or a real user."
msgstr "Se l'utente è un dispositivo o un utente reale."
#: build/lib/core/models.py:266 core/models.py:266
#: build/lib/core/models.py:191 core/models.py:191
msgid "staff status"
msgstr "stato del personale"
#: build/lib/core/models.py:268 core/models.py:268
#: build/lib/core/models.py:193 core/models.py:193
msgid "Whether the user can log into this admin site."
msgstr "Indica se l'utente può accedere a questo sito amministratore."
#: build/lib/core/models.py:271 core/models.py:271
#: build/lib/core/models.py:196 core/models.py:196
msgid "active"
msgstr "attivo"
#: build/lib/core/models.py:274 core/models.py:274
#: build/lib/core/models.py:199 core/models.py:199
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."
#: build/lib/core/models.py:286 core/models.py:286
#: build/lib/core/models.py:211 core/models.py:211
msgid "user"
msgstr "utente"
#: build/lib/core/models.py:287 core/models.py:287
#: build/lib/core/models.py:212 core/models.py:212
msgid "users"
msgstr "utenti"
#: build/lib/core/models.py:470 build/lib/core/models.py:1155
#: core/models.py:470 core/models.py:1155
#: build/lib/core/models.py:368 build/lib/core/models.py:1281
#: core/models.py:368 core/models.py:1281
msgid "title"
msgstr "titolo"
#: build/lib/core/models.py:471 core/models.py:471
#: build/lib/core/models.py:369 core/models.py:369
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:519 core/models.py:519
#: build/lib/core/models.py:418 core/models.py:418
msgid "Document"
msgstr "Documento"
#: build/lib/core/models.py:520 core/models.py:520
#: build/lib/core/models.py:419 core/models.py:419
msgid "Documents"
msgstr "Documenti"
#: build/lib/core/models.py:532 build/lib/core/models.py:873 core/models.py:532
#: core/models.py:873
#: build/lib/core/models.py:431 build/lib/core/models.py:820 core/models.py:431
#: core/models.py:820
msgid "Untitled Document"
msgstr "Documento senza titolo"
#: build/lib/core/models.py:908 core/models.py:908
#: build/lib/core/models.py:855 core/models.py:855
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} ha condiviso un documento con te!"
#: build/lib/core/models.py:912 core/models.py:912
#: build/lib/core/models.py:859 core/models.py:859
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} ti ha invitato con il ruolo \"{role}\" nel seguente documento:"
#: build/lib/core/models.py:918 core/models.py:918
#: build/lib/core/models.py:865 core/models.py:865
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} ha condiviso un documento con te: {title}"
#: build/lib/core/models.py:1016 core/models.py:1016
#: build/lib/core/models.py:964 core/models.py:964
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:1017 core/models.py:1017
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:1023 core/models.py:1023
#: build/lib/core/models.py:971 core/models.py:971
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:1046 core/models.py:1046
#: build/lib/core/models.py:994 core/models.py:994
msgid "Document favorite"
msgstr "Documento preferito"
#: build/lib/core/models.py:1047 core/models.py:1047
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorites"
msgstr "Documenti preferiti"
#: build/lib/core/models.py:1053 core/models.py:1053
#: build/lib/core/models.py:1001 core/models.py:1001
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1075 core/models.py:1075
#: build/lib/core/models.py:1023 core/models.py:1023
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1076 core/models.py:1076
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1082 core/models.py:1082
#: build/lib/core/models.py:1030 core/models.py:1030
msgid "This user is already in this document."
msgstr "Questo utente è già presente in questo documento."
#: build/lib/core/models.py:1088 core/models.py:1088
#: build/lib/core/models.py:1036 core/models.py:1036
msgid "This team is already in this document."
msgstr "Questo team è già presente in questo documento."
#: build/lib/core/models.py:1094 build/lib/core/models.py:1242
#: core/models.py:1094 core/models.py:1242
#: build/lib/core/models.py:1042 build/lib/core/models.py:1367
#: core/models.py:1042 core/models.py:1367
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1156 core/models.py:1156
#: build/lib/core/models.py:1188 core/models.py:1188
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1195 core/models.py:1195
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1260 core/models.py:1260
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1264 core/models.py:1264
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1282 core/models.py:1282
msgid "description"
msgstr "descrizione"
#: build/lib/core/models.py:1157 core/models.py:1157
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "code"
msgstr "code"
#: build/lib/core/models.py:1158 core/models.py:1158
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1160 core/models.py:1160
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "public"
msgstr "pubblico"
#: build/lib/core/models.py:1162 core/models.py:1162
#: build/lib/core/models.py:1288 core/models.py:1288
msgid "Whether this template is public for anyone to use."
msgstr "Indica se questo modello è pubblico per chiunque."
#: build/lib/core/models.py:1168 core/models.py:1168
#: build/lib/core/models.py:1294 core/models.py:1294
msgid "Template"
msgstr "Modello"
#: build/lib/core/models.py:1169 core/models.py:1169
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Templates"
msgstr "Modelli"
#: build/lib/core/models.py:1223 core/models.py:1223
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1224 core/models.py:1224
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1230 core/models.py:1230
#: build/lib/core/models.py:1355 core/models.py:1355
msgid "This user is already in this template."
msgstr "Questo utente è già in questo modello."
#: build/lib/core/models.py:1236 core/models.py:1236
#: build/lib/core/models.py:1361 core/models.py:1361
msgid "This team is already in this template."
msgstr "Questo team è già in questo modello."
#: build/lib/core/models.py:1259 core/models.py:1259
#: build/lib/core/models.py:1438 core/models.py:1438
msgid "email address"
msgstr "indirizzo e-mail"
#: build/lib/core/models.py:1278 core/models.py:1278
#: build/lib/core/models.py:1457 core/models.py:1457
msgid "Document invitation"
msgstr "Invito al documento"
#: build/lib/core/models.py:1279 core/models.py:1279
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitations"
msgstr "Inviti al documento"
#: build/lib/core/models.py:1299 core/models.py:1299
#: build/lib/core/models.py:1478 core/models.py:1478
msgid "This email is already associated to a registered user."
msgstr "Questa email è già associata a un utente registrato."
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/html/template.html:162
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr "Logo e-mail"
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
#: core/templates/mail/html/template.html:209
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr "Apri"
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
#: core/templates/mail/html/template.html:226
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr ""
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#: core/templates/mail/html/template.html:233
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr ""
+135 -108
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-05-22 12:09+0000\n"
"PO-Revision-Date: 2025-05-22 14:16\n"
"POT-Creation-Date: 2025-07-08 15:21+0000\n"
"PO-Revision-Date: 2025-07-18 10:25\n"
"Last-Translator: \n"
"Language-Team: Dutch\n"
"Language: nl_NL\n"
@@ -46,31 +46,61 @@ msgstr "Ik ben Eigenaar"
msgid "Favorite"
msgstr "Favoriete"
#: build/lib/core/api/serializers.py:446 core/api/serializers.py:446
#: build/lib/core/api/serializers.py:467 core/api/serializers.py:467
msgid "A new document was created on your behalf!"
msgstr "Een nieuw document was gecreëerd voor u!"
#: build/lib/core/api/serializers.py:450 core/api/serializers.py:450
#: build/lib/core/api/serializers.py:471 core/api/serializers.py:471
msgid "You have been granted ownership of a new document:"
msgstr "U heeft eigenaarschap van een nieuw document:"
#: build/lib/core/api/serializers.py:586 core/api/serializers.py:586
#: build/lib/core/api/serializers.py:608 core/api/serializers.py:608
msgid "Body"
msgstr "Text"
#: build/lib/core/api/serializers.py:589 core/api/serializers.py:589
#: build/lib/core/api/serializers.py:611 core/api/serializers.py:611
msgid "Body type"
msgstr "Text type"
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
#: build/lib/core/api/serializers.py:617 core/api/serializers.py:617
msgid "Format"
msgstr "Formaat"
#: build/lib/core/api/viewsets.py:967 core/api/viewsets.py:967
#: build/lib/core/api/viewsets.py:943 core/api/viewsets.py:943
#, python-brace-format
msgid "copy of {title}"
msgstr "kopie van {title}"
#: build/lib/core/choices.py:35 build/lib/core/choices.py:42 core/choices.py:35
#: core/choices.py:42
msgid "Reader"
msgstr "Lezer"
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
#: core/choices.py:43
msgid "Editor"
msgstr "Bewerker"
#: build/lib/core/choices.py:44 core/choices.py:44
msgid "Administrator"
msgstr "Administrator"
#: build/lib/core/choices.py:45 core/choices.py:45
msgid "Owner"
msgstr "Eigenaar"
#: build/lib/core/choices.py:56 core/choices.py:56
msgid "Restricted"
msgstr "Niet toegestaan"
#: build/lib/core/choices.py:60 core/choices.py:60
msgid "Authenticated"
msgstr "Geauthenticeerd"
#: build/lib/core/choices.py:62 core/choices.py:62
msgid "Public"
msgstr "Publiek"
#: build/lib/core/enums.py:36 core/enums.py:36
msgid "First child"
msgstr "Eerste node"
@@ -95,295 +125,292 @@ msgstr "Links"
msgid "Right"
msgstr "Rechts"
#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56
#: core/models.py:63
msgid "Reader"
msgstr "Lezer"
#: build/lib/core/models.py:57 build/lib/core/models.py:64 core/models.py:57
#: core/models.py:64
msgid "Editor"
msgstr "Bewerker"
#: build/lib/core/models.py:65 core/models.py:65
msgid "Administrator"
msgstr "Administrator"
#: build/lib/core/models.py:66 core/models.py:66
msgid "Owner"
msgstr "Eigenaar"
#: build/lib/core/models.py:77 core/models.py:77
msgid "Restricted"
msgstr "Niet toegestaan"
#: build/lib/core/models.py:81 core/models.py:81
msgid "Authenticated"
msgstr "Geauthenticeerd"
#: build/lib/core/models.py:83 core/models.py:83
msgid "Public"
msgstr "Publiek"
#: build/lib/core/models.py:154 core/models.py:154
#: build/lib/core/models.py:79 core/models.py:79
msgid "id"
msgstr "id"
#: build/lib/core/models.py:155 core/models.py:155
#: build/lib/core/models.py:80 core/models.py:80
msgid "primary key for the record as UUID"
msgstr "primaire sleutel voor dossier als UUID"
#: build/lib/core/models.py:161 core/models.py:161
#: build/lib/core/models.py:86 core/models.py:86
msgid "created on"
msgstr "gemaakt op"
#: build/lib/core/models.py:162 core/models.py:162
#: build/lib/core/models.py:87 core/models.py:87
msgid "date and time at which a record was created"
msgstr "datum en tijd wanneer dossier was gecreëerd"
#: build/lib/core/models.py:167 core/models.py:167
#: build/lib/core/models.py:92 core/models.py:92
msgid "updated on"
msgstr "Laatst gewijzigd op"
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:93 core/models.py:93
msgid "date and time at which a record was last updated"
msgstr "datum en tijd waarop dossier laatst was gewijzigd"
#: build/lib/core/models.py:204 core/models.py:204
#: build/lib/core/models.py:129 core/models.py:129
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
msgstr "Wij konden geen gebruiker vinden met deze id, maar de email is al geassocieerd met een geregistreerde gebruiker."
#: build/lib/core/models.py:217 core/models.py:217
#: build/lib/core/models.py:142 core/models.py:142
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr ".Geef een valide id. De waarde mag alleen letters, nummers en @/./.+/-/_: karakters bevatten."
#: build/lib/core/models.py:223 core/models.py:223
#: build/lib/core/models.py:148 core/models.py:148
msgid "sub"
msgstr "id"
#: build/lib/core/models.py:225 core/models.py:225
#: build/lib/core/models.py:150 core/models.py:150
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr "Verplicht. 255 karakters of minder. Alleen letters, nummers en @/./+/-/_/: karakters zijn toegestaan."
#: build/lib/core/models.py:234 core/models.py:234
#: build/lib/core/models.py:159 core/models.py:159
msgid "full name"
msgstr "volledige naam"
#: build/lib/core/models.py:235 core/models.py:235
#: build/lib/core/models.py:160 core/models.py:160
msgid "short name"
msgstr "gebruikersnaam"
#: build/lib/core/models.py:237 core/models.py:237
#: build/lib/core/models.py:162 core/models.py:162
msgid "identity email address"
msgstr "identiteit email adres"
#: build/lib/core/models.py:242 core/models.py:242
#: build/lib/core/models.py:167 core/models.py:167
msgid "admin email address"
msgstr "admin email adres"
#: build/lib/core/models.py:249 core/models.py:249
#: build/lib/core/models.py:174 core/models.py:174
msgid "language"
msgstr "taal"
#: build/lib/core/models.py:250 core/models.py:250
#: build/lib/core/models.py:175 core/models.py:175
msgid "The language in which the user wants to see the interface."
msgstr "De taal waarin de gebruiker de interface wilt zien."
#: build/lib/core/models.py:258 core/models.py:258
#: build/lib/core/models.py:183 core/models.py:183
msgid "The timezone in which the user wants to see times."
msgstr "De tijdzone waarin de gebruiker de tijden wilt zien."
#: build/lib/core/models.py:261 core/models.py:261
#: build/lib/core/models.py:186 core/models.py:186
msgid "device"
msgstr "apparaat"
#: build/lib/core/models.py:263 core/models.py:263
#: build/lib/core/models.py:188 core/models.py:188
msgid "Whether the user is a device or a real user."
msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
#: build/lib/core/models.py:266 core/models.py:266
#: build/lib/core/models.py:191 core/models.py:191
msgid "staff status"
msgstr "beheerder status"
#: build/lib/core/models.py:268 core/models.py:268
#: build/lib/core/models.py:193 core/models.py:193
msgid "Whether the user can log into this admin site."
msgstr "Of de gebruiker kan inloggen in het admin gedeelte."
#: build/lib/core/models.py:271 core/models.py:271
#: build/lib/core/models.py:196 core/models.py:196
msgid "active"
msgstr "actief"
#: build/lib/core/models.py:274 core/models.py:274
#: build/lib/core/models.py:199 core/models.py:199
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."
#: build/lib/core/models.py:286 core/models.py:286
#: build/lib/core/models.py:211 core/models.py:211
msgid "user"
msgstr "gebruiker"
#: build/lib/core/models.py:287 core/models.py:287
#: build/lib/core/models.py:212 core/models.py:212
msgid "users"
msgstr "gebruikers"
#: build/lib/core/models.py:470 build/lib/core/models.py:1155
#: core/models.py:470 core/models.py:1155
#: build/lib/core/models.py:368 build/lib/core/models.py:1281
#: core/models.py:368 core/models.py:1281
msgid "title"
msgstr "titel"
#: build/lib/core/models.py:471 core/models.py:471
#: build/lib/core/models.py:369 core/models.py:369
msgid "excerpt"
msgstr "uittreksel"
#: build/lib/core/models.py:519 core/models.py:519
#: build/lib/core/models.py:418 core/models.py:418
msgid "Document"
msgstr "Document"
#: build/lib/core/models.py:520 core/models.py:520
#: build/lib/core/models.py:419 core/models.py:419
msgid "Documents"
msgstr "Documenten"
#: build/lib/core/models.py:532 build/lib/core/models.py:873 core/models.py:532
#: core/models.py:873
#: build/lib/core/models.py:431 build/lib/core/models.py:820 core/models.py:431
#: core/models.py:820
msgid "Untitled Document"
msgstr "Naamloos Document"
#: build/lib/core/models.py:908 core/models.py:908
#: build/lib/core/models.py:855 core/models.py:855
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} heeft een document met gedeeld!"
#: build/lib/core/models.py:912 core/models.py:912
#: build/lib/core/models.py:859 core/models.py:859
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} heeft u uitgenodigd met de rol \"{role}\" op het volgende document:"
#: build/lib/core/models.py:918 core/models.py:918
#: build/lib/core/models.py:865 core/models.py:865
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} heeft een document met u gedeeld: {title}"
#: build/lib/core/models.py:1016 core/models.py:1016
#: build/lib/core/models.py:964 core/models.py:964
msgid "Document/user link trace"
msgstr "Document/gebruiker url"
#: build/lib/core/models.py:1017 core/models.py:1017
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link traces"
msgstr "Document/gebruiker url"
#: build/lib/core/models.py:1023 core/models.py:1023
#: build/lib/core/models.py:971 core/models.py:971
msgid "A link trace already exists for this document/user."
msgstr "Een url bestaat al voor dit document/deze gebruiker."
#: build/lib/core/models.py:1046 core/models.py:1046
#: build/lib/core/models.py:994 core/models.py:994
msgid "Document favorite"
msgstr "Document favoriet"
#: build/lib/core/models.py:1047 core/models.py:1047
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorites"
msgstr "Document favorieten"
#: build/lib/core/models.py:1053 core/models.py:1053
#: build/lib/core/models.py:1001 core/models.py:1001
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Dit document is al in gebruik als favoriete door dezelfde gebruiker."
#: build/lib/core/models.py:1075 core/models.py:1075
#: build/lib/core/models.py:1023 core/models.py:1023
msgid "Document/user relation"
msgstr "Document/gebruiker relatie"
#: build/lib/core/models.py:1076 core/models.py:1076
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relations"
msgstr "Document/gebruiker relaties"
#: build/lib/core/models.py:1082 core/models.py:1082
#: build/lib/core/models.py:1030 core/models.py:1030
msgid "This user is already in this document."
msgstr "De gebruiker is al in dit document."
#: build/lib/core/models.py:1088 core/models.py:1088
#: build/lib/core/models.py:1036 core/models.py:1036
msgid "This team is already in this document."
msgstr "Het team is al in dit document."
#: build/lib/core/models.py:1094 build/lib/core/models.py:1242
#: core/models.py:1094 core/models.py:1242
#: build/lib/core/models.py:1042 build/lib/core/models.py:1367
#: core/models.py:1042 core/models.py:1367
msgid "Either user or team must be set, not both."
msgstr "Een gebruiker of team moet gekozen worden, maar niet beide."
#: build/lib/core/models.py:1156 core/models.py:1156
#: build/lib/core/models.py:1188 core/models.py:1188
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1195 core/models.py:1195
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1260 core/models.py:1260
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1264 core/models.py:1264
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1282 core/models.py:1282
msgid "description"
msgstr "omschrijving"
#: build/lib/core/models.py:1157 core/models.py:1157
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "code"
msgstr "code"
#: build/lib/core/models.py:1158 core/models.py:1158
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1160 core/models.py:1160
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "public"
msgstr "publiek"
#: build/lib/core/models.py:1162 core/models.py:1162
#: build/lib/core/models.py:1288 core/models.py:1288
msgid "Whether this template is public for anyone to use."
msgstr "Of dit template als publiek is en door iedereen te gebruiken is."
#: build/lib/core/models.py:1168 core/models.py:1168
#: build/lib/core/models.py:1294 core/models.py:1294
msgid "Template"
msgstr "Template"
#: build/lib/core/models.py:1169 core/models.py:1169
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Templates"
msgstr "Templates"
#: build/lib/core/models.py:1223 core/models.py:1223
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Template/user relation"
msgstr "Template/gebruiker relatie"
#: build/lib/core/models.py:1224 core/models.py:1224
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relations"
msgstr "Template/gebruiker relaties"
#: build/lib/core/models.py:1230 core/models.py:1230
#: build/lib/core/models.py:1355 core/models.py:1355
msgid "This user is already in this template."
msgstr "De gebruiker bestaat al in dit template."
#: build/lib/core/models.py:1236 core/models.py:1236
#: build/lib/core/models.py:1361 core/models.py:1361
msgid "This team is already in this template."
msgstr "Het team bestaat al in dit template."
#: build/lib/core/models.py:1259 core/models.py:1259
#: build/lib/core/models.py:1438 core/models.py:1438
msgid "email address"
msgstr "email adres"
#: build/lib/core/models.py:1278 core/models.py:1278
#: build/lib/core/models.py:1457 core/models.py:1457
msgid "Document invitation"
msgstr "Document uitnodiging"
#: build/lib/core/models.py:1279 core/models.py:1279
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitations"
msgstr "Document uitnodigingen"
#: build/lib/core/models.py:1299 core/models.py:1299
#: build/lib/core/models.py:1478 core/models.py:1478
msgid "This email is already associated to a registered user."
msgstr "Deze email is al geassocieerd met een geregistreerde gebruiker."
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/html/template.html:162
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr "Logo email"
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
#: core/templates/mail/html/template.html:209
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr "Open"
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
#: core/templates/mail/html/template.html:226
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr " Docs, jouw nieuwe essentiële tool voor het organiseren, delen en collaboreren van documenten als team. "
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#: core/templates/mail/html/template.html:233
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr " Geleverd door %(brandname)s "
+148 -121
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-05-22 12:09+0000\n"
"PO-Revision-Date: 2025-05-22 14:16\n"
"POT-Creation-Date: 2025-07-08 15:21+0000\n"
"PO-Revision-Date: 2025-07-18 10:25\n"
"Last-Translator: \n"
"Language-Team: Portuguese\n"
"Language: pt_PT\n"
@@ -19,57 +19,87 @@ msgstr ""
#: build/lib/core/admin.py:37 core/admin.py:37
msgid "Personal info"
msgstr ""
msgstr "Informações Pessoais"
#: build/lib/core/admin.py:50 build/lib/core/admin.py:138 core/admin.py:50
#: core/admin.py:138
msgid "Permissions"
msgstr ""
msgstr "Permissões"
#: build/lib/core/admin.py:62 core/admin.py:62
msgid "Important dates"
msgstr ""
msgstr "Datas importantes"
#: build/lib/core/admin.py:148 core/admin.py:148
msgid "Tree structure"
msgstr ""
msgstr "Estrutura de árvore"
#: build/lib/core/api/filters.py:47 core/api/filters.py:47
msgid "Title"
msgstr ""
msgstr "Título"
#: build/lib/core/api/filters.py:61 core/api/filters.py:61
msgid "Creator is me"
msgstr ""
msgstr "Eu sou o criador"
#: build/lib/core/api/filters.py:64 core/api/filters.py:64
msgid "Favorite"
msgstr ""
msgstr "Favorito"
#: build/lib/core/api/serializers.py:446 core/api/serializers.py:446
#: build/lib/core/api/serializers.py:467 core/api/serializers.py:467
msgid "A new document was created on your behalf!"
msgstr ""
msgstr "Um novo documento foi criado em seu nome!"
#: build/lib/core/api/serializers.py:450 core/api/serializers.py:450
#: build/lib/core/api/serializers.py:471 core/api/serializers.py:471
msgid "You have been granted ownership of a new document:"
msgstr ""
msgstr "A propriedade de um novo documento foi concedida a você:"
#: build/lib/core/api/serializers.py:586 core/api/serializers.py:586
#: build/lib/core/api/serializers.py:608 core/api/serializers.py:608
msgid "Body"
msgstr ""
msgstr "Corpo"
#: build/lib/core/api/serializers.py:589 core/api/serializers.py:589
#: build/lib/core/api/serializers.py:611 core/api/serializers.py:611
msgid "Body type"
msgstr ""
msgstr "Tipo de corpo"
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
#: build/lib/core/api/serializers.py:617 core/api/serializers.py:617
msgid "Format"
msgstr ""
msgstr "Formato"
#: build/lib/core/api/viewsets.py:967 core/api/viewsets.py:967
#: build/lib/core/api/viewsets.py:943 core/api/viewsets.py:943
#, python-brace-format
msgid "copy of {title}"
msgstr ""
msgstr "cópia de {title}"
#: build/lib/core/choices.py:35 build/lib/core/choices.py:42 core/choices.py:35
#: core/choices.py:42
msgid "Reader"
msgstr "Leitor"
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
#: core/choices.py:43
msgid "Editor"
msgstr "Editor"
#: build/lib/core/choices.py:44 core/choices.py:44
msgid "Administrator"
msgstr "Administrador"
#: build/lib/core/choices.py:45 core/choices.py:45
msgid "Owner"
msgstr "Dono"
#: build/lib/core/choices.py:56 core/choices.py:56
msgid "Restricted"
msgstr "Restrito"
#: build/lib/core/choices.py:60 core/choices.py:60
msgid "Authenticated"
msgstr "Autenticado"
#: build/lib/core/choices.py:62 core/choices.py:62
msgid "Public"
msgstr "Público"
#: build/lib/core/enums.py:36 core/enums.py:36
msgid "First child"
@@ -95,295 +125,292 @@ msgstr ""
msgid "Right"
msgstr ""
#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56
#: core/models.py:63
msgid "Reader"
msgstr ""
#: build/lib/core/models.py:57 build/lib/core/models.py:64 core/models.py:57
#: core/models.py:64
msgid "Editor"
msgstr ""
#: build/lib/core/models.py:65 core/models.py:65
msgid "Administrator"
msgstr ""
#: build/lib/core/models.py:66 core/models.py:66
msgid "Owner"
msgstr ""
#: build/lib/core/models.py:77 core/models.py:77
msgid "Restricted"
msgstr ""
#: build/lib/core/models.py:81 core/models.py:81
msgid "Authenticated"
msgstr ""
#: build/lib/core/models.py:83 core/models.py:83
msgid "Public"
msgstr ""
#: build/lib/core/models.py:154 core/models.py:154
#: build/lib/core/models.py:79 core/models.py:79
msgid "id"
msgstr ""
#: build/lib/core/models.py:155 core/models.py:155
#: build/lib/core/models.py:80 core/models.py:80
msgid "primary key for the record as UUID"
msgstr ""
#: build/lib/core/models.py:161 core/models.py:161
#: build/lib/core/models.py:86 core/models.py:86
msgid "created on"
msgstr ""
#: build/lib/core/models.py:162 core/models.py:162
#: build/lib/core/models.py:87 core/models.py:87
msgid "date and time at which a record was created"
msgstr ""
#: build/lib/core/models.py:167 core/models.py:167
#: build/lib/core/models.py:92 core/models.py:92
msgid "updated on"
msgstr ""
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:93 core/models.py:93
msgid "date and time at which a record was last updated"
msgstr ""
#: build/lib/core/models.py:204 core/models.py:204
#: build/lib/core/models.py:129 core/models.py:129
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
msgstr ""
#: build/lib/core/models.py:217 core/models.py:217
#: build/lib/core/models.py:142 core/models.py:142
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr ""
#: build/lib/core/models.py:223 core/models.py:223
#: build/lib/core/models.py:148 core/models.py:148
msgid "sub"
msgstr ""
#: build/lib/core/models.py:225 core/models.py:225
#: build/lib/core/models.py:150 core/models.py:150
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr ""
#: build/lib/core/models.py:234 core/models.py:234
#: build/lib/core/models.py:159 core/models.py:159
msgid "full name"
msgstr ""
#: build/lib/core/models.py:235 core/models.py:235
#: build/lib/core/models.py:160 core/models.py:160
msgid "short name"
msgstr ""
#: build/lib/core/models.py:237 core/models.py:237
#: build/lib/core/models.py:162 core/models.py:162
msgid "identity email address"
msgstr ""
#: build/lib/core/models.py:242 core/models.py:242
#: build/lib/core/models.py:167 core/models.py:167
msgid "admin email address"
msgstr ""
#: build/lib/core/models.py:249 core/models.py:249
#: build/lib/core/models.py:174 core/models.py:174
msgid "language"
msgstr ""
#: build/lib/core/models.py:250 core/models.py:250
#: build/lib/core/models.py:175 core/models.py:175
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/core/models.py:258 core/models.py:258
#: build/lib/core/models.py:183 core/models.py:183
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/core/models.py:261 core/models.py:261
#: build/lib/core/models.py:186 core/models.py:186
msgid "device"
msgstr ""
#: build/lib/core/models.py:263 core/models.py:263
#: build/lib/core/models.py:188 core/models.py:188
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/core/models.py:266 core/models.py:266
#: build/lib/core/models.py:191 core/models.py:191
msgid "staff status"
msgstr ""
#: build/lib/core/models.py:268 core/models.py:268
#: build/lib/core/models.py:193 core/models.py:193
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/core/models.py:271 core/models.py:271
#: build/lib/core/models.py:196 core/models.py:196
msgid "active"
msgstr ""
#: build/lib/core/models.py:274 core/models.py:274
#: build/lib/core/models.py:199 core/models.py:199
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: build/lib/core/models.py:286 core/models.py:286
#: build/lib/core/models.py:211 core/models.py:211
msgid "user"
msgstr ""
#: build/lib/core/models.py:287 core/models.py:287
#: build/lib/core/models.py:212 core/models.py:212
msgid "users"
msgstr ""
#: build/lib/core/models.py:470 build/lib/core/models.py:1155
#: core/models.py:470 core/models.py:1155
#: build/lib/core/models.py:368 build/lib/core/models.py:1281
#: core/models.py:368 core/models.py:1281
msgid "title"
msgstr ""
#: build/lib/core/models.py:471 core/models.py:471
#: build/lib/core/models.py:369 core/models.py:369
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:519 core/models.py:519
#: build/lib/core/models.py:418 core/models.py:418
msgid "Document"
msgstr ""
#: build/lib/core/models.py:520 core/models.py:520
#: build/lib/core/models.py:419 core/models.py:419
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:532 build/lib/core/models.py:873 core/models.py:532
#: core/models.py:873
#: build/lib/core/models.py:431 build/lib/core/models.py:820 core/models.py:431
#: core/models.py:820
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:908 core/models.py:908
#: build/lib/core/models.py:855 core/models.py:855
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:912 core/models.py:912
#: build/lib/core/models.py:859 core/models.py:859
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:918 core/models.py:918
#: build/lib/core/models.py:865 core/models.py:865
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:1016 core/models.py:1016
#: build/lib/core/models.py:964 core/models.py:964
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:1017 core/models.py:1017
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:1023 core/models.py:1023
#: build/lib/core/models.py:971 core/models.py:971
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:1046 core/models.py:1046
#: build/lib/core/models.py:994 core/models.py:994
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:1047 core/models.py:1047
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1053 core/models.py:1053
#: build/lib/core/models.py:1001 core/models.py:1001
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1075 core/models.py:1075
#: build/lib/core/models.py:1023 core/models.py:1023
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1076 core/models.py:1076
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1082 core/models.py:1082
#: build/lib/core/models.py:1030 core/models.py:1030
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1088 core/models.py:1088
#: build/lib/core/models.py:1036 core/models.py:1036
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1094 build/lib/core/models.py:1242
#: core/models.py:1094 core/models.py:1242
#: build/lib/core/models.py:1042 build/lib/core/models.py:1367
#: core/models.py:1042 core/models.py:1367
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1156 core/models.py:1156
#: build/lib/core/models.py:1188 core/models.py:1188
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1195 core/models.py:1195
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1260 core/models.py:1260
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1264 core/models.py:1264
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1282 core/models.py:1282
msgid "description"
msgstr ""
#: build/lib/core/models.py:1157 core/models.py:1157
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "code"
msgstr ""
#: build/lib/core/models.py:1158 core/models.py:1158
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "css"
msgstr ""
#: build/lib/core/models.py:1160 core/models.py:1160
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "public"
msgstr ""
#: build/lib/core/models.py:1162 core/models.py:1162
#: build/lib/core/models.py:1288 core/models.py:1288
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1168 core/models.py:1168
#: build/lib/core/models.py:1294 core/models.py:1294
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1169 core/models.py:1169
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1223 core/models.py:1223
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1224 core/models.py:1224
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1230 core/models.py:1230
#: build/lib/core/models.py:1355 core/models.py:1355
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1236 core/models.py:1236
#: build/lib/core/models.py:1361 core/models.py:1361
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1259 core/models.py:1259
#: build/lib/core/models.py:1438 core/models.py:1438
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1278 core/models.py:1278
#: build/lib/core/models.py:1457 core/models.py:1457
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1279 core/models.py:1279
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1299 core/models.py:1299
#: build/lib/core/models.py:1478 core/models.py:1478
msgid "This email is already associated to a registered user."
msgstr ""
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/html/template.html:162
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr ""
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
#: core/templates/mail/html/template.html:209
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr ""
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
#: core/templates/mail/html/template.html:226
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr ""
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#: core/templates/mail/html/template.html:233
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr ""
+135 -108
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-05-22 12:09+0000\n"
"PO-Revision-Date: 2025-05-22 14:16\n"
"POT-Creation-Date: 2025-07-08 15:21+0000\n"
"PO-Revision-Date: 2025-07-18 10:25\n"
"Last-Translator: \n"
"Language-Team: Slovenian\n"
"Language: sl_SI\n"
@@ -46,31 +46,61 @@ msgstr "Ustvaril sem jaz"
msgid "Favorite"
msgstr "Priljubljena"
#: build/lib/core/api/serializers.py:446 core/api/serializers.py:446
#: build/lib/core/api/serializers.py:467 core/api/serializers.py:467
msgid "A new document was created on your behalf!"
msgstr "Nov dokument je bil ustvarjen v vašem imenu!"
#: build/lib/core/api/serializers.py:450 core/api/serializers.py:450
#: build/lib/core/api/serializers.py:471 core/api/serializers.py:471
msgid "You have been granted ownership of a new document:"
msgstr "Dodeljeno vam je bilo lastništvo nad novim dokumentom:"
#: build/lib/core/api/serializers.py:586 core/api/serializers.py:586
#: build/lib/core/api/serializers.py:608 core/api/serializers.py:608
msgid "Body"
msgstr "Telo"
#: build/lib/core/api/serializers.py:589 core/api/serializers.py:589
#: build/lib/core/api/serializers.py:611 core/api/serializers.py:611
msgid "Body type"
msgstr "Vrsta telesa"
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
#: build/lib/core/api/serializers.py:617 core/api/serializers.py:617
msgid "Format"
msgstr "Oblika"
#: build/lib/core/api/viewsets.py:967 core/api/viewsets.py:967
#: build/lib/core/api/viewsets.py:943 core/api/viewsets.py:943
#, python-brace-format
msgid "copy of {title}"
msgstr ""
#: build/lib/core/choices.py:35 build/lib/core/choices.py:42 core/choices.py:35
#: core/choices.py:42
msgid "Reader"
msgstr "Bralec"
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
#: core/choices.py:43
msgid "Editor"
msgstr "Urednik"
#: build/lib/core/choices.py:44 core/choices.py:44
msgid "Administrator"
msgstr "Skrbnik"
#: build/lib/core/choices.py:45 core/choices.py:45
msgid "Owner"
msgstr "Lastnik"
#: build/lib/core/choices.py:56 core/choices.py:56
msgid "Restricted"
msgstr "Omejeno"
#: build/lib/core/choices.py:60 core/choices.py:60
msgid "Authenticated"
msgstr "Preverjeno"
#: build/lib/core/choices.py:62 core/choices.py:62
msgid "Public"
msgstr "Javno"
#: build/lib/core/enums.py:36 core/enums.py:36
msgid "First child"
msgstr "Prvi otrok"
@@ -95,295 +125,292 @@ msgstr "Levo"
msgid "Right"
msgstr "Desno"
#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56
#: core/models.py:63
msgid "Reader"
msgstr "Bralec"
#: build/lib/core/models.py:57 build/lib/core/models.py:64 core/models.py:57
#: core/models.py:64
msgid "Editor"
msgstr "Urednik"
#: build/lib/core/models.py:65 core/models.py:65
msgid "Administrator"
msgstr "Skrbnik"
#: build/lib/core/models.py:66 core/models.py:66
msgid "Owner"
msgstr "Lastnik"
#: build/lib/core/models.py:77 core/models.py:77
msgid "Restricted"
msgstr "Omejeno"
#: build/lib/core/models.py:81 core/models.py:81
msgid "Authenticated"
msgstr "Preverjeno"
#: build/lib/core/models.py:83 core/models.py:83
msgid "Public"
msgstr "Javno"
#: build/lib/core/models.py:154 core/models.py:154
#: build/lib/core/models.py:79 core/models.py:79
msgid "id"
msgstr ""
#: build/lib/core/models.py:155 core/models.py:155
#: build/lib/core/models.py:80 core/models.py:80
msgid "primary key for the record as UUID"
msgstr "primarni ključ za zapis kot UUID"
#: build/lib/core/models.py:161 core/models.py:161
#: build/lib/core/models.py:86 core/models.py:86
msgid "created on"
msgstr "ustvarjen na"
#: build/lib/core/models.py:162 core/models.py:162
#: build/lib/core/models.py:87 core/models.py:87
msgid "date and time at which a record was created"
msgstr "datum in čas, ko je bil zapis ustvarjen"
#: build/lib/core/models.py:167 core/models.py:167
#: build/lib/core/models.py:92 core/models.py:92
msgid "updated on"
msgstr "posodobljeno dne"
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:93 core/models.py:93
msgid "date and time at which a record was last updated"
msgstr "datum in čas, ko je bil zapis nazadnje posodobljen"
#: build/lib/core/models.py:204 core/models.py:204
#: build/lib/core/models.py:129 core/models.py:129
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
msgstr "Nismo mogli najti uporabnika s tem sub, vendar je e-poštni naslov že povezan z registriranim uporabnikom."
#: build/lib/core/models.py:217 core/models.py:217
#: build/lib/core/models.py:142 core/models.py:142
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr "Vnesite veljavno sub. Ta vrednost lahko vsebuje samo črke, številke in znake @/./+/-/_/:."
#: build/lib/core/models.py:223 core/models.py:223
#: build/lib/core/models.py:148 core/models.py:148
msgid "sub"
msgstr ""
#: build/lib/core/models.py:225 core/models.py:225
#: build/lib/core/models.py:150 core/models.py:150
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr "Obvezno. 255 znakov ali manj. Samo črke, številke in znaki @/./+/-/_/: ."
#: build/lib/core/models.py:234 core/models.py:234
#: build/lib/core/models.py:159 core/models.py:159
msgid "full name"
msgstr "polno ime"
#: build/lib/core/models.py:235 core/models.py:235
#: build/lib/core/models.py:160 core/models.py:160
msgid "short name"
msgstr "kratko ime"
#: build/lib/core/models.py:237 core/models.py:237
#: build/lib/core/models.py:162 core/models.py:162
msgid "identity email address"
msgstr "elektronski naslov identitete"
#: build/lib/core/models.py:242 core/models.py:242
#: build/lib/core/models.py:167 core/models.py:167
msgid "admin email address"
msgstr "elektronski naslov skrbnika"
#: build/lib/core/models.py:249 core/models.py:249
#: build/lib/core/models.py:174 core/models.py:174
msgid "language"
msgstr "jezik"
#: build/lib/core/models.py:250 core/models.py:250
#: build/lib/core/models.py:175 core/models.py:175
msgid "The language in which the user wants to see the interface."
msgstr "Jezik, v katerem uporabnik želi videti vmesnik."
#: build/lib/core/models.py:258 core/models.py:258
#: build/lib/core/models.py:183 core/models.py:183
msgid "The timezone in which the user wants to see times."
msgstr "Časovni pas, v katerem želi uporabnik videti uro."
#: build/lib/core/models.py:261 core/models.py:261
#: build/lib/core/models.py:186 core/models.py:186
msgid "device"
msgstr "naprava"
#: build/lib/core/models.py:263 core/models.py:263
#: build/lib/core/models.py:188 core/models.py:188
msgid "Whether the user is a device or a real user."
msgstr "Ali je uporabnik naprava ali pravi uporabnik."
#: build/lib/core/models.py:266 core/models.py:266
#: build/lib/core/models.py:191 core/models.py:191
msgid "staff status"
msgstr "kadrovski status"
#: build/lib/core/models.py:268 core/models.py:268
#: build/lib/core/models.py:193 core/models.py:193
msgid "Whether the user can log into this admin site."
msgstr "Ali se uporabnik lahko prijavi na to skrbniško mesto."
#: build/lib/core/models.py:271 core/models.py:271
#: build/lib/core/models.py:196 core/models.py:196
msgid "active"
msgstr "aktivni"
#: build/lib/core/models.py:274 core/models.py:274
#: build/lib/core/models.py:199 core/models.py:199
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."
#: build/lib/core/models.py:286 core/models.py:286
#: build/lib/core/models.py:211 core/models.py:211
msgid "user"
msgstr "uporabnik"
#: build/lib/core/models.py:287 core/models.py:287
#: build/lib/core/models.py:212 core/models.py:212
msgid "users"
msgstr "uporabniki"
#: build/lib/core/models.py:470 build/lib/core/models.py:1155
#: core/models.py:470 core/models.py:1155
#: build/lib/core/models.py:368 build/lib/core/models.py:1281
#: core/models.py:368 core/models.py:1281
msgid "title"
msgstr "naslov"
#: build/lib/core/models.py:471 core/models.py:471
#: build/lib/core/models.py:369 core/models.py:369
msgid "excerpt"
msgstr "odlomek"
#: build/lib/core/models.py:519 core/models.py:519
#: build/lib/core/models.py:418 core/models.py:418
msgid "Document"
msgstr "Dokument"
#: build/lib/core/models.py:520 core/models.py:520
#: build/lib/core/models.py:419 core/models.py:419
msgid "Documents"
msgstr "Dokumenti"
#: build/lib/core/models.py:532 build/lib/core/models.py:873 core/models.py:532
#: core/models.py:873
#: build/lib/core/models.py:431 build/lib/core/models.py:820 core/models.py:431
#: core/models.py:820
msgid "Untitled Document"
msgstr "Dokument brez naslova"
#: build/lib/core/models.py:908 core/models.py:908
#: build/lib/core/models.py:855 core/models.py:855
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} je delil dokument z vami!"
#: build/lib/core/models.py:912 core/models.py:912
#: build/lib/core/models.py:859 core/models.py:859
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} vas je povabil z vlogo \"{role}\" na naslednjem dokumentu:"
#: build/lib/core/models.py:918 core/models.py:918
#: build/lib/core/models.py:865 core/models.py:865
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} je delil dokument z vami: {title}"
#: build/lib/core/models.py:1016 core/models.py:1016
#: build/lib/core/models.py:964 core/models.py:964
msgid "Document/user link trace"
msgstr "Dokument/sled povezave uporabnika"
#: build/lib/core/models.py:1017 core/models.py:1017
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link traces"
msgstr "Sledi povezav dokumenta/uporabnika"
#: build/lib/core/models.py:1023 core/models.py:1023
#: build/lib/core/models.py:971 core/models.py:971
msgid "A link trace already exists for this document/user."
msgstr "Za ta dokument/uporabnika že obstaja sled povezave."
#: build/lib/core/models.py:1046 core/models.py:1046
#: build/lib/core/models.py:994 core/models.py:994
msgid "Document favorite"
msgstr "Priljubljeni dokument"
#: build/lib/core/models.py:1047 core/models.py:1047
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorites"
msgstr "Priljubljeni dokumenti"
#: build/lib/core/models.py:1053 core/models.py:1053
#: build/lib/core/models.py:1001 core/models.py:1001
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Ta dokument je že ciljno usmerjen s priljubljenim primerkom relacije za istega uporabnika."
#: build/lib/core/models.py:1075 core/models.py:1075
#: build/lib/core/models.py:1023 core/models.py:1023
msgid "Document/user relation"
msgstr "Odnos dokument/uporabnik"
#: build/lib/core/models.py:1076 core/models.py:1076
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relations"
msgstr "Odnosi dokument/uporabnik"
#: build/lib/core/models.py:1082 core/models.py:1082
#: build/lib/core/models.py:1030 core/models.py:1030
msgid "This user is already in this document."
msgstr "Ta uporabnik je že v tem dokumentu."
#: build/lib/core/models.py:1088 core/models.py:1088
#: build/lib/core/models.py:1036 core/models.py:1036
msgid "This team is already in this document."
msgstr "Ta ekipa je že v tem dokumentu."
#: build/lib/core/models.py:1094 build/lib/core/models.py:1242
#: core/models.py:1094 core/models.py:1242
#: build/lib/core/models.py:1042 build/lib/core/models.py:1367
#: core/models.py:1042 core/models.py:1367
msgid "Either user or team must be set, not both."
msgstr "Nastaviti je treba bodisi uporabnika ali ekipo, a ne obojega."
#: build/lib/core/models.py:1156 core/models.py:1156
#: build/lib/core/models.py:1188 core/models.py:1188
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1195 core/models.py:1195
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1260 core/models.py:1260
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1264 core/models.py:1264
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1282 core/models.py:1282
msgid "description"
msgstr "opis"
#: build/lib/core/models.py:1157 core/models.py:1157
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "code"
msgstr "koda"
#: build/lib/core/models.py:1158 core/models.py:1158
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1160 core/models.py:1160
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "public"
msgstr "javno"
#: build/lib/core/models.py:1162 core/models.py:1162
#: build/lib/core/models.py:1288 core/models.py:1288
msgid "Whether this template is public for anyone to use."
msgstr "Ali je ta predloga javna za uporabo."
#: build/lib/core/models.py:1168 core/models.py:1168
#: build/lib/core/models.py:1294 core/models.py:1294
msgid "Template"
msgstr "Predloga"
#: build/lib/core/models.py:1169 core/models.py:1169
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Templates"
msgstr "Predloge"
#: build/lib/core/models.py:1223 core/models.py:1223
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Template/user relation"
msgstr "Odnos predloga/uporabnik"
#: build/lib/core/models.py:1224 core/models.py:1224
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relations"
msgstr "Odnosi med predlogo in uporabnikom"
#: build/lib/core/models.py:1230 core/models.py:1230
#: build/lib/core/models.py:1355 core/models.py:1355
msgid "This user is already in this template."
msgstr "Ta uporabnik je že v tej predlogi."
#: build/lib/core/models.py:1236 core/models.py:1236
#: build/lib/core/models.py:1361 core/models.py:1361
msgid "This team is already in this template."
msgstr "Ta ekipa je že v tej predlogi."
#: build/lib/core/models.py:1259 core/models.py:1259
#: build/lib/core/models.py:1438 core/models.py:1438
msgid "email address"
msgstr "elektronski naslov"
#: build/lib/core/models.py:1278 core/models.py:1278
#: build/lib/core/models.py:1457 core/models.py:1457
msgid "Document invitation"
msgstr "Vabilo na dokument"
#: build/lib/core/models.py:1279 core/models.py:1279
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitations"
msgstr "Vabila na dokument"
#: build/lib/core/models.py:1299 core/models.py:1299
#: build/lib/core/models.py:1478 core/models.py:1478
msgid "This email is already associated to a registered user."
msgstr "Ta e-poštni naslov je že povezan z registriranim uporabnikom."
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/html/template.html:162
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr "E-pošta z logotipom"
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
#: core/templates/mail/html/template.html:209
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr "Odpri"
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
#: core/templates/mail/html/template.html:226
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr " Dokumenti, vaše novo bistveno orodje za organiziranje, skupno rabo in skupinsko sodelovanje pri dokumentih. "
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#: core/templates/mail/html/template.html:233
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr " Pod okriljem %(brandname)s "
+135 -108
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-05-22 12:09+0000\n"
"PO-Revision-Date: 2025-05-22 14:16\n"
"POT-Creation-Date: 2025-07-08 15:21+0000\n"
"PO-Revision-Date: 2025-07-18 10:25\n"
"Last-Translator: \n"
"Language-Team: Swedish\n"
"Language: sv_SE\n"
@@ -46,31 +46,61 @@ msgstr "Skaparen är jag"
msgid "Favorite"
msgstr "Favoriter"
#: build/lib/core/api/serializers.py:446 core/api/serializers.py:446
#: build/lib/core/api/serializers.py:467 core/api/serializers.py:467
msgid "A new document was created on your behalf!"
msgstr "Ett nytt dokument skapades åt dig!"
#: build/lib/core/api/serializers.py:450 core/api/serializers.py:450
#: build/lib/core/api/serializers.py:471 core/api/serializers.py:471
msgid "You have been granted ownership of a new document:"
msgstr "Du har beviljats äganderätt till ett nytt dokument:"
#: build/lib/core/api/serializers.py:586 core/api/serializers.py:586
#: build/lib/core/api/serializers.py:608 core/api/serializers.py:608
msgid "Body"
msgstr ""
#: build/lib/core/api/serializers.py:589 core/api/serializers.py:589
#: build/lib/core/api/serializers.py:611 core/api/serializers.py:611
msgid "Body type"
msgstr ""
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
#: build/lib/core/api/serializers.py:617 core/api/serializers.py:617
msgid "Format"
msgstr "Format"
#: build/lib/core/api/viewsets.py:967 core/api/viewsets.py:967
#: build/lib/core/api/viewsets.py:943 core/api/viewsets.py:943
#, python-brace-format
msgid "copy of {title}"
msgstr ""
#: build/lib/core/choices.py:35 build/lib/core/choices.py:42 core/choices.py:35
#: core/choices.py:42
msgid "Reader"
msgstr ""
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
#: core/choices.py:43
msgid "Editor"
msgstr ""
#: build/lib/core/choices.py:44 core/choices.py:44
msgid "Administrator"
msgstr "Administratör"
#: build/lib/core/choices.py:45 core/choices.py:45
msgid "Owner"
msgstr ""
#: build/lib/core/choices.py:56 core/choices.py:56
msgid "Restricted"
msgstr ""
#: build/lib/core/choices.py:60 core/choices.py:60
msgid "Authenticated"
msgstr ""
#: build/lib/core/choices.py:62 core/choices.py:62
msgid "Public"
msgstr "Publik"
#: build/lib/core/enums.py:36 core/enums.py:36
msgid "First child"
msgstr ""
@@ -95,295 +125,292 @@ msgstr ""
msgid "Right"
msgstr ""
#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56
#: core/models.py:63
msgid "Reader"
msgstr ""
#: build/lib/core/models.py:57 build/lib/core/models.py:64 core/models.py:57
#: core/models.py:64
msgid "Editor"
msgstr ""
#: build/lib/core/models.py:65 core/models.py:65
msgid "Administrator"
msgstr "Administratör"
#: build/lib/core/models.py:66 core/models.py:66
msgid "Owner"
msgstr ""
#: build/lib/core/models.py:77 core/models.py:77
msgid "Restricted"
msgstr ""
#: build/lib/core/models.py:81 core/models.py:81
msgid "Authenticated"
msgstr ""
#: build/lib/core/models.py:83 core/models.py:83
msgid "Public"
msgstr "Publik"
#: build/lib/core/models.py:154 core/models.py:154
#: build/lib/core/models.py:79 core/models.py:79
msgid "id"
msgstr ""
#: build/lib/core/models.py:155 core/models.py:155
#: build/lib/core/models.py:80 core/models.py:80
msgid "primary key for the record as UUID"
msgstr ""
#: build/lib/core/models.py:161 core/models.py:161
#: build/lib/core/models.py:86 core/models.py:86
msgid "created on"
msgstr ""
#: build/lib/core/models.py:162 core/models.py:162
#: build/lib/core/models.py:87 core/models.py:87
msgid "date and time at which a record was created"
msgstr ""
#: build/lib/core/models.py:167 core/models.py:167
#: build/lib/core/models.py:92 core/models.py:92
msgid "updated on"
msgstr ""
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:93 core/models.py:93
msgid "date and time at which a record was last updated"
msgstr ""
#: build/lib/core/models.py:204 core/models.py:204
#: build/lib/core/models.py:129 core/models.py:129
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
msgstr ""
#: build/lib/core/models.py:217 core/models.py:217
#: build/lib/core/models.py:142 core/models.py:142
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr ""
#: build/lib/core/models.py:223 core/models.py:223
#: build/lib/core/models.py:148 core/models.py:148
msgid "sub"
msgstr ""
#: build/lib/core/models.py:225 core/models.py:225
#: build/lib/core/models.py:150 core/models.py:150
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr ""
#: build/lib/core/models.py:234 core/models.py:234
#: build/lib/core/models.py:159 core/models.py:159
msgid "full name"
msgstr ""
#: build/lib/core/models.py:235 core/models.py:235
#: build/lib/core/models.py:160 core/models.py:160
msgid "short name"
msgstr ""
#: build/lib/core/models.py:237 core/models.py:237
#: build/lib/core/models.py:162 core/models.py:162
msgid "identity email address"
msgstr ""
#: build/lib/core/models.py:242 core/models.py:242
#: build/lib/core/models.py:167 core/models.py:167
msgid "admin email address"
msgstr ""
#: build/lib/core/models.py:249 core/models.py:249
#: build/lib/core/models.py:174 core/models.py:174
msgid "language"
msgstr ""
#: build/lib/core/models.py:250 core/models.py:250
#: build/lib/core/models.py:175 core/models.py:175
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/core/models.py:258 core/models.py:258
#: build/lib/core/models.py:183 core/models.py:183
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/core/models.py:261 core/models.py:261
#: build/lib/core/models.py:186 core/models.py:186
msgid "device"
msgstr ""
#: build/lib/core/models.py:263 core/models.py:263
#: build/lib/core/models.py:188 core/models.py:188
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/core/models.py:266 core/models.py:266
#: build/lib/core/models.py:191 core/models.py:191
msgid "staff status"
msgstr ""
#: build/lib/core/models.py:268 core/models.py:268
#: build/lib/core/models.py:193 core/models.py:193
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/core/models.py:271 core/models.py:271
#: build/lib/core/models.py:196 core/models.py:196
msgid "active"
msgstr "aktiv"
#: build/lib/core/models.py:274 core/models.py:274
#: build/lib/core/models.py:199 core/models.py:199
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: build/lib/core/models.py:286 core/models.py:286
#: build/lib/core/models.py:211 core/models.py:211
msgid "user"
msgstr ""
#: build/lib/core/models.py:287 core/models.py:287
#: build/lib/core/models.py:212 core/models.py:212
msgid "users"
msgstr ""
#: build/lib/core/models.py:470 build/lib/core/models.py:1155
#: core/models.py:470 core/models.py:1155
#: build/lib/core/models.py:368 build/lib/core/models.py:1281
#: core/models.py:368 core/models.py:1281
msgid "title"
msgstr ""
#: build/lib/core/models.py:471 core/models.py:471
#: build/lib/core/models.py:369 core/models.py:369
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:519 core/models.py:519
#: build/lib/core/models.py:418 core/models.py:418
msgid "Document"
msgstr ""
#: build/lib/core/models.py:520 core/models.py:520
#: build/lib/core/models.py:419 core/models.py:419
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:532 build/lib/core/models.py:873 core/models.py:532
#: core/models.py:873
#: build/lib/core/models.py:431 build/lib/core/models.py:820 core/models.py:431
#: core/models.py:820
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:908 core/models.py:908
#: build/lib/core/models.py:855 core/models.py:855
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:912 core/models.py:912
#: build/lib/core/models.py:859 core/models.py:859
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:918 core/models.py:918
#: build/lib/core/models.py:865 core/models.py:865
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:1016 core/models.py:1016
#: build/lib/core/models.py:964 core/models.py:964
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:1017 core/models.py:1017
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:1023 core/models.py:1023
#: build/lib/core/models.py:971 core/models.py:971
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:1046 core/models.py:1046
#: build/lib/core/models.py:994 core/models.py:994
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:1047 core/models.py:1047
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1053 core/models.py:1053
#: build/lib/core/models.py:1001 core/models.py:1001
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1075 core/models.py:1075
#: build/lib/core/models.py:1023 core/models.py:1023
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1076 core/models.py:1076
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1082 core/models.py:1082
#: build/lib/core/models.py:1030 core/models.py:1030
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1088 core/models.py:1088
#: build/lib/core/models.py:1036 core/models.py:1036
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1094 build/lib/core/models.py:1242
#: core/models.py:1094 core/models.py:1242
#: build/lib/core/models.py:1042 build/lib/core/models.py:1367
#: core/models.py:1042 core/models.py:1367
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1156 core/models.py:1156
#: build/lib/core/models.py:1188 core/models.py:1188
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1195 core/models.py:1195
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1260 core/models.py:1260
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1264 core/models.py:1264
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1282 core/models.py:1282
msgid "description"
msgstr ""
#: build/lib/core/models.py:1157 core/models.py:1157
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "code"
msgstr ""
#: build/lib/core/models.py:1158 core/models.py:1158
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "css"
msgstr ""
#: build/lib/core/models.py:1160 core/models.py:1160
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "public"
msgstr ""
#: build/lib/core/models.py:1162 core/models.py:1162
#: build/lib/core/models.py:1288 core/models.py:1288
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1168 core/models.py:1168
#: build/lib/core/models.py:1294 core/models.py:1294
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1169 core/models.py:1169
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1223 core/models.py:1223
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1224 core/models.py:1224
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1230 core/models.py:1230
#: build/lib/core/models.py:1355 core/models.py:1355
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1236 core/models.py:1236
#: build/lib/core/models.py:1361 core/models.py:1361
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1259 core/models.py:1259
#: build/lib/core/models.py:1438 core/models.py:1438
msgid "email address"
msgstr "e-postadress"
#: build/lib/core/models.py:1278 core/models.py:1278
#: build/lib/core/models.py:1457 core/models.py:1457
msgid "Document invitation"
msgstr "Bjud in dokument"
#: build/lib/core/models.py:1279 core/models.py:1279
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitations"
msgstr "Inbjudningar dokument"
#: build/lib/core/models.py:1299 core/models.py:1299
#: build/lib/core/models.py:1478 core/models.py:1478
msgid "This email is already associated to a registered user."
msgstr "Denna e-postadress är redan associerad med en registrerad användare."
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/html/template.html:162
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr "Logotyp e-post"
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
#: core/templates/mail/html/template.html:209
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr "Öppna"
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
#: core/templates/mail/html/template.html:226
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr ""
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#: core/templates/mail/html/template.html:233
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr ""
+135 -108
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-05-22 12:09+0000\n"
"PO-Revision-Date: 2025-05-22 14:16\n"
"POT-Creation-Date: 2025-07-08 15:21+0000\n"
"PO-Revision-Date: 2025-07-18 10:25\n"
"Last-Translator: \n"
"Language-Team: Turkish\n"
"Language: tr_TR\n"
@@ -46,31 +46,61 @@ msgstr ""
msgid "Favorite"
msgstr ""
#: build/lib/core/api/serializers.py:446 core/api/serializers.py:446
#: build/lib/core/api/serializers.py:467 core/api/serializers.py:467
msgid "A new document was created on your behalf!"
msgstr ""
#: build/lib/core/api/serializers.py:450 core/api/serializers.py:450
#: build/lib/core/api/serializers.py:471 core/api/serializers.py:471
msgid "You have been granted ownership of a new document:"
msgstr ""
#: build/lib/core/api/serializers.py:586 core/api/serializers.py:586
#: build/lib/core/api/serializers.py:608 core/api/serializers.py:608
msgid "Body"
msgstr ""
#: build/lib/core/api/serializers.py:589 core/api/serializers.py:589
#: build/lib/core/api/serializers.py:611 core/api/serializers.py:611
msgid "Body type"
msgstr ""
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
#: build/lib/core/api/serializers.py:617 core/api/serializers.py:617
msgid "Format"
msgstr ""
#: build/lib/core/api/viewsets.py:967 core/api/viewsets.py:967
#: build/lib/core/api/viewsets.py:943 core/api/viewsets.py:943
#, python-brace-format
msgid "copy of {title}"
msgstr ""
#: build/lib/core/choices.py:35 build/lib/core/choices.py:42 core/choices.py:35
#: core/choices.py:42
msgid "Reader"
msgstr ""
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
#: core/choices.py:43
msgid "Editor"
msgstr ""
#: build/lib/core/choices.py:44 core/choices.py:44
msgid "Administrator"
msgstr ""
#: build/lib/core/choices.py:45 core/choices.py:45
msgid "Owner"
msgstr ""
#: build/lib/core/choices.py:56 core/choices.py:56
msgid "Restricted"
msgstr ""
#: build/lib/core/choices.py:60 core/choices.py:60
msgid "Authenticated"
msgstr ""
#: build/lib/core/choices.py:62 core/choices.py:62
msgid "Public"
msgstr ""
#: build/lib/core/enums.py:36 core/enums.py:36
msgid "First child"
msgstr ""
@@ -95,295 +125,292 @@ msgstr ""
msgid "Right"
msgstr ""
#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56
#: core/models.py:63
msgid "Reader"
msgstr ""
#: build/lib/core/models.py:57 build/lib/core/models.py:64 core/models.py:57
#: core/models.py:64
msgid "Editor"
msgstr ""
#: build/lib/core/models.py:65 core/models.py:65
msgid "Administrator"
msgstr ""
#: build/lib/core/models.py:66 core/models.py:66
msgid "Owner"
msgstr ""
#: build/lib/core/models.py:77 core/models.py:77
msgid "Restricted"
msgstr ""
#: build/lib/core/models.py:81 core/models.py:81
msgid "Authenticated"
msgstr ""
#: build/lib/core/models.py:83 core/models.py:83
msgid "Public"
msgstr ""
#: build/lib/core/models.py:154 core/models.py:154
#: build/lib/core/models.py:79 core/models.py:79
msgid "id"
msgstr ""
#: build/lib/core/models.py:155 core/models.py:155
#: build/lib/core/models.py:80 core/models.py:80
msgid "primary key for the record as UUID"
msgstr ""
#: build/lib/core/models.py:161 core/models.py:161
#: build/lib/core/models.py:86 core/models.py:86
msgid "created on"
msgstr ""
#: build/lib/core/models.py:162 core/models.py:162
#: build/lib/core/models.py:87 core/models.py:87
msgid "date and time at which a record was created"
msgstr ""
#: build/lib/core/models.py:167 core/models.py:167
#: build/lib/core/models.py:92 core/models.py:92
msgid "updated on"
msgstr ""
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:93 core/models.py:93
msgid "date and time at which a record was last updated"
msgstr ""
#: build/lib/core/models.py:204 core/models.py:204
#: build/lib/core/models.py:129 core/models.py:129
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
msgstr ""
#: build/lib/core/models.py:217 core/models.py:217
#: build/lib/core/models.py:142 core/models.py:142
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr ""
#: build/lib/core/models.py:223 core/models.py:223
#: build/lib/core/models.py:148 core/models.py:148
msgid "sub"
msgstr ""
#: build/lib/core/models.py:225 core/models.py:225
#: build/lib/core/models.py:150 core/models.py:150
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr ""
#: build/lib/core/models.py:234 core/models.py:234
#: build/lib/core/models.py:159 core/models.py:159
msgid "full name"
msgstr ""
#: build/lib/core/models.py:235 core/models.py:235
#: build/lib/core/models.py:160 core/models.py:160
msgid "short name"
msgstr ""
#: build/lib/core/models.py:237 core/models.py:237
#: build/lib/core/models.py:162 core/models.py:162
msgid "identity email address"
msgstr ""
#: build/lib/core/models.py:242 core/models.py:242
#: build/lib/core/models.py:167 core/models.py:167
msgid "admin email address"
msgstr ""
#: build/lib/core/models.py:249 core/models.py:249
#: build/lib/core/models.py:174 core/models.py:174
msgid "language"
msgstr ""
#: build/lib/core/models.py:250 core/models.py:250
#: build/lib/core/models.py:175 core/models.py:175
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/core/models.py:258 core/models.py:258
#: build/lib/core/models.py:183 core/models.py:183
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/core/models.py:261 core/models.py:261
#: build/lib/core/models.py:186 core/models.py:186
msgid "device"
msgstr ""
#: build/lib/core/models.py:263 core/models.py:263
#: build/lib/core/models.py:188 core/models.py:188
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/core/models.py:266 core/models.py:266
#: build/lib/core/models.py:191 core/models.py:191
msgid "staff status"
msgstr ""
#: build/lib/core/models.py:268 core/models.py:268
#: build/lib/core/models.py:193 core/models.py:193
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/core/models.py:271 core/models.py:271
#: build/lib/core/models.py:196 core/models.py:196
msgid "active"
msgstr ""
#: build/lib/core/models.py:274 core/models.py:274
#: build/lib/core/models.py:199 core/models.py:199
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: build/lib/core/models.py:286 core/models.py:286
#: build/lib/core/models.py:211 core/models.py:211
msgid "user"
msgstr ""
#: build/lib/core/models.py:287 core/models.py:287
#: build/lib/core/models.py:212 core/models.py:212
msgid "users"
msgstr ""
#: build/lib/core/models.py:470 build/lib/core/models.py:1155
#: core/models.py:470 core/models.py:1155
#: build/lib/core/models.py:368 build/lib/core/models.py:1281
#: core/models.py:368 core/models.py:1281
msgid "title"
msgstr ""
#: build/lib/core/models.py:471 core/models.py:471
#: build/lib/core/models.py:369 core/models.py:369
msgid "excerpt"
msgstr ""
#: build/lib/core/models.py:519 core/models.py:519
#: build/lib/core/models.py:418 core/models.py:418
msgid "Document"
msgstr ""
#: build/lib/core/models.py:520 core/models.py:520
#: build/lib/core/models.py:419 core/models.py:419
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:532 build/lib/core/models.py:873 core/models.py:532
#: core/models.py:873
#: build/lib/core/models.py:431 build/lib/core/models.py:820 core/models.py:431
#: core/models.py:820
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:908 core/models.py:908
#: build/lib/core/models.py:855 core/models.py:855
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:912 core/models.py:912
#: build/lib/core/models.py:859 core/models.py:859
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:918 core/models.py:918
#: build/lib/core/models.py:865 core/models.py:865
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:1016 core/models.py:1016
#: build/lib/core/models.py:964 core/models.py:964
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:1017 core/models.py:1017
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:1023 core/models.py:1023
#: build/lib/core/models.py:971 core/models.py:971
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:1046 core/models.py:1046
#: build/lib/core/models.py:994 core/models.py:994
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:1047 core/models.py:1047
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:1053 core/models.py:1053
#: build/lib/core/models.py:1001 core/models.py:1001
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:1075 core/models.py:1075
#: build/lib/core/models.py:1023 core/models.py:1023
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:1076 core/models.py:1076
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:1082 core/models.py:1082
#: build/lib/core/models.py:1030 core/models.py:1030
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:1088 core/models.py:1088
#: build/lib/core/models.py:1036 core/models.py:1036
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:1094 build/lib/core/models.py:1242
#: core/models.py:1094 core/models.py:1242
#: build/lib/core/models.py:1042 build/lib/core/models.py:1367
#: core/models.py:1042 core/models.py:1367
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:1156 core/models.py:1156
#: build/lib/core/models.py:1188 core/models.py:1188
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1195 core/models.py:1195
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1260 core/models.py:1260
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1264 core/models.py:1264
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1282 core/models.py:1282
msgid "description"
msgstr ""
#: build/lib/core/models.py:1157 core/models.py:1157
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "code"
msgstr ""
#: build/lib/core/models.py:1158 core/models.py:1158
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "css"
msgstr ""
#: build/lib/core/models.py:1160 core/models.py:1160
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "public"
msgstr ""
#: build/lib/core/models.py:1162 core/models.py:1162
#: build/lib/core/models.py:1288 core/models.py:1288
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:1168 core/models.py:1168
#: build/lib/core/models.py:1294 core/models.py:1294
msgid "Template"
msgstr ""
#: build/lib/core/models.py:1169 core/models.py:1169
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:1223 core/models.py:1223
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:1224 core/models.py:1224
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1230 core/models.py:1230
#: build/lib/core/models.py:1355 core/models.py:1355
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1236 core/models.py:1236
#: build/lib/core/models.py:1361 core/models.py:1361
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1259 core/models.py:1259
#: build/lib/core/models.py:1438 core/models.py:1438
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1278 core/models.py:1278
#: build/lib/core/models.py:1457 core/models.py:1457
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1279 core/models.py:1279
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1299 core/models.py:1299
#: build/lib/core/models.py:1478 core/models.py:1478
msgid "This email is already associated to a registered user."
msgstr ""
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/html/template.html:162
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr ""
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
#: core/templates/mail/html/template.html:209
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr ""
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
#: core/templates/mail/html/template.html:226
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr ""
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#: core/templates/mail/html/template.html:233
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr ""
+135 -108
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-05-22 12:09+0000\n"
"PO-Revision-Date: 2025-05-22 14:16\n"
"POT-Creation-Date: 2025-07-08 15:21+0000\n"
"PO-Revision-Date: 2025-07-18 10:25\n"
"Last-Translator: \n"
"Language-Team: Chinese Simplified\n"
"Language: zh_CN\n"
@@ -46,31 +46,61 @@ msgstr "创建者是我"
msgid "Favorite"
msgstr "收藏"
#: build/lib/core/api/serializers.py:446 core/api/serializers.py:446
#: build/lib/core/api/serializers.py:467 core/api/serializers.py:467
msgid "A new document was created on your behalf!"
msgstr "已为您创建了一份新文档!"
#: build/lib/core/api/serializers.py:450 core/api/serializers.py:450
#: build/lib/core/api/serializers.py:471 core/api/serializers.py:471
msgid "You have been granted ownership of a new document:"
msgstr "您已被授予新文档的所有权:"
#: build/lib/core/api/serializers.py:586 core/api/serializers.py:586
#: build/lib/core/api/serializers.py:608 core/api/serializers.py:608
msgid "Body"
msgstr "正文"
#: build/lib/core/api/serializers.py:589 core/api/serializers.py:589
#: build/lib/core/api/serializers.py:611 core/api/serializers.py:611
msgid "Body type"
msgstr "正文类型"
#: build/lib/core/api/serializers.py:595 core/api/serializers.py:595
#: build/lib/core/api/serializers.py:617 core/api/serializers.py:617
msgid "Format"
msgstr "格式"
#: build/lib/core/api/viewsets.py:967 core/api/viewsets.py:967
#: build/lib/core/api/viewsets.py:943 core/api/viewsets.py:943
#, python-brace-format
msgid "copy of {title}"
msgstr "{title} 的副本"
#: build/lib/core/choices.py:35 build/lib/core/choices.py:42 core/choices.py:35
#: core/choices.py:42
msgid "Reader"
msgstr "阅读者"
#: build/lib/core/choices.py:36 build/lib/core/choices.py:43 core/choices.py:36
#: core/choices.py:43
msgid "Editor"
msgstr "编辑者"
#: build/lib/core/choices.py:44 core/choices.py:44
msgid "Administrator"
msgstr "超级管理员"
#: build/lib/core/choices.py:45 core/choices.py:45
msgid "Owner"
msgstr "所有者"
#: build/lib/core/choices.py:56 core/choices.py:56
msgid "Restricted"
msgstr "受限的"
#: build/lib/core/choices.py:60 core/choices.py:60
msgid "Authenticated"
msgstr "已验证"
#: build/lib/core/choices.py:62 core/choices.py:62
msgid "Public"
msgstr "公开"
#: build/lib/core/enums.py:36 core/enums.py:36
msgid "First child"
msgstr "第一个子项"
@@ -95,295 +125,292 @@ msgstr "左"
msgid "Right"
msgstr "右"
#: build/lib/core/models.py:56 build/lib/core/models.py:63 core/models.py:56
#: core/models.py:63
msgid "Reader"
msgstr "阅读者"
#: build/lib/core/models.py:57 build/lib/core/models.py:64 core/models.py:57
#: core/models.py:64
msgid "Editor"
msgstr "编辑者"
#: build/lib/core/models.py:65 core/models.py:65
msgid "Administrator"
msgstr "超级管理员"
#: build/lib/core/models.py:66 core/models.py:66
msgid "Owner"
msgstr "所有者"
#: build/lib/core/models.py:77 core/models.py:77
msgid "Restricted"
msgstr "受限的"
#: build/lib/core/models.py:81 core/models.py:81
msgid "Authenticated"
msgstr "已验证"
#: build/lib/core/models.py:83 core/models.py:83
msgid "Public"
msgstr "公开"
#: build/lib/core/models.py:154 core/models.py:154
#: build/lib/core/models.py:79 core/models.py:79
msgid "id"
msgstr "id"
#: build/lib/core/models.py:155 core/models.py:155
#: build/lib/core/models.py:80 core/models.py:80
msgid "primary key for the record as UUID"
msgstr "记录的主密钥为 UUID"
#: build/lib/core/models.py:161 core/models.py:161
#: build/lib/core/models.py:86 core/models.py:86
msgid "created on"
msgstr "创建时间"
#: build/lib/core/models.py:162 core/models.py:162
#: build/lib/core/models.py:87 core/models.py:87
msgid "date and time at which a record was created"
msgstr "记录的创建日期和时间"
#: build/lib/core/models.py:167 core/models.py:167
#: build/lib/core/models.py:92 core/models.py:92
msgid "updated on"
msgstr "更新时间"
#: build/lib/core/models.py:168 core/models.py:168
#: build/lib/core/models.py:93 core/models.py:93
msgid "date and time at which a record was last updated"
msgstr "记录的最后更新时间"
#: build/lib/core/models.py:204 core/models.py:204
#: build/lib/core/models.py:129 core/models.py:129
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
msgstr "未找到具有该 sub 的用户,但该邮箱已关联到一个注册用户。"
#: build/lib/core/models.py:217 core/models.py:217
#: build/lib/core/models.py:142 core/models.py:142
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr "请输入有效的 sub。该值只能包含字母、数字及 @/./+/-/_/: 字符。"
#: build/lib/core/models.py:223 core/models.py:223
#: build/lib/core/models.py:148 core/models.py:148
msgid "sub"
msgstr "sub"
#: build/lib/core/models.py:225 core/models.py:225
#: build/lib/core/models.py:150 core/models.py:150
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr "必填。最多 255 个字符,仅允许字母、数字及 @/./+/-/_/: 字符。"
#: build/lib/core/models.py:234 core/models.py:234
#: build/lib/core/models.py:159 core/models.py:159
msgid "full name"
msgstr "全名"
#: build/lib/core/models.py:235 core/models.py:235
#: build/lib/core/models.py:160 core/models.py:160
msgid "short name"
msgstr "简称"
#: build/lib/core/models.py:237 core/models.py:237
#: build/lib/core/models.py:162 core/models.py:162
msgid "identity email address"
msgstr "身份电子邮件地址"
#: build/lib/core/models.py:242 core/models.py:242
#: build/lib/core/models.py:167 core/models.py:167
msgid "admin email address"
msgstr "管理员电子邮件地址"
#: build/lib/core/models.py:249 core/models.py:249
#: build/lib/core/models.py:174 core/models.py:174
msgid "language"
msgstr "语言"
#: build/lib/core/models.py:250 core/models.py:250
#: build/lib/core/models.py:175 core/models.py:175
msgid "The language in which the user wants to see the interface."
msgstr "用户希望看到的界面语言。"
#: build/lib/core/models.py:258 core/models.py:258
#: build/lib/core/models.py:183 core/models.py:183
msgid "The timezone in which the user wants to see times."
msgstr "用户查看时间希望的时区。"
#: build/lib/core/models.py:261 core/models.py:261
#: build/lib/core/models.py:186 core/models.py:186
msgid "device"
msgstr "设备"
#: build/lib/core/models.py:263 core/models.py:263
#: build/lib/core/models.py:188 core/models.py:188
msgid "Whether the user is a device or a real user."
msgstr "用户是设备还是真实用户。"
#: build/lib/core/models.py:266 core/models.py:266
#: build/lib/core/models.py:191 core/models.py:191
msgid "staff status"
msgstr "员工状态"
#: build/lib/core/models.py:268 core/models.py:268
#: build/lib/core/models.py:193 core/models.py:193
msgid "Whether the user can log into this admin site."
msgstr "用户是否可以登录该管理员站点。"
#: build/lib/core/models.py:271 core/models.py:271
#: build/lib/core/models.py:196 core/models.py:196
msgid "active"
msgstr "激活"
#: build/lib/core/models.py:274 core/models.py:274
#: build/lib/core/models.py:199 core/models.py:199
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "是否应将此用户视为活跃用户。取消选择此选项而不是删除账户。"
#: build/lib/core/models.py:286 core/models.py:286
#: build/lib/core/models.py:211 core/models.py:211
msgid "user"
msgstr "用户"
#: build/lib/core/models.py:287 core/models.py:287
#: build/lib/core/models.py:212 core/models.py:212
msgid "users"
msgstr "个用户"
#: build/lib/core/models.py:470 build/lib/core/models.py:1155
#: core/models.py:470 core/models.py:1155
#: build/lib/core/models.py:368 build/lib/core/models.py:1281
#: core/models.py:368 core/models.py:1281
msgid "title"
msgstr "标题"
#: build/lib/core/models.py:471 core/models.py:471
#: build/lib/core/models.py:369 core/models.py:369
msgid "excerpt"
msgstr "摘要"
#: build/lib/core/models.py:519 core/models.py:519
#: build/lib/core/models.py:418 core/models.py:418
msgid "Document"
msgstr "文档"
#: build/lib/core/models.py:520 core/models.py:520
#: build/lib/core/models.py:419 core/models.py:419
msgid "Documents"
msgstr "个文档"
#: build/lib/core/models.py:532 build/lib/core/models.py:873 core/models.py:532
#: core/models.py:873
#: build/lib/core/models.py:431 build/lib/core/models.py:820 core/models.py:431
#: core/models.py:820
msgid "Untitled Document"
msgstr "未命名文档"
#: build/lib/core/models.py:908 core/models.py:908
#: build/lib/core/models.py:855 core/models.py:855
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} 与您共享了一个文档!"
#: build/lib/core/models.py:912 core/models.py:912
#: build/lib/core/models.py:859 core/models.py:859
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} 邀请您以“{role}”角色访问以下文档:"
#: build/lib/core/models.py:918 core/models.py:918
#: build/lib/core/models.py:865 core/models.py:865
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} 与您共享了一个文档:{title}"
#: build/lib/core/models.py:1016 core/models.py:1016
#: build/lib/core/models.py:964 core/models.py:964
msgid "Document/user link trace"
msgstr "文档/用户链接跟踪"
#: build/lib/core/models.py:1017 core/models.py:1017
#: build/lib/core/models.py:965 core/models.py:965
msgid "Document/user link traces"
msgstr "个文档/用户链接跟踪"
#: build/lib/core/models.py:1023 core/models.py:1023
#: build/lib/core/models.py:971 core/models.py:971
msgid "A link trace already exists for this document/user."
msgstr "此文档/用户的链接跟踪已存在。"
#: build/lib/core/models.py:1046 core/models.py:1046
#: build/lib/core/models.py:994 core/models.py:994
msgid "Document favorite"
msgstr "文档收藏"
#: build/lib/core/models.py:1047 core/models.py:1047
#: build/lib/core/models.py:995 core/models.py:995
msgid "Document favorites"
msgstr "文档收藏夹"
#: build/lib/core/models.py:1053 core/models.py:1053
#: build/lib/core/models.py:1001 core/models.py:1001
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "该文档已被同一用户的收藏关系实例关联。"
#: build/lib/core/models.py:1075 core/models.py:1075
#: build/lib/core/models.py:1023 core/models.py:1023
msgid "Document/user relation"
msgstr "文档/用户关系"
#: build/lib/core/models.py:1076 core/models.py:1076
#: build/lib/core/models.py:1024 core/models.py:1024
msgid "Document/user relations"
msgstr "文档/用户关系集"
#: build/lib/core/models.py:1082 core/models.py:1082
#: build/lib/core/models.py:1030 core/models.py:1030
msgid "This user is already in this document."
msgstr "该用户已在此文档中。"
#: build/lib/core/models.py:1088 core/models.py:1088
#: build/lib/core/models.py:1036 core/models.py:1036
msgid "This team is already in this document."
msgstr "该团队已在此文档中。"
#: build/lib/core/models.py:1094 build/lib/core/models.py:1242
#: core/models.py:1094 core/models.py:1242
#: build/lib/core/models.py:1042 build/lib/core/models.py:1367
#: core/models.py:1042 core/models.py:1367
msgid "Either user or team must be set, not both."
msgstr "必须设置用户或团队之一,不能同时设置两者。"
#: build/lib/core/models.py:1156 core/models.py:1156
#: build/lib/core/models.py:1188 core/models.py:1188
msgid "Document ask for access"
msgstr ""
#: build/lib/core/models.py:1189 core/models.py:1189
msgid "Document ask for accesses"
msgstr ""
#: build/lib/core/models.py:1195 core/models.py:1195
msgid "This user has already asked for access to this document."
msgstr ""
#: build/lib/core/models.py:1260 core/models.py:1260
#, python-brace-format
msgid "{name} would like access to a document!"
msgstr ""
#: build/lib/core/models.py:1264 core/models.py:1264
#, python-brace-format
msgid "{name} would like access to the following document:"
msgstr ""
#: build/lib/core/models.py:1270 core/models.py:1270
#, python-brace-format
msgid "{name} is asking for access to the document: {title}"
msgstr ""
#: build/lib/core/models.py:1282 core/models.py:1282
msgid "description"
msgstr "说明"
#: build/lib/core/models.py:1157 core/models.py:1157
#: build/lib/core/models.py:1283 core/models.py:1283
msgid "code"
msgstr "代码"
#: build/lib/core/models.py:1158 core/models.py:1158
#: build/lib/core/models.py:1284 core/models.py:1284
msgid "css"
msgstr "css"
#: build/lib/core/models.py:1160 core/models.py:1160
#: build/lib/core/models.py:1286 core/models.py:1286
msgid "public"
msgstr "公开"
#: build/lib/core/models.py:1162 core/models.py:1162
#: build/lib/core/models.py:1288 core/models.py:1288
msgid "Whether this template is public for anyone to use."
msgstr "该模板是否公开供任何人使用。"
#: build/lib/core/models.py:1168 core/models.py:1168
#: build/lib/core/models.py:1294 core/models.py:1294
msgid "Template"
msgstr "模板"
#: build/lib/core/models.py:1169 core/models.py:1169
#: build/lib/core/models.py:1295 core/models.py:1295
msgid "Templates"
msgstr "模板"
#: build/lib/core/models.py:1223 core/models.py:1223
#: build/lib/core/models.py:1348 core/models.py:1348
msgid "Template/user relation"
msgstr "模板/用户关系"
#: build/lib/core/models.py:1224 core/models.py:1224
#: build/lib/core/models.py:1349 core/models.py:1349
msgid "Template/user relations"
msgstr "模板/用户关系集"
#: build/lib/core/models.py:1230 core/models.py:1230
#: build/lib/core/models.py:1355 core/models.py:1355
msgid "This user is already in this template."
msgstr "该用户已在此模板中。"
#: build/lib/core/models.py:1236 core/models.py:1236
#: build/lib/core/models.py:1361 core/models.py:1361
msgid "This team is already in this template."
msgstr "该团队已在此模板中。"
#: build/lib/core/models.py:1259 core/models.py:1259
#: build/lib/core/models.py:1438 core/models.py:1438
msgid "email address"
msgstr "电子邮件地址"
#: build/lib/core/models.py:1278 core/models.py:1278
#: build/lib/core/models.py:1457 core/models.py:1457
msgid "Document invitation"
msgstr "文档邀请"
#: build/lib/core/models.py:1279 core/models.py:1279
#: build/lib/core/models.py:1458 core/models.py:1458
msgid "Document invitations"
msgstr "文档邀请"
#: build/lib/core/models.py:1299 core/models.py:1299
#: build/lib/core/models.py:1478 core/models.py:1478
msgid "This email is already associated to a registered user."
msgstr "此电子邮件已经与现有注册用户关联。"
#: core/templates/mail/html/invitation.html:162
#: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/html/template.html:162
#: core/templates/mail/text/template.txt:3
msgid "Logo email"
msgstr "徽标邮件"
#: core/templates/mail/html/invitation.html:209
#: core/templates/mail/text/invitation.txt:10
#: core/templates/mail/html/template.html:209
#: core/templates/mail/text/template.txt:10
msgid "Open"
msgstr "打开"
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
#: core/templates/mail/html/template.html:226
#: core/templates/mail/text/template.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr " Docs——您的全新必备工具,帮助团队组织、共享和协作处理文档。 "
#: core/templates/mail/html/invitation.html:233
#: core/templates/mail/text/invitation.txt:16
#: core/templates/mail/html/template.html:233
#: core/templates/mail/text/template.txt:16
#, python-format
msgid " Brought to you by %(brandname)s "
msgstr " 由 %(brandname)s 倾力打造。 "
+14 -14
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "3.3.0"
version = "3.4.2"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -26,7 +26,7 @@ readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"beautifulsoup4==4.13.4",
"boto3==1.38.46",
"boto3==1.39.4",
"Brotli==1.1.0",
"celery[redis]==5.5.3",
"django-configurations==2.5.1",
@@ -34,12 +34,12 @@ dependencies = [
"django-countries==7.6.1",
"django-csp==4.0",
"django-filter==25.1",
"django-lasuite[all]==0.0.10",
"django-lasuite[all]==0.0.11",
"django-parler==2.3",
"django-redis==6.0.0",
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django==5.2.3",
"django==5.2.4",
"django-treebeard==4.7.1",
"djangorestframework==3.16.0",
"drf_spectacular==0.28.0",
@@ -52,9 +52,9 @@ dependencies = [
"markdown==3.8.2",
"mozilla-django-oidc==4.0.1",
"nested-multipart-parser==1.5.0",
"openai==1.93.0",
"openai==1.95.0",
"psycopg[binary]==3.2.9",
"pycrdt==0.12.23",
"pycrdt==0.12.25",
"PyJWT==2.10.1",
"python-magic==0.4.27",
"redis<6.0.0",
@@ -64,19 +64,19 @@ dependencies = [
]
[project.urls]
"Bug Tracker" = "https://github.com/numerique-gouv/impress/issues/new"
"Changelog" = "https://github.com/numerique-gouv/impress/blob/main/CHANGELOG.md"
"Homepage" = "https://github.com/numerique-gouv/impress"
"Repository" = "https://github.com/numerique-gouv/impress"
"Bug Tracker" = "https://github.com/suitenumerique/docs/issues/new"
"Changelog" = "https://github.com/suitenumerique/docs/blob/main/CHANGELOG.md"
"Homepage" = "https://github.com/suitenumerique/docs"
"Repository" = "https://github.com/suitenumerique/docs"
[project.optional-dependencies]
dev = [
"django-extensions==4.1",
"django-test-migrations==1.5.0",
"drf-spectacular-sidecar==2025.6.1",
"drf-spectacular-sidecar==2025.7.1",
"freezegun==1.5.2",
"ipdb==0.13.13",
"ipython==9.3.0",
"ipython==9.4.0",
"pyfakefs==5.9.1",
"pylint-django==2.6.1",
"pylint==3.3.7",
@@ -84,9 +84,9 @@ dev = [
"pytest-django==4.11.1",
"pytest==8.4.1",
"pytest-icdiff==0.9",
"pytest-xdist==3.7.0",
"pytest-xdist==3.8.0",
"responses==0.25.7",
"ruff==0.12.1",
"ruff==0.12.2",
"types-requests==2.32.4.20250611",
]
@@ -1,6 +1,6 @@
import { FullConfig, FullProject, chromium, expect } from '@playwright/test';
import { keyCloakSignIn } from './common';
import { keyCloakSignIn } from './utils-common';
const saveStorageState = async (
browserConfig: FullProject<unknown, unknown>,
@@ -2,7 +2,7 @@ import path from 'path';
import { expect, test } from '@playwright/test';
import { CONFIG, createDoc, overrideConfig } from './common';
import { CONFIG, createDoc, overrideConfig } from './utils-common';
test.describe('Config', () => {
test('it checks that sentry is trying to init from config endpoint', async ({
@@ -6,7 +6,7 @@ import {
keyCloakSignIn,
randomName,
verifyDocName,
} from './common';
} from './utils-common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -14,10 +14,10 @@ test.beforeEach(async ({ page }) => {
test.describe('Doc Create', () => {
test('it creates a doc', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, 'My new doc', browserName, 1);
const [docTitle] = await createDoc(page, 'my-new-doc', browserName, 1);
await page.waitForFunction(
() => document.title.match(/My new doc - Docs/),
() => document.title.match(/my-new-doc - Docs/),
{ timeout: 5000 },
);
@@ -29,6 +29,50 @@ test.describe('Doc Create', () => {
await expect(page.getByTestId('grid-loader')).toBeHidden();
await expect(docsGrid.getByText(docTitle)).toBeVisible();
});
test('it creates a sub doc from slash menu editor', async ({
page,
browserName,
}) => {
const [title] = await createDoc(page, 'my-new-slash-doc', browserName, 1);
await verifyDocName(page, title);
await page.locator('.bn-block-outer').last().fill('/');
await page
.getByText('New sub-doc', {
exact: true,
})
.click();
const input = page.getByRole('textbox', { name: 'doc title input' });
await expect(input).toHaveText('');
await expect(
page.locator('.c__tree-view--row-content').getByText('Untitled document'),
).toBeVisible();
});
test('it creates a sub doc from interlinking dropdown', async ({
page,
browserName,
}) => {
const [title] = await createDoc(page, 'my-new-slash-doc', browserName, 1);
await verifyDocName(page, title);
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Link a doc').first().click();
await page
.locator('.quick-search-container')
.getByText('New sub-doc')
.click();
const input = page.getByRole('textbox', { name: 'doc title input' });
await expect(input).toHaveText('');
await expect(
page.locator('.c__tree-view--row-content').getByText('Untitled document'),
).toBeVisible();
});
});
test.describe('Doc Create: Not logged', () => {
@@ -0,0 +1,74 @@
import { expect, test } from '@playwright/test';
test.describe('Doc Editor - Heading Accessibility', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('should filter heading options progressively (h1 -> h2 -> h3)', async ({
page,
}) => {
await page.getByRole('button', { name: 'Nouveau doc' }).click();
await page.waitForURL('**/docs/**', {
timeout: 10000,
waitUntil: 'domcontentloaded',
});
const input = page.getByLabel('doc title input');
await input.fill('heading-accessibility-test');
await input.blur();
const editor = page.locator('.ProseMirror');
await editor.click();
await page.keyboard.type('/');
await expect(page.getByText('Titre 1')).toBeVisible();
await expect(page.getByText('Titre 2')).toBeHidden();
await expect(page.getByText('Titre 3')).toBeHidden();
await page.getByText('Titre 1').click();
await page.keyboard.type('Main Title');
await page.keyboard.press('Enter');
await editor.click();
await page.keyboard.type('/');
await expect(page.getByText('Titre 1')).toBeHidden();
await expect(page.getByText('Titre 2')).toBeVisible();
await expect(page.getByText('Titre 3')).toBeHidden();
await page.getByText('Titre 2').click();
await page.keyboard.type('Sub Title');
await page.keyboard.press('Enter');
await editor.click();
await page.keyboard.type('/');
await expect(page.getByText('Titre 1')).toBeHidden();
await expect(page.getByText('Titre 2')).toBeVisible();
await expect(page.getByText('Titre 3')).toBeVisible();
await page.getByText('Titre 3').click();
await page.keyboard.type('Sub Sub Title');
await page.keyboard.press('Enter');
await editor.click();
await page.keyboard.type('/');
await expect(page.getByText('Titre 1')).toBeHidden();
await expect(page.getByText('Titre 2')).toBeHidden();
await expect(page.getByText('Titre 3')).toBeVisible();
await page.getByText('Titre 3').click();
await page.keyboard.type('Another Sub Sub Title');
await page.keyboard.press('Enter');
await editor.click();
await page.keyboard.type('/');
await expect(page.getByText('Titre 1')).toBeHidden();
await expect(page.getByText('Titre 2')).toBeHidden();
await expect(page.getByText('Titre 3')).toBeVisible();
});
});
@@ -1,16 +1,16 @@
import path from 'path';
import { expect, test } from '@playwright/test';
import { chromium, expect, test } from '@playwright/test';
import cs from 'convert-stream';
import {
CONFIG,
addNewMember,
createDoc,
goToGridDoc,
mockedDocument,
overrideConfig,
verifyDocName,
} from './common';
} from './utils-common';
import { createRootSubPage } from './utils-sub-pages';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -173,6 +173,7 @@ test.describe('Doc Editor', () => {
await expect(editor.getByText('Hello World Doc 2')).toBeHidden();
await expect(editor.getByText('Hello World Doc 1')).toBeVisible();
await page.goto('/');
await page
.getByRole('button', {
name: 'New doc',
@@ -522,52 +523,151 @@ test.describe('Doc Editor', () => {
test('it checks block editing when not connected to collab server', async ({
page,
browserName,
}) => {
await page.route('**/api/v1.0/config/', async (route) => {
const request = route.request();
if (request.method().includes('GET')) {
await route.fulfill({
json: {
...CONFIG,
COLLABORATION_WS_URL: 'ws://localhost:5555/collaboration/ws/',
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY: true,
},
});
} else {
await route.continue();
}
test.slow();
/**
* The good port is 4444, but we want to simulate a not connected
* collaborative server.
* So we use a port that is not used by the collaborative server.
* The server will not be able to connect to the collaborative server.
*/
await overrideConfig(page, {
COLLABORATION_WS_URL: 'ws://localhost:5555/collaboration/ws/',
});
await page.goto('/');
void page
.getByRole('button', {
name: 'New doc',
})
.click();
const [parentTitle] = await createDoc(
page,
'editing-blocking',
browserName,
1,
);
const card = page.getByLabel('It is the card information');
await expect(
card.getByText('Your network do not allow you to edit'),
card.getByText('Others are editing. Your network prevent changes.'),
).toBeHidden();
const editor = page.locator('.ProseMirror');
await expect(editor).toHaveAttribute('contenteditable', 'true');
let responseCanEditPromise = page.waitForResponse(
(response) =>
response.url().includes(`/can-edit/`) && response.status() === 200,
);
await page.getByRole('button', { name: 'Share' }).click();
await addNewMember(page, 0, 'Editor', 'impress');
await page.getByLabel('Visibility', { exact: true }).click();
await page
.getByRole('menuitem', {
name: 'Public',
})
.click();
await expect(
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.getByLabel('Visibility mode').click();
await page.getByRole('menuitem', { name: 'Editing' }).click();
// Close the modal
await page.getByRole('button', { name: 'close' }).first().click();
const urlParentDoc = page.url();
const { name: childTitle } = await createRootSubPage(
page,
browserName,
'editing-blocking - child',
);
let responseCanEdit = await responseCanEditPromise;
expect(responseCanEdit.ok()).toBeTruthy();
let jsonCanEdit = (await responseCanEdit.json()) as { can_edit: boolean };
expect(jsonCanEdit.can_edit).toBeTruthy();
const urlChildDoc = page.url();
/**
* We open another browser that will connect to the collaborative server
* and will block the current browser to edit the doc.
*/
const otherBrowser = await chromium.launch({ headless: true });
const otherContext = await otherBrowser.newContext({
locale: 'en-US',
timezoneId: 'Europe/Paris',
permissions: [],
storageState: {
cookies: [],
origins: [],
},
});
const otherPage = await otherContext.newPage();
const webSocketPromise = otherPage.waitForEvent(
'websocket',
(webSocket) => {
return webSocket
.url()
.includes('ws://localhost:4444/collaboration/ws/?room=');
},
);
await otherPage.goto(urlChildDoc);
const webSocket = await webSocketPromise;
expect(webSocket.url()).toContain(
'ws://localhost:4444/collaboration/ws/?room=',
);
await verifyDocName(otherPage, childTitle);
await page.reload();
responseCanEditPromise = page.waitForResponse(
(response) =>
response.url().includes(`/can-edit/`) && response.status() === 200,
);
responseCanEdit = await responseCanEditPromise;
expect(responseCanEdit.ok()).toBeTruthy();
jsonCanEdit = (await responseCanEdit.json()) as { can_edit: boolean };
expect(jsonCanEdit.can_edit).toBeFalsy();
await expect(
card.getByText('Your network do not allow you to edit'),
card.getByText('Others are editing. Your network prevent changes.'),
).toBeVisible({
timeout: 10000,
});
await expect(editor).toHaveAttribute('contenteditable', 'false');
await page.goto(urlParentDoc);
await verifyDocName(page, parentTitle);
await page.getByRole('button', { name: 'Share' }).click();
await page.getByLabel('Visibility mode').click();
await page.getByRole('menuitem', { name: 'Reading' }).click();
// Close the modal
await page.getByRole('button', { name: 'close' }).first().click();
await page.goto(urlChildDoc);
await expect(editor).toHaveAttribute('contenteditable', 'true');
await expect(
card.getByText('Others are editing. Your network prevent changes.'),
).toBeHidden();
});
test('it checks if callout custom block', async ({ page, browserName }) => {
@@ -606,4 +706,75 @@ test.describe('Doc Editor', () => {
'pink',
);
});
test('it checks interlink feature', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-interlink', browserName, 1);
await verifyDocName(page, randomDoc);
const { name: docChild1 } = await createRootSubPage(
page,
browserName,
'doc-interlink-child-1',
);
await verifyDocName(page, docChild1);
const { name: docChild2 } = await createRootSubPage(
page,
browserName,
'doc-interlink-child-2',
);
await verifyDocName(page, docChild2);
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Link a doc').first().click();
const input = page.locator(
"span[data-inline-content-type='interlinkingSearchInline'] input",
);
const searchContainer = page.locator('.quick-search-container');
await input.fill('doc-interlink');
await expect(searchContainer.getByText(randomDoc)).toBeVisible();
await expect(searchContainer.getByText(docChild1)).toBeVisible();
await expect(searchContainer.getByText(docChild2)).toBeVisible();
await input.pressSequentially('-child');
await expect(searchContainer.getByText(docChild1)).toBeVisible();
await expect(searchContainer.getByText(docChild2)).toBeVisible();
await expect(searchContainer.getByText(randomDoc)).toBeHidden();
// use keydown to select the second result
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Enter');
const interlink = page.getByRole('link', {
name: 'child-2',
});
await expect(interlink).toBeVisible();
await interlink.click();
await verifyDocName(page, docChild2);
});
test('it checks interlink shortcut @', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-interlink', browserName, 1);
await verifyDocName(page, randomDoc);
const editor = page.locator('.bn-block-outer').last();
await editor.click();
await page.keyboard.press('@');
await expect(
page.locator(
"span[data-inline-content-type='interlinkingSearchInline'] input",
),
).toBeVisible();
});
});
@@ -4,7 +4,8 @@ import { expect, test } from '@playwright/test';
import cs from 'convert-stream';
import pdf from 'pdf-parse';
import { createDoc, verifyDocName } from './common';
import { createDoc, verifyDocName } from './utils-common';
import { createRootSubPage } from './utils-sub-pages';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -72,6 +73,7 @@ test.describe('Doc Export', () => {
await page
.getByRole('button', {
name: 'download',
exact: true,
})
.click();
@@ -82,6 +84,7 @@ test.describe('Doc Export', () => {
void page
.getByRole('button', {
name: 'Download',
exact: true,
})
.click();
@@ -120,6 +123,7 @@ test.describe('Doc Export', () => {
await page
.getByRole('button', {
name: 'download',
exact: true,
})
.click();
@@ -129,6 +133,7 @@ test.describe('Doc Export', () => {
await expect(
page.getByRole('button', {
name: 'Download',
exact: true,
}),
).toBeVisible();
@@ -139,6 +144,7 @@ test.describe('Doc Export', () => {
void page
.getByRole('button', {
name: 'Download',
exact: true,
})
.click();
@@ -312,12 +318,14 @@ test.describe('Doc Export', () => {
await page
.getByRole('button', {
name: 'download',
exact: true,
})
.click();
await expect(
page.getByRole('button', {
name: 'Download',
exact: true,
}),
).toBeVisible();
@@ -328,6 +336,7 @@ test.describe('Doc Export', () => {
void page
.getByRole('button', {
name: 'Download',
exact: true,
})
.click();
@@ -338,4 +347,137 @@ test.describe('Doc Export', () => {
const pdfData = await pdf(pdfBuffer);
expect(pdfData.text).toContain('Hello World');
});
test('it exports the doc with multi columns', async ({
page,
browserName,
}) => {
const [randomDoc] = await createDoc(
page,
'doc-multi-columns',
browserName,
1,
);
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Three Columns', { exact: true }).click();
await page.locator('.bn-block-column').first().fill('Column 1');
await page.locator('.bn-block-column').nth(1).fill('Column 2');
await page.locator('.bn-block-column').last().fill('Column 3');
expect(await page.locator('.bn-block-column').count()).toBe(3);
await expect(
page.locator('.bn-block-column[data-node-type="column"]').first(),
).toHaveText('Column 1');
await expect(
page.locator('.bn-block-column[data-node-type="column"]').nth(1),
).toHaveText('Column 2');
await expect(
page.locator('.bn-block-column[data-node-type="column"]').last(),
).toHaveText('Column 3');
await page
.getByRole('button', {
name: 'download',
exact: true,
})
.click();
await expect(
page.getByRole('button', {
name: 'Download',
exact: true,
}),
).toBeVisible();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
});
void page
.getByRole('button', {
name: 'Download',
exact: true,
})
.click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfData = await pdf(pdfBuffer);
expect(pdfData.text).toContain('Column 1');
expect(pdfData.text).toContain('Column 2');
expect(pdfData.text).toContain('Column 3');
});
test('it exports the doc with interlinking', async ({
page,
browserName,
}) => {
const [randomDoc] = await createDoc(
page,
'export-interlinking',
browserName,
1,
);
await verifyDocName(page, randomDoc);
const { name: docChild } = await createRootSubPage(
page,
browserName,
'export-interlink-child',
);
await verifyDocName(page, docChild);
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Link a doc').first().click();
await page
.locator(
"span[data-inline-content-type='interlinkingSearchInline'] input",
)
.fill('interlink-child');
await page
.locator('.quick-search-container')
.getByText('interlink-child')
.click();
const interlink = page.getByRole('link', {
name: 'interlink-child',
});
await expect(interlink).toBeVisible();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${docChild}.pdf`);
});
await page
.getByRole('button', {
name: 'download',
exact: true,
})
.click();
void page
.getByRole('button', {
name: 'Download',
exact: true,
})
.click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${docChild}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfData = await pdf(pdfBuffer);
expect(pdfData.text).toContain('interlink-child'); // This is the pdf text
});
});
@@ -0,0 +1,349 @@
import { expect, test } from '@playwright/test';
import { createDoc, mockedListDocs } from './utils-common';
import { createRootSubPage } from './utils-sub-pages';
test.describe('Doc grid dnd', () => {
test('it creates a doc', async ({ page, browserName }) => {
await page.goto('/');
const header = page.locator('header').first();
await createDoc(page, 'Draggable doc', browserName, 1);
await header.locator('h2').getByText('Docs').click();
await createDoc(page, 'Droppable doc', browserName, 1);
await header.locator('h2').getByText('Docs').click();
const response = await page.waitForResponse(
(response) =>
response.url().endsWith('documents/?page=1') &&
response.status() === 200,
);
const responseJson = await response.json();
const items = responseJson.results;
const docsGrid = page.getByTestId('docs-grid');
await expect(docsGrid).toBeVisible();
await expect(page.getByTestId('grid-loader')).toBeHidden();
const draggableElement = page.getByTestId(`draggable-doc-${items[1].id}`);
const dropZone = page.getByTestId(`droppable-doc-${items[0].id}`);
await expect(draggableElement).toBeVisible();
await expect(dropZone).toBeVisible();
// Obtenir les positions des éléments
const draggableBoundingBox = await draggableElement.boundingBox();
const dropZoneBoundingBox = await dropZone.boundingBox();
expect(draggableBoundingBox).toBeDefined();
expect(dropZoneBoundingBox).toBeDefined();
// eslint-disable-next-line playwright/no-conditional-in-test
if (!draggableBoundingBox || !dropZoneBoundingBox) {
throw new Error('Impossible de déterminer la position des éléments');
}
await page.mouse.move(
draggableBoundingBox.x + draggableBoundingBox.width / 2,
draggableBoundingBox.y + draggableBoundingBox.height / 2,
);
await page.mouse.down();
// Déplacer vers la zone cible
await page.mouse.move(
dropZoneBoundingBox.x + dropZoneBoundingBox.width / 2,
dropZoneBoundingBox.y + dropZoneBoundingBox.height / 2,
{ steps: 10 }, // Make the movement smoother
);
const dragOverlay = page.getByTestId('drag-doc-overlay');
await expect(dragOverlay).toBeVisible();
await expect(dragOverlay).toHaveText(items[1].title as string);
await page.mouse.up();
await expect(dragOverlay).toBeHidden();
});
test("it checks can't drop when we have not the minimum role", async ({
page,
}) => {
await mockedListDocs(page, data);
await page.goto('/');
const docsGrid = page.getByTestId('docs-grid');
await expect(docsGrid).toBeVisible();
await expect(page.getByTestId('grid-loader')).toBeHidden();
const canDropAndDrag = page.getByTestId('droppable-doc-can-drop-and-drag');
const noDropAndNoDrag = page.getByTestId(
'droppable-doc-no-drop-and-no-drag',
);
await expect(canDropAndDrag).toBeVisible();
await expect(noDropAndNoDrag).toBeVisible();
const canDropAndDragBoundigBox = await canDropAndDrag.boundingBox();
const noDropAndNoDragBoundigBox = await noDropAndNoDrag.boundingBox();
// eslint-disable-next-line playwright/no-conditional-in-test
if (!canDropAndDragBoundigBox || !noDropAndNoDragBoundigBox) {
throw new Error('Impossible de déterminer la position des éléments');
}
await page.mouse.move(
canDropAndDragBoundigBox.x + canDropAndDragBoundigBox.width / 2,
canDropAndDragBoundigBox.y + canDropAndDragBoundigBox.height / 2,
);
await page.mouse.down();
await page.mouse.move(
noDropAndNoDragBoundigBox.x + noDropAndNoDragBoundigBox.width / 2,
noDropAndNoDragBoundigBox.y + noDropAndNoDragBoundigBox.height / 2,
{ steps: 10 },
);
const dragOverlay = page.getByTestId('drag-doc-overlay');
await expect(dragOverlay).toBeVisible();
await expect(dragOverlay).toHaveText(
'You must be at least the editor of the target document',
);
await page.mouse.up();
});
test("it checks can't drag when we have not the minimum role", async ({
page,
}) => {
await mockedListDocs(page, data);
await page.goto('/');
const docsGrid = page.getByTestId('docs-grid');
await expect(docsGrid).toBeVisible();
await expect(page.getByTestId('grid-loader')).toBeHidden();
const canDropAndDrag = page.getByTestId('droppable-doc-can-drop-and-drag');
const noDropAndNoDrag = page.getByTestId(
'droppable-doc-no-drop-and-no-drag',
);
await expect(canDropAndDrag).toBeVisible();
await expect(noDropAndNoDrag).toBeVisible();
const canDropAndDragBoundigBox = await canDropAndDrag.boundingBox();
const noDropAndNoDragBoundigBox = await noDropAndNoDrag.boundingBox();
// eslint-disable-next-line playwright/no-conditional-in-test
if (!canDropAndDragBoundigBox || !noDropAndNoDragBoundigBox) {
throw new Error('Impossible de déterminer la position des éléments');
}
await page.mouse.move(
noDropAndNoDragBoundigBox.x + noDropAndNoDragBoundigBox.width / 2,
noDropAndNoDragBoundigBox.y + noDropAndNoDragBoundigBox.height / 2,
);
await page.mouse.down();
await page.mouse.move(
canDropAndDragBoundigBox.x + canDropAndDragBoundigBox.width / 2,
canDropAndDragBoundigBox.y + canDropAndDragBoundigBox.height / 2,
{ steps: 10 },
);
const dragOverlay = page.getByTestId('drag-doc-overlay');
await expect(dragOverlay).toBeVisible();
await expect(dragOverlay).toHaveText(
'You must be the owner to move the document',
);
await page.mouse.up();
});
});
test.describe('Doc grid dnd mobile', () => {
test.use({ viewport: { width: 500, height: 1200 } });
test('DND is deactivated on mobile', async ({ page, browserName }) => {
await page.goto('/');
const docsGrid = page.getByTestId('docs-grid');
await expect(page.getByTestId('docs-grid')).toBeVisible();
await expect(page.getByTestId('grid-loader')).toBeHidden();
await expect(docsGrid.getByRole('row').first()).toBeVisible();
await expect(docsGrid.locator('.--docs--grid-droppable')).toHaveCount(0);
await createDoc(page, 'Draggable doc mobile', browserName, 1, true);
await createRootSubPage(
page,
browserName,
'Draggable doc mobile child',
true,
);
await page
.getByRole('button', { name: 'Open the header menu' })
.getByText('menu')
.click();
await expect(page.locator('.--docs-sub-page-item').first()).toHaveAttribute(
'draggable',
'false',
);
});
});
const data = [
{
id: 'can-drop-and-drag',
abilities: {
accesses_manage: true,
accesses_view: true,
ai_transform: true,
ai_translate: true,
attachment_upload: true,
children_list: true,
children_create: true,
collaboration_auth: true,
descendants: true,
destroy: true,
favorite: true,
link_configuration: true,
invite_owner: true,
move: true,
partial_update: true,
restore: true,
retrieve: true,
media_auth: true,
link_select_options: {
restricted: ['reader', 'editor'],
authenticated: ['reader', 'editor'],
public: ['reader', 'editor'],
},
tree: true,
update: true,
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
},
created_at: '2025-03-14T14:45:22.527221Z',
creator: 'bc6895e0-8f6d-4b00-827d-c143aa6b2ecb',
depth: 1,
excerpt: null,
is_favorite: false,
link_role: 'reader',
link_reach: 'restricted',
nb_accesses_ancestors: 1,
nb_accesses_direct: 1,
numchild: 5,
path: '000000o',
title: 'Can drop and drag',
updated_at: '2025-03-14T14:45:27.699542Z',
user_roles: ['owner'],
user_role: 'owner',
},
{
id: 'can-only-drop',
title: 'Can only drop',
abilities: {
accesses_manage: true,
accesses_view: true,
ai_transform: true,
ai_translate: true,
attachment_upload: true,
children_list: true,
children_create: true,
collaboration_auth: true,
descendants: true,
destroy: true,
favorite: true,
link_configuration: true,
invite_owner: true,
move: true,
partial_update: true,
restore: true,
retrieve: true,
media_auth: true,
link_select_options: {
restricted: ['reader', 'editor'],
authenticated: ['reader', 'editor'],
public: ['reader', 'editor'],
},
tree: true,
update: true,
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
},
created_at: '2025-03-14T14:45:22.527221Z',
creator: 'bc6895e0-8f6d-4b00-827d-c143aa6b2ecb',
depth: 1,
excerpt: null,
is_favorite: false,
link_role: 'reader',
link_reach: 'restricted',
nb_accesses_ancestors: 1,
nb_accesses_direct: 1,
numchild: 5,
path: '000000o',
updated_at: '2025-03-14T14:45:27.699542Z',
user_roles: ['editor'],
user_role: 'editor',
},
{
id: 'no-drop-and-no-drag',
abilities: {
accesses_manage: false,
accesses_view: true,
ai_transform: false,
ai_translate: false,
attachment_upload: false,
children_list: true,
children_create: false,
collaboration_auth: true,
descendants: true,
destroy: false,
favorite: true,
link_configuration: false,
invite_owner: false,
move: false,
partial_update: false,
restore: false,
retrieve: true,
media_auth: true,
link_select_options: {
restricted: ['reader', 'editor'],
authenticated: ['reader', 'editor'],
public: ['reader', 'editor'],
},
tree: true,
update: false,
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
},
created_at: '2025-03-14T14:44:16.032773Z',
creator: '9264f420-f018-4bd6-96ae-4788f41af56d',
depth: 1,
excerpt: null,
is_favorite: false,
link_role: 'reader',
link_reach: 'restricted',
nb_accesses_ancestors: 14,
nb_accesses_direct: 14,
numchild: 0,
path: '000000l',
title: 'No drop and no drag',
updated_at: '2025-03-14T14:44:16.032774Z',
user_roles: ['reader'],
user_role: 'reader',
},
];
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { createDoc, getGridRow } from './common';
import { createDoc, getGridRow } from './utils-common';
type SmallDoc = {
id: string;
@@ -59,6 +59,7 @@ test.describe('Documents Grid mobile', () => {
link_reach: 'public',
created_at: '2024-10-07T13:02:41.085298Z',
updated_at: '2024-10-07T13:30:21.829690Z',
user_roles: ['owner'],
},
],
},
@@ -90,6 +91,22 @@ test.describe('Document grid item options', () => {
await page.goto('/');
});
test('it checks the share modal', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, `check share modal`, browserName);
await page.goto('/');
await expect(page.getByText(docTitle)).toBeVisible();
const row = await getGridRow(page, docTitle);
await row.getByText(`more_horiz`).click();
await page.getByRole('menuitem', { name: 'Share' }).click();
await expect(
page.getByRole('dialog').getByText('Share the document'),
).toBeVisible();
});
test('it pins a document', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, `Favorite doc`, browserName);
@@ -168,6 +185,8 @@ test.describe('Document grid item options', () => {
},
link_reach: 'restricted',
created_at: '2021-09-01T09:00:00Z',
user_roles: ['editor'],
user_role: 'editor',
},
],
},
@@ -223,6 +242,8 @@ test.describe('Documents filters', () => {
await allDocs.click();
await page.waitForURL('**/?target=all_docs');
let url = new URL(page.url());
let target = url.searchParams.get('target');
expect(target).toBe('all_docs');
@@ -4,60 +4,22 @@ import {
createDoc,
getGridRow,
goToGridDoc,
mockedAccesses,
mockedDocument,
mockedInvitations,
verifyDocName,
} from './common';
} from './utils-common';
import { mockedAccesses, mockedInvitations } from './utils-share';
import { createRootSubPage } from './utils-sub-pages';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Header', () => {
test('it checks the element are correctly displayed', async ({ page }) => {
await mockedDocument(page, {
accesses: [
{
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
role: 'owner',
user: {
email: 'super@owner.com',
full_name: 'Super Owner',
},
},
{
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
role: 'admin',
user: {
email: 'super@admin.com',
},
},
{
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
role: 'owner',
user: {
email: 'super2@owner.com',
},
},
],
abilities: {
destroy: true, // Means owner
link_configuration: true,
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
accesses_manage: true,
accesses_view: true,
update: true,
partial_update: true,
retrieve: true,
},
link_reach: 'public',
created_at: '2021-09-01T09:00:00Z',
});
await goToGridDoc(page);
test('it checks the element are correctly displayed', async ({
page,
browserName,
}) => {
await createDoc(page, 'doc-update', browserName, 1);
const card = page.getByLabel(
'It is the card information about the document.',
@@ -66,6 +28,18 @@ test.describe('Doc Header', () => {
const docTitle = card.getByRole('textbox', { name: 'doc title input' });
await expect(docTitle).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await page.getByLabel('Visibility', { exact: true }).click();
await page
.getByRole('menuitem', {
name: 'Public',
})
.click();
await page.getByRole('button', { name: 'close' }).first().click();
await expect(card.getByText('Public document')).toBeVisible();
await expect(card.getByText('Owner ·')).toBeVisible();
@@ -95,9 +69,7 @@ test.describe('Doc Header', () => {
page.getByRole('heading', { name: 'Delete a doc' }),
).toBeVisible();
await expect(
page.getByText(`Are you sure you want to delete this document ?`),
).toBeVisible();
await expect(page.getByText(`This document and any sub-`)).toBeVisible();
await page
.getByRole('button', {
@@ -158,32 +130,31 @@ test.describe('Doc Header', () => {
await expect(shareModal).toBeVisible();
await expect(page.getByText('Share the document')).toBeVisible();
await expect(page.getByPlaceholder('Type a name or email')).toBeVisible();
const invitationCard = shareModal.getByLabel('List invitation card');
await expect(invitationCard).toBeVisible();
await expect(
invitationCard.getByText('test@invitation.test').first(),
).toBeVisible();
await expect(invitationCard.getByLabel('doc-role-dropdown')).toBeVisible();
const invitationRole = invitationCard.getByLabel('doc-role-dropdown');
await expect(invitationRole).toBeVisible();
await invitationCard.getByRole('button', { name: 'more_horiz' }).click();
await invitationRole.click();
await expect(page.getByLabel('Delete')).toBeEnabled();
await invitationCard.click();
await page.getByRole('menuitem', { name: 'Remove access' }).click();
await expect(invitationCard).toBeHidden();
const memberCard = shareModal.getByLabel('List members card');
const roles = memberCard.getByLabel('doc-role-dropdown');
await expect(memberCard).toBeVisible();
await expect(
memberCard.getByText('test@accesses.test').first(),
).toBeVisible();
await expect(memberCard.getByLabel('doc-role-dropdown')).toBeVisible();
await expect(
memberCard.getByRole('button', { name: 'more_horiz' }),
).toBeVisible();
await memberCard.getByRole('button', { name: 'more_horiz' }).click();
await expect(roles).toBeVisible();
await expect(page.getByLabel('Delete')).toBeEnabled();
await roles.click();
await expect(
page.getByRole('menuitem', { name: 'Remove access' }),
).toBeEnabled();
});
test('it checks the options available if editor', async ({ page }) => {
@@ -424,7 +395,7 @@ test.describe('Doc Header', () => {
});
test('it pins a document', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, `Favorite doc`, browserName);
const [docTitle] = await createDoc(page, `Pin doc`, browserName);
await page.getByLabel('Open the document options').click();
@@ -456,6 +427,70 @@ test.describe('Doc Header', () => {
await expect(row.getByLabel('Pin document icon')).toBeHidden();
await expect(leftPanelFavorites.getByText(docTitle)).toBeHidden();
});
test('it duplicates a document', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, `Duplicate doc`, browserName);
const editor = page.locator('.ProseMirror');
await editor.click();
await editor.fill('Hello Duplicated World');
await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
await expect(
page.getByText('Document duplicated successfully!'),
).toBeVisible();
const duplicateTitle = 'Copy of ' + docTitle;
await verifyDocName(page, duplicateTitle);
await page.goto('/');
const row = await getGridRow(page, duplicateTitle);
await expect(row.getByText(duplicateTitle)).toBeVisible();
await row.getByText(`more_horiz`).click();
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
const duplicateDuplicateTitle = 'Copy of ' + duplicateTitle;
await page.getByText(duplicateDuplicateTitle).click();
await expect(page.getByText('Hello Duplicated World')).toBeVisible();
});
test('it duplicates a child document', async ({ page, browserName }) => {
await createDoc(page, `Duplicate doc`, browserName);
const { name: childTitle } = await createRootSubPage(
page,
browserName,
'Duplicate doc - child',
);
const editor = page.locator('.ProseMirror');
await editor.click();
await editor.fill('Hello Duplicated World');
const duplicateTitle = 'Copy of ' + childTitle;
const docTree = page.getByTestId('doc-tree');
const child = docTree
.getByRole('treeitem')
.locator('.--docs-sub-page-item')
.filter({
hasText: childTitle,
});
await child.hover();
await child.getByText(`more_horiz`).click();
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
await verifyDocName(page, duplicateTitle);
await expect(
page.getByTestId('doc-tree').getByText(duplicateTitle),
).toBeVisible();
});
});
test.describe('Documents Header mobile', () => {
@@ -0,0 +1,94 @@
import { expect, test } from '@playwright/test';
import { createDoc, verifyDocName } from './utils-common';
import { updateShareLink } from './utils-share';
import { createRootSubPage } from './utils-sub-pages';
test.describe('Inherited share accesses', () => {
test('it checks inherited accesses', async ({ page, browserName }) => {
await page.goto('/');
const [parentTitle] = await createDoc(page, 'root-doc', browserName, 1);
// Wait for and intercept the POST request to create a new page
await createRootSubPage(page, browserName, 'sub-page');
await page.getByRole('button', { name: 'Share' }).click();
await expect(
page.getByText('People with access via the parent document'),
).toBeVisible();
const user = page.getByTestId(
`doc-share-member-row-user@${browserName}.test`,
);
await expect(user).toBeVisible();
await expect(user.getByText('E2E Chromium')).toBeVisible();
await expect(user.getByText('Owner')).toBeVisible();
await page
.locator('.--docs--doc-inherited-share-content')
.getByRole('link')
.click();
await verifyDocName(page, parentTitle);
});
test('it checks if the link is inherited', async ({ page, browserName }) => {
await page.goto('/');
// Create root doc
await createDoc(page, 'root-doc', browserName, 1);
// Update share link
await page.getByRole('button', { name: 'Share' }).click();
await updateShareLink(page, 'Connected', 'Reading');
await page.getByRole('button', { name: 'OK' }).click();
// Create sub page
await createRootSubPage(page, browserName, 'sub-page');
// Verify share link is like the parent document
await page.getByRole('button', { name: 'Share' }).click();
const docVisibilityCard = page.getByLabel('Doc visibility card');
await expect(docVisibilityCard.getByText('Connected')).toBeVisible();
await expect(docVisibilityCard.getByText('Reading')).toBeVisible();
// Verify inherited link
await docVisibilityCard.getByText('Connected').click();
await expect(
page.getByRole('menuitem', { name: 'Private' }),
).toBeDisabled();
// Update child link
await page.getByRole('menuitem', { name: 'Public' }).click();
await docVisibilityCard.getByText('Reading').click();
await page.getByRole('menuitem', { name: 'Editing' }).click();
await expect(docVisibilityCard.getByText('Connected')).toBeHidden();
await expect(docVisibilityCard.getByText('Reading')).toBeHidden();
await expect(
docVisibilityCard.getByText('Public', {
exact: true,
}),
).toBeVisible();
await expect(docVisibilityCard.getByText('Editing')).toBeVisible();
await expect(
docVisibilityCard.getByText(
'The link sharing rules differ from the parent document',
),
).toBeVisible();
// Restore inherited link
await page.getByRole('button', { name: 'Restore' }).click();
await expect(docVisibilityCard.getByText('Connected')).toBeVisible();
await expect(docVisibilityCard.getByText('Reading')).toBeVisible();
await expect(docVisibilityCard.getByText('Public')).toBeHidden();
await expect(docVisibilityCard.getByText('Editing')).toBeHidden();
await expect(
docVisibilityCard.getByText(
'The link sharing rules differ from the parent document',
),
).toBeHidden();
});
});
@@ -6,7 +6,8 @@ import {
keyCloakSignIn,
randomName,
verifyDocName,
} from './common';
} from './utils-common';
import { createRootSubPage } from './utils-sub-pages';
test.describe('Document create member', () => {
test.beforeEach(async ({ page }) => {
@@ -272,7 +273,9 @@ test.describe('Document create member: Multiple login', () => {
await expect(
page.getByRole('link', { name: 'Docs Logo Docs' }),
).toBeVisible();
).toBeVisible({
timeout: 10000,
});
await page.goto(urlDoc);
@@ -294,4 +297,61 @@ test.describe('Document create member: Multiple login', () => {
await expect(page.getByText('Share with 2 users')).toBeVisible();
await expect(page.getByText(`E2E ${otherBrowser}`)).toBeVisible();
});
test('It cannot request member access on child doc on a 403 page', async ({
page,
browserName,
}) => {
test.slow();
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docParent] = await createDoc(
page,
'Block Member access request on child doc - parent',
browserName,
1,
);
await verifyDocName(page, docParent);
await createRootSubPage(
page,
browserName,
'Block Member access request on child doc - child',
);
const urlDoc = page.url();
await page
.getByRole('button', {
name: 'Logout',
})
.click();
const otherBrowser = BROWSERS.find((b) => b !== browserName);
await keyCloakSignIn(page, otherBrowser!);
await expect(
page.getByRole('link', { name: 'Docs Logo Docs' }),
).toBeVisible({
timeout: 10000,
});
await page.goto(urlDoc);
await expect(
page.getByText(
"You're currently viewing a sub-document. To gain access, please request permission from the main document.",
),
).toBeVisible({
timeout: 10000,
});
await expect(
page.getByRole('button', { name: 'Request access' }),
).toBeHidden();
});
});
@@ -1,6 +1,7 @@
import { expect, test } from '@playwright/test';
import { addNewMember, createDoc, goToGridDoc, verifyDocName } from './common';
import { createDoc, goToGridDoc, verifyDocName } from './utils-common';
import { addNewMember } from './utils-share';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -8,47 +9,59 @@ test.beforeEach(async ({ page }) => {
test.describe('Document list members', () => {
test('it checks a big list of members', async ({ page }) => {
await page.route(
/.*\/documents\/.*\/accesses\/\?page=.*/,
async (route) => {
const request = route.request();
const url = new URL(request.url());
const pageId = url.searchParams.get('page') ?? '1';
const accesses = {
count: 40,
next: +pageId < 2 ? 'http://anything/?page=2' : undefined,
previous: null,
results: Array.from({ length: 20 }, (_, i) => ({
id: `2ff1ec07-86c1-4534-a643-f41824a6c53a-${pageId}-${i}`,
user: {
id: `fc092149-cafa-4ffa-a29d-e4b18af751-${pageId}-${i}`,
email: `impress@impress.world-page-${pageId}-${i}`,
full_name: `Impress World Page ${pageId}-${i}`,
},
team: '',
role: 'editor',
abilities: {
destroy: false,
partial_update: true,
set_role_to: [],
},
})),
};
if (request.method().includes('GET')) {
await route.fulfill({
json: accesses,
});
} else {
await route.continue();
}
},
);
const docTitle = await goToGridDoc(page);
await verifyDocName(page, docTitle);
// Get the current URL and extract the last part
const currentUrl = page.url();
const currentDocId = (() => {
// Remove trailing slash if present
const cleanUrl = currentUrl.endsWith('/')
? currentUrl.slice(0, -1)
: currentUrl;
// Split by '/' and get the last part
return cleanUrl.split('/').pop() || '';
})();
await page.route('**/documents/**/accesses/', async (route) => {
const request = route.request();
const url = new URL(request.url());
const pageId = url.searchParams.get('page') ?? '1';
const accesses = Array.from({ length: 20 }, (_, i) => ({
id: `2ff1ec07-86c1-4534-a643-f41824a6c53a-${pageId}-${i}`,
document: {
id: currentDocId,
name: `Doc ${pageId}-${i}`,
path: `0000.${pageId}-${i}`,
},
user: {
id: `fc092149-cafa-4ffa-a29d-e4b18af751-${pageId}-${i}`,
email: `impress@impress.world-page-${pageId}-${i}`,
full_name: `Impress World Page ${pageId}-${i}`,
},
team: '',
role: 'editor',
max_ancestors_role: null,
max_role: 'editor',
abilities: {
destroy: false,
partial_update: true,
set_role_to: ['administrator', 'editor'],
},
}));
if (request.method().includes('GET')) {
await route.fulfill({
json: accesses,
});
} else {
await route.continue();
}
});
await page.getByRole('button', { name: 'Share' }).click();
const prefix = 'doc-share-member-row';
@@ -56,11 +69,6 @@ test.describe('Document list members', () => {
const loadMore = page.getByTestId('load-more-members');
await expect(elements).toHaveCount(20);
await expect(page.getByText(`Impress World Page 1-16`)).toBeVisible();
await loadMore.click();
await expect(elements).toHaveCount(40);
await expect(page.getByText(`Impress World Page 2-15`)).toBeVisible();
await expect(loadMore).toBeHidden();
});
@@ -141,7 +149,11 @@ test.describe('Document list members', () => {
`You are the sole owner of this group, make another member the group owner before you can change your own role or be removed from your document.`,
);
await expect(soloOwner).toBeVisible();
await list.click();
await list.click({
// eslint-disable-next-line playwright/no-force-option
force: true, // Force click to close the dropdown
});
const newUserEmail = await addNewMember(page, 0, 'Owner');
const newUser = list.getByTestId(`doc-share-member-row-${newUserEmail}`);
const newUserRoles = newUser.getByLabel('doc-role-dropdown');
@@ -150,10 +162,16 @@ test.describe('Document list members', () => {
await currentUserRole.click();
await expect(soloOwner).toBeHidden();
await list.click();
await list.click({
// eslint-disable-next-line playwright/no-force-option
force: true, // Force click to close the dropdown
});
await newUserRoles.click();
await list.click();
await list.click({
// eslint-disable-next-line playwright/no-force-option
force: true, // Force click to close the dropdown
});
await currentUserRole.click();
await page.getByLabel('Administrator').click();
@@ -177,17 +195,14 @@ test.describe('Document list members', () => {
const emailMyself = `user@${browserName}.test`;
const mySelf = list.getByTestId(`doc-share-member-row-${emailMyself}`);
const mySelfMoreActions = mySelf.getByRole('button', {
name: 'more_horiz',
const mySelfRole = mySelf.getByRole('button', {
name: 'doc-role-dropdown',
});
const userOwnerEmail = await addNewMember(page, 0, 'Owner');
const userOwner = list.getByTestId(
`doc-share-member-row-${userOwnerEmail}`,
);
const userOwnerMoreActions = userOwner.getByRole('button', {
name: 'more_horiz',
});
await page.getByRole('button', { name: 'close' }).first().click();
await page.getByRole('button', { name: 'Share' }).first().click();
@@ -197,24 +212,20 @@ test.describe('Document list members', () => {
const userReader = list.getByTestId(
`doc-share-member-row-${userReaderEmail}`,
);
const userReaderMoreActions = userReader.getByRole('button', {
name: 'more_horiz',
const userReaderRole = userReader.getByRole('button', {
name: 'doc-role-dropdown',
});
await expect(mySelf).toBeVisible();
await expect(userOwner).toBeVisible();
await expect(userReader).toBeVisible();
await expect(userOwnerMoreActions).toBeVisible();
await expect(userReaderMoreActions).toBeVisible();
await expect(mySelfMoreActions).toBeVisible();
await userReaderMoreActions.click();
await page.getByLabel('Delete').click();
await userReaderRole.click();
await page.getByRole('menuitem', { name: 'Remove access' }).click();
await expect(userReader).toBeHidden();
await mySelfMoreActions.click();
await page.getByLabel('Delete').click();
await mySelfRole.click();
await page.getByRole('menuitem', { name: 'Remove access' }).click();
await expect(
page.getByText('Insufficient access rights to view the document.'),
).toBeVisible();
@@ -8,25 +8,19 @@ import {
keyCloakSignIn,
mockedDocument,
verifyDocName,
} from './common';
} from './utils-common';
import { createRootSubPage } from './utils-sub-pages';
test.describe('Doc Routing', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('Check the presence of the meta tag noindex', async ({ page }) => {
const buttonCreateHomepage = page.getByRole('button', {
name: 'New doc',
});
await expect(buttonCreateHomepage).toBeVisible();
await buttonCreateHomepage.click();
await expect(
page.getByRole('button', {
name: 'Share',
}),
).toBeVisible();
test('Check the presence of the meta tag noindex', async ({
page,
browserName,
}) => {
await createDoc(page, 'doc-routing-test', browserName, 1);
const metaDescription = page.locator('meta[name="robots"]');
await expect(metaDescription).toHaveAttribute('content', 'noindex');
});
@@ -60,16 +54,20 @@ test.describe('Doc Routing', () => {
});
test('checks 401 on docs/[id] page', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, 'My new doc', browserName, 1);
const [docTitle] = await createDoc(page, '401-doc-parent', browserName, 1);
await verifyDocName(page, docTitle);
await createRootSubPage(page, browserName, '401-doc-child');
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
const responsePromise = page.route(
/.*\/link-configuration\/$|users\/me\/$/,
/.*\/documents\/.*\/$|users\/me\/$/,
async (route) => {
const request = route.request();
if (
request.method().includes('PUT') ||
request.method().includes('PATCH') ||
request.method().includes('GET')
) {
await route.fulfill({
@@ -84,11 +82,7 @@ test.describe('Doc Routing', () => {
},
);
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByLabel('Visibility', { exact: true });
await selectVisibility.click();
await page.getByLabel('Connected').click();
await page.getByRole('link', { name: '401-doc-parent' }).click();
await responsePromise;
@@ -1,6 +1,7 @@
import { expect, test } from '@playwright/test';
import { createDoc, verifyDocName } from './common';
import { createDoc, verifyDocName } from './utils-common';
import { createRootSubPage } from './utils-sub-pages';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -25,7 +26,10 @@ test.describe('Document search', () => {
);
await verifyDocName(page, doc2Title);
await page.goto('/');
await page.getByRole('button', { name: 'search' }).click();
await page
.getByTestId('left-panel-desktop')
.getByRole('button', { name: 'search' })
.click();
await expect(
page.getByRole('img', { name: 'No active search' }),
@@ -94,4 +98,118 @@ test.describe('Document search', () => {
page.getByLabel('Search modal').getByText('search'),
).toBeHidden();
});
test('it check the presence of filters in search modal', async ({
page,
browserName,
}) => {
// Doc grid filters are not visible
const searchButton = page
.getByTestId('left-panel-desktop')
.getByRole('button', { name: 'search', exact: true });
const filters = page.getByTestId('doc-search-filters');
await searchButton.click();
await expect(
page.getByRole('combobox', { name: 'Quick search input' }),
).toBeVisible();
await expect(filters).toBeHidden();
await page.getByRole('button', { name: 'close' }).click();
// Create a doc without children for the moment
// and check that filters are not visible
const [doc1Title] = await createDoc(page, 'My page search', browserName, 1);
await verifyDocName(page, doc1Title);
await searchButton.click();
await expect(
page.getByRole('combobox', { name: 'Quick search input' }),
).toBeVisible();
await expect(filters).toBeHidden();
await page.getByRole('button', { name: 'close' }).click();
// Create a sub page
// and check that filters are visible
await createRootSubPage(page, browserName, 'My sub page search');
await searchButton.click();
await expect(filters).toBeVisible();
await filters.click();
await filters.getByRole('button', { name: 'Current doc' }).click();
await expect(
page.getByRole('menuitem', { name: 'All docs' }),
).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Current doc' }),
).toBeVisible();
await page.getByRole('menuitem', { name: 'All docs' }).click();
await expect(page.getByRole('button', { name: 'Reset' })).toBeVisible();
});
test('it searches sub pages', async ({ page, browserName }) => {
// First doc
const [firstDocTitle] = await createDoc(
page,
'My first sub page search',
browserName,
1,
);
await verifyDocName(page, firstDocTitle);
// Create a new doc - for the moment without children
const [secondDocTitle] = await createDoc(
page,
'My second sub page search',
browserName,
1,
);
const searchButton = page
.getByTestId('left-panel-desktop')
.getByRole('button', { name: 'search' });
await searchButton.click();
await page.getByRole('combobox', { name: 'Quick search input' }).click();
await page
.getByRole('combobox', { name: 'Quick search input' })
.fill('sub page search');
// Expect to find the first doc
await expect(
page.getByRole('presentation').getByLabel(firstDocTitle),
).toBeVisible();
await expect(
page.getByRole('presentation').getByLabel(secondDocTitle),
).toBeVisible();
await page.getByRole('button', { name: 'close' }).click();
// Create a sub page
const { name: secondChildDocTitle } = await createRootSubPage(
page,
browserName,
'second - Child doc',
);
await searchButton.click();
await page
.getByRole('combobox', { name: 'Quick search input' })
.fill('second');
// Now there is a sub page - expect to have the focus on the current doc
await expect(
page.getByRole('presentation').getByLabel(secondDocTitle),
).toBeVisible();
await expect(
page.getByRole('presentation').getByLabel(secondChildDocTitle),
).toBeVisible();
await expect(
page.getByRole('presentation').getByLabel(firstDocTitle),
).toBeHidden();
});
});
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { createDoc, verifyDocName } from './common';
import { createDoc, verifyDocName } from './utils-common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -0,0 +1,312 @@
/* eslint-disable playwright/no-conditional-in-test */
import { expect, test } from '@playwright/test';
import {
createDoc,
expectLoginPage,
keyCloakSignIn,
randomName,
updateDocTitle,
verifyDocName,
} from './utils-common';
import { addNewMember } from './utils-share';
import { clickOnAddRootSubPage, createRootSubPage } from './utils-sub-pages';
test.describe('Doc Tree', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('create new sub pages', async ({ page, browserName }) => {
const [titleParent] = await createDoc(
page,
'doc-tree-content',
browserName,
1,
);
await verifyDocName(page, titleParent);
const addButton = page.getByRole('button', { name: 'New doc' });
const docTree = page.getByTestId('doc-tree');
await expect(addButton).toBeVisible();
// Wait for and intercept the POST request to create a new page
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes('/documents/') &&
response.url().includes('/children/') &&
response.request().method() === 'POST',
);
await clickOnAddRootSubPage(page);
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
const subPageJson = await response.json();
await expect(docTree).toBeVisible();
const subPageItem = docTree
.getByTestId(`doc-sub-page-item-${subPageJson.id}`)
.first();
await expect(subPageItem).toBeVisible();
await subPageItem.click();
await verifyDocName(page, '');
const input = page.getByRole('textbox', { name: 'doc title input' });
await input.click();
const [randomDocName] = randomName('doc-tree-test', browserName, 1);
await input.fill(randomDocName);
await input.press('Enter');
await expect(subPageItem.getByText(randomDocName)).toBeVisible();
await page.reload();
await expect(subPageItem.getByText(randomDocName)).toBeVisible();
});
test('check the reorder of sub pages', async ({ page, browserName }) => {
await createDoc(page, 'doc-tree-content', browserName, 1);
const addButton = page.getByRole('button', { name: 'New doc' });
await expect(addButton).toBeVisible();
const docTree = page.getByTestId('doc-tree');
// Create first sub page
const firstResponsePromise = page.waitForResponse(
(response) =>
response.url().includes('/documents/') &&
response.url().includes('/children/') &&
response.request().method() === 'POST',
);
await clickOnAddRootSubPage(page);
const firstResponse = await firstResponsePromise;
expect(firstResponse.ok()).toBeTruthy();
await updateDocTitle(page, 'first');
const secondResponsePromise = page.waitForResponse(
(response) =>
response.url().includes('/documents/') &&
response.url().includes('/children/') &&
response.request().method() === 'POST',
);
// Create second sub page
await clickOnAddRootSubPage(page);
const secondResponse = await secondResponsePromise;
expect(secondResponse.ok()).toBeTruthy();
await updateDocTitle(page, 'second');
const secondSubPageJson = await secondResponse.json();
const firstSubPageJson = await firstResponse.json();
const firstSubPageItem = docTree
.getByTestId(`doc-sub-page-item-${firstSubPageJson.id}`)
.first();
const secondSubPageItem = docTree
.getByTestId(`doc-sub-page-item-${secondSubPageJson.id}`)
.first();
// check that the sub pages are visible in the tree
await expect(firstSubPageItem).toBeVisible();
await expect(secondSubPageItem).toBeVisible();
// get the bounding boxes of the sub pages
const firstSubPageBoundingBox = await firstSubPageItem.boundingBox();
const secondSubPageBoundingBox = await secondSubPageItem.boundingBox();
expect(firstSubPageBoundingBox).toBeDefined();
expect(secondSubPageBoundingBox).toBeDefined();
if (!firstSubPageBoundingBox || !secondSubPageBoundingBox) {
throw new Error('Impossible de déterminer la position des éléments');
}
// move the first sub page to the second position
await page.mouse.move(
firstSubPageBoundingBox.x + firstSubPageBoundingBox.width / 2,
firstSubPageBoundingBox.y + firstSubPageBoundingBox.height / 2,
);
await page.mouse.down();
await page.mouse.move(
secondSubPageBoundingBox.x + secondSubPageBoundingBox.width / 2,
secondSubPageBoundingBox.y + secondSubPageBoundingBox.height + 2,
{ steps: 20 },
);
await page.mouse.up();
// check that the sub pages are visible in the tree
await expect(firstSubPageItem).toBeVisible();
await expect(secondSubPageItem).toBeVisible();
// reload the page
await page.reload();
// check that the sub pages are visible in the tree
await expect(firstSubPageItem).toBeVisible();
await expect(secondSubPageItem).toBeVisible();
// Check the position of the sub pages
const allSubPageItems = await docTree
.getByTestId(/^doc-sub-page-item/)
.all();
expect(allSubPageItems.length).toBe(2);
// Check that the first element has the ID of the second sub page after the drag and drop
await expect(allSubPageItems[0]).toHaveAttribute(
'data-testid',
`doc-sub-page-item-${secondSubPageJson.id}`,
);
// Check that the second element has the ID of the first sub page after the drag and drop
await expect(allSubPageItems[1]).toHaveAttribute(
'data-testid',
`doc-sub-page-item-${firstSubPageJson.id}`,
);
});
test('it detaches a document', async ({ page, browserName }) => {
const [docParent] = await createDoc(
page,
'doc-tree-detach',
browserName,
1,
);
await verifyDocName(page, docParent);
const { name: docChild } = await createRootSubPage(
page,
browserName,
'doc-tree-detach-child',
);
const docTree = page.getByTestId('doc-tree');
await expect(docTree.getByText(docChild)).toBeVisible();
await docTree.click();
const child = docTree
.getByRole('treeitem')
.locator('.--docs-sub-page-item')
.filter({
hasText: docChild,
});
await child.hover();
const menu = child.getByText(`more_horiz`);
await menu.click();
await page.getByText('Move to my docs').click();
await expect(
page.getByRole('textbox', { name: 'doc title input' }),
).not.toHaveText(docChild);
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
await expect(page.getByText(docChild)).toBeVisible();
});
test('Only owner can detaches a document', async ({ page, browserName }) => {
const [docParent] = await createDoc(
page,
'doc-tree-detach',
browserName,
1,
);
await verifyDocName(page, docParent);
await page.getByRole('button', { name: 'Share' }).click();
await addNewMember(page, 0, 'Owner', 'impress');
const list = page.getByTestId('doc-share-quick-search');
const currentUser = list.getByTestId(
`doc-share-member-row-user@${browserName}.test`,
);
const currentUserRole = currentUser.getByLabel('doc-role-dropdown');
await currentUserRole.click();
await page.getByLabel('Administrator').click();
await list.click();
await page.getByRole('button', { name: 'Ok' }).click();
const { name: docChild } = await createRootSubPage(
page,
browserName,
'doc-tree-detach-child',
);
const docTree = page.getByTestId('doc-tree');
await expect(docTree.getByText(docChild)).toBeVisible();
await docTree.click();
const child = docTree
.getByRole('treeitem')
.locator('.--docs-sub-page-item')
.filter({
hasText: docChild,
});
await child.hover();
const menu = child.getByText(`more_horiz`);
await menu.click();
await expect(
page.getByRole('menuitem', { name: 'Move to my docs' }),
).toHaveAttribute('aria-disabled', 'true');
});
});
test.describe('Doc Tree: Inheritance', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('A child inherit from the parent', async ({ page, browserName }) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docParent] = await createDoc(
page,
'doc-tree-inheritance-parent',
browserName,
1,
);
await verifyDocName(page, docParent);
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByLabel('Visibility', { exact: true });
await selectVisibility.click();
await page
.getByRole('menuitem', {
name: 'Public',
})
.click();
await expect(
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.getByRole('button', { name: 'close' }).click();
const { name: docChild } = await createRootSubPage(
page,
browserName,
'doc-tree-inheritance-child',
);
const urlDoc = page.url();
await page
.getByRole('button', {
name: 'Logout',
})
.click();
await expectLoginPage(page);
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docChild)).toBeVisible();
const docTree = page.getByTestId('doc-tree');
await expect(docTree.getByText(docParent)).toBeVisible();
});
});
@@ -5,7 +5,7 @@ import {
goToGridDoc,
mockedDocument,
verifyDocName,
} from './common';
} from './utils-common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -6,7 +6,8 @@ import {
expectLoginPage,
keyCloakSignIn,
verifyDocName,
} from './common';
} from './utils-common';
import { createRootSubPage } from './utils-sub-pages';
test.describe('Doc Visibility', () => {
test.beforeEach(async ({ page }) => {
@@ -263,6 +264,7 @@ test.describe('Doc Visibility: Public', () => {
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await expect(page.getByRole('button', { name: 'search' })).toBeHidden();
await expect(page.getByRole('button', { name: 'New doc' })).toBeHidden();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
const card = page.getByLabel('It is the card information');
await expect(card).toBeVisible();
await expect(card.getByText('Reader')).toBeVisible();
@@ -270,7 +272,7 @@ test.describe('Doc Visibility: Public', () => {
await page.getByRole('button', { name: 'Share' }).click();
await expect(
page.getByText(
'You do not have permission to view users sharing this document or modify link settings.',
'You can view this document but need additional access to see its members or modify settings.',
),
).toBeVisible();
@@ -397,6 +399,8 @@ test.describe('Doc Visibility: Authenticated', () => {
page,
browserName,
}) => {
test.slow();
await page.goto('/');
await keyCloakSignIn(page, browserName);
@@ -434,6 +438,14 @@ test.describe('Doc Visibility: Authenticated', () => {
const urlDoc = page.url();
const { name: childTitle } = await createRootSubPage(
page,
browserName,
'Authenticated read only - child',
);
const urlChildDoc = page.url();
await page
.getByRole('button', {
name: 'Logout',
@@ -445,7 +457,9 @@ test.describe('Doc Visibility: Authenticated', () => {
await expect(
page.getByRole('link', { name: 'Docs Logo Docs' }),
).toBeVisible();
).toBeVisible({
timeout: 10000,
});
await page.goto(urlDoc);
@@ -456,7 +470,7 @@ test.describe('Doc Visibility: Authenticated', () => {
await expect(
page.getByText(
'You do not have permission to view users sharing this document or modify link settings.',
'You can view this document but need additional access to see its members or modify settings.',
),
).toBeVisible();
@@ -465,6 +479,22 @@ test.describe('Doc Visibility: Authenticated', () => {
await expect(
page.getByRole('button', { name: 'Request access' }),
).toBeDisabled();
await page.goto(urlChildDoc);
await expect(page.locator('h2').getByText(childTitle)).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await expect(
page.getByText(
'As this is a sub-document, please request access to the parent document to enable these features.',
),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Request access' }),
).toBeHidden();
});
test('It checks a authenticated doc in editable mode', async ({
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { overrideConfig } from './common';
import { overrideConfig } from './utils-common';
test.describe('Footer', () => {
test.use({ storageState: { cookies: [], origins: [] } });
@@ -1,6 +1,10 @@
import { expect, test } from '@playwright/test';
import { expectLoginPage, keyCloakSignIn, overrideConfig } from './common';
import {
expectLoginPage,
keyCloakSignIn,
overrideConfig,
} from './utils-common';
test.describe('Header', () => {
test('checks all the elements are visible', async ({ page }) => {
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { overrideConfig } from './common';
import { overrideConfig } from './utils-common';
test.beforeEach(async ({ page }) => {
await page.goto('/docs/');
@@ -1,6 +1,6 @@
import { Page, expect, test } from '@playwright/test';
import { createDoc } from './common';
import { createDoc } from './utils-common';
test.describe.serial('Language', () => {
let page: Page;
@@ -6,7 +6,7 @@ export const CONFIG = {
AI_FEATURE_ENABLED: true,
CRISP_WEBSITE_ID: null,
COLLABORATION_WS_URL: 'ws://localhost:4444/collaboration/ws/',
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY: false,
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY: true,
ENVIRONMENT: 'development',
FRONTEND_CSS_URL: null,
FRONTEND_HOMEPAGE_FEATURE_ENABLED: true,
@@ -78,12 +78,17 @@ export const createDoc = async (
docName: string,
browserName: string,
length: number = 1,
isMobile: boolean = false,
) => {
const randomDocs = randomName(docName, browserName, length);
for (let i = 0; i < randomDocs.length; i++) {
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
if (isMobile) {
await page
.getByRole('button', { name: 'Open the header menu' })
.getByText('menu')
.click();
}
await page
.getByRole('button', {
@@ -124,42 +129,6 @@ export const verifyDocName = async (page: Page, docName: string) => {
}
};
export const addNewMember = async (
page: Page,
index: number,
role: 'Administrator' | 'Owner' | 'Editor' | 'Reader',
fillText: string = 'user ',
) => {
const responsePromiseSearchUser = page.waitForResponse(
(response) =>
response.url().includes(`/users/?q=${encodeURIComponent(fillText)}`) &&
response.status() === 200,
);
const inputSearch = page.getByRole('combobox', {
name: 'Quick search input',
});
// Select a new user
await inputSearch.fill(fillText);
// Intercept response
const responseSearchUser = await responsePromiseSearchUser;
const users = (await responseSearchUser.json()) as {
email: string;
}[];
// Choose user
await page.getByRole('option', { name: users[index].email }).click();
// Choose a role
await page.getByLabel('doc-role-dropdown').click();
await page.getByLabel(role).click();
await page.getByRole('button', { name: 'Invite' }).click();
return users[index].email;
};
export const getGridRow = async (page: Page, title: string) => {
const docsGrid = page.getByRole('grid');
await expect(docsGrid).toBeVisible();
@@ -210,7 +179,27 @@ export const goToGridDoc = async (
return docTitle as string;
};
export const mockedDocument = async (page: Page, json: object) => {
export const updateDocTitle = async (page: Page, title: string) => {
const input = page.getByLabel('doc title input');
await expect(input).toBeVisible();
await expect(input).toHaveText('');
await input.click();
await input.fill(title);
await input.click();
await input.blur();
await verifyDocName(page, title);
};
export const waitForResponseCreateDoc = (page: Page) => {
return page.waitForResponse(
(response) =>
response.url().includes('/documents/') &&
response.url().includes('/children/') &&
response.request().method() === 'POST',
);
};
export const mockedDocument = async (page: Page, data: object) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (
@@ -220,12 +209,15 @@ export const mockedDocument = async (page: Page, json: object) => {
!request.url().includes('accesses') &&
!request.url().includes('invitations')
) {
const { abilities, ...doc } = data as unknown as {
abilities?: Record<string, unknown>;
};
await route.fulfill({
json: {
id: 'mocked-document-id',
content: '',
title: 'Mocked document',
accesses: [],
path: '000000',
abilities: {
destroy: false, // Means not owner
link_configuration: false,
@@ -236,10 +228,21 @@ export const mockedDocument = async (page: Page, json: object) => {
update: false,
partial_update: false, // Means not editor
retrieve: true,
link_select_options: {
public: ['reader', 'editor'],
authenticated: ['reader', 'editor'],
restricted: null,
},
...abilities,
},
link_reach: 'restricted',
computed_link_reach: 'restricted',
computed_link_role: 'reader',
ancestors_link_reach: null,
ancestors_link_role: null,
created_at: '2021-09-01T09:00:00Z',
...json,
user_role: 'owner',
...doc,
},
});
} else {
@@ -248,81 +251,18 @@ export const mockedDocument = async (page: Page, json: object) => {
});
};
export const mockedInvitations = async (page: Page, json?: object) => {
await page.route('**/invitations/**/', async (route) => {
export const mockedListDocs = async (page: Page, data: object[] = []) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
request.url().includes('invitations') &&
request.url().includes('page=')
) {
if (request.method().includes('GET') && request.url().includes('page=')) {
await route.fulfill({
json: {
count: 1,
count: data.length,
next: null,
previous: null,
results: [
{
id: '120ec765-43af-4602-83eb-7f4e1224548a',
abilities: {
destroy: true,
update: true,
partial_update: true,
retrieve: true,
},
created_at: '2024-10-03T12:19:26.107687Z',
email: 'test@invitation.test',
document: '4888c328-8406-4412-9b0b-c0ba5b9e5fb6',
role: 'editor',
issuer: '7380f42f-02eb-4ad5-b8f0-037a0e66066d',
is_expired: false,
...json,
},
],
results: data,
},
});
} else {
await route.continue();
}
});
};
export const mockedAccesses = async (page: Page, json?: object) => {
await page.route('**/accesses/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
request.url().includes('accesses') &&
request.url().includes('page=')
) {
await route.fulfill({
json: {
count: 1,
next: null,
previous: null,
results: [
{
id: 'bc8bbbc5-a635-4f65-9817-fd1e9ec8ef87',
user: {
id: 'b4a21bb3-722e-426c-9f78-9d190eda641c',
email: 'test@accesses.test',
},
team: '',
role: 'reader',
abilities: {
destroy: true,
update: true,
partial_update: true,
retrieve: true,
set_role_to: ['administrator', 'editor'],
},
...json,
},
],
},
});
} else {
await route.continue();
}
});
};
@@ -0,0 +1,165 @@
import { Page, expect } from '@playwright/test';
export type Role = 'Administrator' | 'Owner' | 'Member' | 'Editor' | 'Reader';
export type LinkReach = 'Private' | 'Connected' | 'Public';
export type LinkRole = 'Reading' | 'Edition';
export const addNewMember = async (
page: Page,
index: number,
role: 'Administrator' | 'Owner' | 'Editor' | 'Reader',
fillText: string = 'user ',
) => {
const responsePromiseSearchUser = page.waitForResponse(
(response) =>
response.url().includes(`/users/?q=${encodeURIComponent(fillText)}`) &&
response.status() === 200,
);
const inputSearch = page.getByRole('combobox', {
name: 'Quick search input',
});
// Select a new user
await inputSearch.fill(fillText);
// Intercept response
const responseSearchUser = await responsePromiseSearchUser;
const users = (await responseSearchUser.json()) as {
email: string;
}[];
// Choose user
await page.getByRole('option', { name: users[index].email }).click();
// Choose a role
await page.getByLabel('doc-role-dropdown').click();
await page.getByLabel(role).click();
await page.getByRole('button', { name: 'Invite' }).click();
return users[index].email;
};
export const updateShareLink = async (
page: Page,
linkReach: LinkReach,
linkRole?: LinkRole | null,
) => {
await page.getByRole('button', { name: 'Visibility', exact: true }).click();
await page.getByRole('menuitem', { name: linkReach }).click();
const visibilityUpdatedText = page
.getByText('The document visibility has been updated')
.first();
await expect(visibilityUpdatedText).toBeVisible();
if (linkRole) {
await page
.getByRole('button', { name: 'Visibility mode', exact: true })
.click();
await page.getByRole('menuitem', { name: linkRole }).click();
await expect(visibilityUpdatedText).toBeVisible();
}
};
export const mockedInvitations = async (page: Page, json?: object) => {
let result = [
{
id: '120ec765-43af-4602-83eb-7f4e1224548a',
abilities: {
destroy: true,
update: true,
partial_update: true,
retrieve: true,
},
created_at: '2024-10-03T12:19:26.107687Z',
email: 'test@invitation.test',
document: '4888c328-8406-4412-9b0b-c0ba5b9e5fb6',
role: 'editor',
issuer: '7380f42f-02eb-4ad5-b8f0-037a0e66066d',
is_expired: false,
...json,
},
];
await page.route('**/invitations/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
request.url().includes('invitations') &&
request.url().includes('page=')
) {
await route.fulfill({
json: {
count: 1,
next: null,
previous: null,
results: result,
},
});
} else {
await route.continue();
}
});
await page.route(
'**/invitations/120ec765-43af-4602-83eb-7f4e1224548a/**/',
async (route) => {
const request = route.request();
if (request.method().includes('DELETE')) {
result = [];
await route.fulfill({
json: {},
});
}
},
);
};
export const mockedAccesses = async (page: Page, json?: object) => {
await page.route('**/accesses/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
request.url().includes('accesses')
) {
await route.fulfill({
json: [
{
id: 'bc8bbbc5-a635-4f65-9817-fd1e9ec8ef87',
user: {
id: 'b4a21bb3-722e-426c-9f78-9d190eda641c',
email: 'test@accesses.test',
},
team: '',
max_ancestors_role: null,
max_role: 'reader',
role: 'reader',
document: {
id: 'mocked-document-id',
path: '000000',
depth: 1,
},
abilities: {
destroy: true,
update: true,
partial_update: true,
retrieve: true,
link_select_options: {
public: ['reader', 'editor'],
authenticated: ['reader', 'editor'],
restricted: null,
},
set_role_to: ['administrator', 'editor'],
},
...json,
},
],
});
} else {
await route.continue();
}
});
};
@@ -0,0 +1,67 @@
import { Page, expect } from '@playwright/test';
import {
randomName,
updateDocTitle,
waitForResponseCreateDoc,
} from './utils-common';
export const createRootSubPage = async (
page: Page,
browserName: string,
docName: string,
isMobile: boolean = false,
) => {
if (isMobile) {
await page
.getByRole('button', { name: 'Open the header menu' })
.getByText('menu')
.click();
}
// Get response
const responsePromise = waitForResponseCreateDoc(page);
await clickOnAddRootSubPage(page);
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
const subPageJson = (await response.json()) as { id: string };
if (isMobile) {
await page
.getByRole('button', { name: 'Open the header menu' })
.getByText('menu')
.click();
}
// Get doc tree
const docTree = page.getByTestId('doc-tree');
await expect(docTree).toBeVisible();
// Get sub page item
const subPageItem = docTree
.getByTestId(`doc-sub-page-item-${subPageJson.id}`)
.first();
await expect(subPageItem).toBeVisible();
await subPageItem.click();
if (isMobile) {
await page
.getByRole('button', { name: 'Open the header menu' })
.getByText('close')
.click();
}
// Update sub page name
const randomDocs = randomName(docName, browserName, 1);
await updateDocTitle(page, randomDocs[0]);
// Return sub page data
return { name: randomDocs[0], docTreeItem: subPageItem, item: subPageJson };
};
export const clickOnAddRootSubPage = async (page: Page) => {
const rootItem = page.getByTestId('doc-tree-root-item');
await expect(rootItem).toBeVisible();
await rootItem.hover();
await rootItem.getByRole('button', { name: 'add_box' }).click();
};
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "3.3.0",
"version": "3.4.2",
"private": true,
"scripts": {
"lint": "eslint . --ext .ts",
@@ -12,7 +12,7 @@
"test:ui::chromium": "yarn test:ui --project=chromium"
},
"devDependencies": {
"@playwright/test": "1.53.1",
"@playwright/test": "1.54.1",
"@types/node": "*",
"@types/pdf-parse": "1.1.5",
"eslint-config-impress": "*",
+30 -27
View File
@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "3.3.0",
"version": "3.4.2",
"private": true,
"scripts": {
"dev": "next dev",
@@ -16,73 +16,76 @@
},
"dependencies": {
"@ag-media/react-pdf-table": "2.0.3",
"@blocknote/code-block": "0.32.0",
"@blocknote/core": "0.32.0",
"@blocknote/mantine": "0.32.0",
"@blocknote/react": "0.32.0",
"@blocknote/xl-docx-exporter": "0.32.0",
"@blocknote/xl-pdf-exporter": "0.32.0",
"@blocknote/code-block": "0.35.0",
"@blocknote/core": "0.35.0",
"@blocknote/mantine": "0.35.0",
"@blocknote/react": "0.35.0",
"@blocknote/xl-docx-exporter": "0.35.0",
"@blocknote/xl-multi-column": "0.35.0",
"@blocknote/xl-pdf-exporter": "0.35.0",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/modifiers": "9.0.0",
"@emoji-mart/data": "1.2.1",
"@emoji-mart/react": "1.1.1",
"@fontsource/material-icons": "5.2.5",
"@gouvfr-lasuite/integration": "1.0.3",
"@gouvfr-lasuite/ui-kit": "0.8.1",
"@gouvfr-lasuite/ui-kit": "0.10.0",
"@hocuspocus/provider": "2.15.2",
"@openfun/cunningham-react": "3.1.0",
"@openfun/cunningham-react": "3.2.0",
"@react-pdf/renderer": "4.3.0",
"@sentry/nextjs": "9.32.0",
"@tanstack/react-query": "5.81.2",
"@sentry/nextjs": "9.42.0",
"@tanstack/react-query": "5.83.0",
"canvg": "4.0.3",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"crisp-sdk-web": "1.0.25",
"docx": "9.5.0",
"emoji-mart": "5.6.0",
"i18next": "25.2.1",
"i18next": "25.3.2",
"i18next-browser-languagedetector": "8.2.0",
"idb": "8.0.3",
"lodash": "4.17.21",
"luxon": "3.6.1",
"next": "15.3.4",
"posthog-js": "1.255.1",
"luxon": "3.7.1",
"next": "15.4.4",
"posthog-js": "1.258.2",
"react": "*",
"react-aria-components": "1.10.1",
"react-aria-components": "1.11.0",
"react-dom": "*",
"react-i18next": "15.5.3",
"react-i18next": "15.6.1",
"react-intersection-observer": "9.16.0",
"react-select": "5.10.1",
"react-select": "5.10.2",
"styled-components": "6.1.19",
"use-debounce": "10.0.5",
"y-protocols": "1.0.6",
"yjs": "*",
"zustand": "5.0.5"
"zustand": "5.0.6"
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.81.2",
"@tanstack/react-query-devtools": "5.83.0",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.3.0",
"@testing-library/user-event": "14.6.1",
"@types/jest": "30.0.0",
"@types/lodash": "4.17.19",
"@types/lodash": "4.17.20",
"@types/luxon": "3.6.2",
"@types/node": "*",
"@types/react": "*",
"@types/react-dom": "*",
"cross-env": "7.0.3",
"dotenv": "16.5.0",
"dotenv": "17.2.1",
"eslint-config-impress": "*",
"fetch-mock": "9.11.0",
"jest": "30.0.3",
"jest-environment-jsdom": "30.0.2",
"jest": "30.0.5",
"jest-environment-jsdom": "30.0.5",
"node-fetch": "2.7.0",
"prettier": "3.6.1",
"stylelint": "16.21.0",
"prettier": "3.6.2",
"stylelint": "16.22.0",
"stylelint-config-standard": "38.0.0",
"stylelint-prettier": "5.0.3",
"typescript": "*",
"webpack": "5.99.9",
"webpack": "5.100.2",
"workbox-webpack-plugin": "7.1.0"
}
}
@@ -21,6 +21,11 @@ export type DefinedInitialDataInfiniteOptionsAPI<
TPageParam
>;
export type InfiniteQueryConfig<Q> = Omit<
DefinedInitialDataInfiniteOptionsAPI<Q>,
'queryKey' | 'initialData' | 'getNextPageParam' | 'initialPageParam'
>;
/**
* Custom React hook that wraps React Query's `useInfiniteQuery` for paginated API requests.
*
@@ -38,7 +43,7 @@ export const useAPIInfiniteQuery = <T, Q extends { next?: APIList<Q>['next'] }>(
key: string,
api: (props: T & { page: number }) => Promise<Q>,
param: T,
queryConfig?: DefinedInitialDataInfiniteOptionsAPI<Q>,
queryConfig?: InfiniteQueryConfig<Q>,
) => {
return useInfiniteQuery<Q, APIError, InfiniteData<Q>, QueryKey, number>({
initialPageParam: 1,

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