Compare commits

...

71 Commits

Author SHA1 Message Date
Manuel Raynaud eb9487d5d7 (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-01 16:29:08 +02:00
Nathan Panchout da40a84a51 wip 2025-07-01 15:49:27 +02:00
Nathan Panchout 48cacf4c99 (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-01 15:46:49 +02:00
Nathan Panchout cfa11c5b35 (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-01 15:46:49 +02:00
Nathan Panchout 2e26ad2b09 (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-01 15:46:48 +02:00
Nathan Panchout dd742d9e6d (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-01 15:26:52 +02:00
Nathan Panchout a0c56816f2 (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-01 15:24:34 +02:00
Nathan Panchout 93def378a9 (backend) update Docker Hub workflow and fix migration conflict
- Added 'feature/doc-dnd' branch to the Docker Hub workflow to support
new feature development.
- Created a new migration to add the 'has_deleted_children' field to the
document model, enhancing the management of document states.
2025-07-01 15:24:34 +02:00
Nathan Panchout 6224e98774 (frontend) add new SVG assets and skeleton loading styles
- Introduced new SVG files for "desynchro" and "undo" icons to enhance
the user interface.
- Added a skeleton loading style in globals.css to improve the visual
experience during content loading.
2025-07-01 15:24:34 +02:00
Anthony LC c438d4f081 ✏️(frontend) child document with different wording
We want to have a different wording when the child
document has no title, so we can distinguish
between the two cases.
2025-07-01 15:24:32 +02:00
Anthony LC 6188427b58 🔥(frontend) silent next.js error
The error modal since next.js 15 are quite intrusive.
We decided to hide them.
2025-07-01 15:23:43 +02:00
Nathan Panchout 1d087726fc (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-01 15:23:41 +02:00
Nathan Panchout b852d19f43 (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-01 15:23:25 +02:00
Nathan Panchout a7a00a3087 (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-01 15:18:09 +02:00
Nathan Panchout 8ae112b43b (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-01 15:18:07 +02:00
Nathan Panchout e72317fbaa 🐛(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-01 15:17:32 +02:00
Samuel Paccoud - DINUM 507c9a583a (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-01 15:17:32 +02:00
Samuel Paccoud - DINUM d7ff740213 🐛(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-01 15:17:32 +02:00
Samuel Paccoud - DINUM 4a44b44aec ♻️(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-01 15:17:32 +02:00
Samuel Paccoud - DINUM 94c372ea6e (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-01 15:17:32 +02:00
Samuel Paccoud - DINUM 45ab75d638 🐛(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-01 15:17:32 +02:00
Samuel Paccoud - DINUM c807aded5c (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-01 15:17:32 +02:00
Samuel Paccoud - DINUM ad7a5235e1 ♻️(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-01 15:17:32 +02:00
Samuel Paccoud - DINUM 62ff585539 🐛(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-01 15:17:32 +02:00
Samuel Paccoud - DINUM 8c6bf045cc (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-01 15:17:32 +02:00
Samuel Paccoud - DINUM 8e7651d5cd 🐛(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-01 15:17:30 +02:00
Samuel Paccoud - DINUM 12a229e0ab (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-01 15:16:09 +02:00
Samuel Paccoud - DINUM a3e3ede1f8 ♻️(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-01 15:16:09 +02:00
Samuel Paccoud - DINUM 46ac2e11ed (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-01 15:16:09 +02:00
Samuel Paccoud - DINUM e0d6271677 fixup! ♻️(backend) optimize refactoring access abilities and fix inheritance 2025-07-01 15:16:09 +02:00
Samuel Paccoud - DINUM 9e9efa197a ♻️(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-01 15:16:07 +02:00
Samuel Paccoud - DINUM 9b29a0b693 (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-01 15:15:17 +02:00
Samuel Paccoud - DINUM b85f1d1737 (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-01 15:15:17 +02:00
Samuel Paccoud - DINUM 4474fc8ddb ♻️(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-01 15:15:16 +02:00
Samuel Paccoud - DINUM 2f980e880a (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-01 15:15:16 +02:00
Samuel Paccoud - DINUM 6edb92ee62 ♻️(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-01 15:15:14 +02:00
Samuel Paccoud - DINUM ee8f61a858 (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-01 15:13:40 +02:00
Samuel Paccoud - DINUM 40785f381a (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-01 15:13:40 +02:00
Samuel Paccoud - DINUM 149f711f60 ♻️(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-01 15:13:40 +02:00
Samuel Paccoud - DINUM b5dad9eb1a ♻️(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-01 15:13:40 +02:00
Samuel Paccoud - DINUM 8c714bfdc6 ♻️(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-01 15:13:40 +02:00
Samuel Paccoud - DINUM 55ea7f8375 (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-01 15:13:36 +02:00
Samuel Paccoud - DINUM abddb4d585 🐛(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-01 15:11:06 +02:00
soyouzpanda 6c3850b22b (frontend) support _FILE environment variables for secrets
Allow configuration variables that handles secrets to be read from a
file given in an environment variable.
2025-07-01 10:47:55 +02:00
soyouzpanda 31e8ed3a00 (backend) support _FILE environment variables for secrets
Allow configuration variables that handles secrets, like
`DJANGO_SECRET_KEY` to be able to read from a file which is given
through an environment file.

For example, if `DJANGO_SECRET_KEY_FILE` is set to
`/var/lib/docs/django-secret-key`, the value of `DJANGO_SECRET_KEY` will
be the content of `/var/lib/docs/django-secret-key`.
2025-07-01 10:32:55 +02:00
Manuel Raynaud 7e63e9e460 ♻️(back) exclude /admin from CSP rules
We have to exclude the /admin prefix to allow loading static files when
the django admin is used.
2025-06-30 14:46:01 +02:00
Anthony LC 388f71d9d0 (frontend) button access request on share modal
When a document is in public or connected mode,
users can now request access to the document.
2025-06-30 12:13:28 +02:00
Anthony LC 2360a832af (frontend) add access request on doc share modal
Add the access request to the document
share modal, allowing admin to see and manage
access requests directly from the modal interface.
2025-06-30 12:13:28 +02:00
Anthony LC 411d52c73b ♻️(frontend) improve separation of concerns in DocShareModal
Improve separation of concerns in the DocShareModal
component.
The member and invitation list are now
in a separate component.
It will help us to integrate cleanly the
request access list.
2025-06-30 12:13:28 +02:00
Anthony LC 394f91387d (backend) send email to admins when user ask for access
When a user requests access to a document, an
email is sent to the admins and owners of the
document.
2025-06-30 12:13:27 +02:00
Anthony LC 878de08b1e (frontend) integrate doc access request
When a user is redirected on the 403 page,
they can now request access to the document.
2025-06-30 12:13:27 +02:00
Manuel Raynaud d33286019c (back) accept for a owner the request to access a document
Add the action accepting a request to access a document. It is possible
to override the role from the request and also update an existing
DocumentAccess
2025-06-30 12:13:26 +02:00
Manuel Raynaud c2e46fa9e2 (back) document as for access CRUD
We introduce a new model for user wanted to access a document or upgrade
their role if they already have access.
The viewsets does not implement PUT and PATCH, we don't need it for now.
2025-06-30 12:13:26 +02:00
Manuel Raynaud 2e1b112133 🚨(back) remove unused ruff ignore rule
A ruff ignore rule was present in the factories module. But this rule is
not used in the file so we can safely remove it.
2025-06-30 10:43:58 +02:00
renovate[bot] 8f7ac12ea1 ⬆️(dependencies) update python dependencies 2025-06-30 10:43:58 +02:00
Manuel Raynaud dfdfe83db5 (back) install and configure django csp (#1085)
We want to protect all requests from django with content security
policy header. We use the djang-csp library and configure it with
default values.

Fixes #1000
2025-06-30 08:42:48 +00:00
Anthony LC 4ae757ce93 🔥(frontend) remove Beta from logo
Docs got homologated, so we can remove the beta
logo from the DSFR theme.
2025-06-27 18:22:59 +02:00
Manuel Raynaud 6964686f7c 🔧(back) remove usage of deprecated db engine
The db engine postgresql_psycopg2 does not exists anymore in django but
for BC compat it is possible to use it in the configuration and it is
replace by postgresql at runtime. We changed this settings to use the
good one.
2025-06-27 16:03:09 +00:00
Manuel Raynaud 45bbffdf9f (back) allow to disable checking unsafe mimetype on attachment upload
We added the possibility to scan all uploaded files with an anti malware
solution. Depending the backend used, we want to give the possibility to
check the file mimtype to determine if this one is tagged as unsafe or
not. To this you can set the environment variable
DOCUMENT_ATTACHMENT_CHECK_UNSAFE_MIME_TYPES_ENABLED to False. The
default value is True.
2025-06-27 15:31:15 +00:00
Anthony LC 95a55e7805 (e2e) reduce flakiness in e2e tests
Flakiness in e2e tests has been reduced by:
- Adding waits for media-check processing in image tests.
- Ensuring that slash menu resets are handled
correctly to avoid flakiness.
- Wait for the Download button to be stable before clicking
2025-06-27 16:50:10 +02:00
Sylvain Zimmer e9ac36e811 📝(readme) fix some small issues in the README
Fix some typo and small issues in the README file.
2025-06-27 15:10:14 +02:00
Stephan Meijer d8294ee11d fix: Makefile failing on run-frontend-development (#1104)
See #1103

Signed-off-by: Stephan Meijer <me@stephanmeijer.com>
2025-06-27 12:35:55 +00:00
Anthony LC 00009ecc16 🔧(conf) add server to server api tokens to common
We have the e2e test "it creates a doc server way"
that is quite complicated to run locally, because
it requires the `DJANGO_SERVER_TO_SERVER_API_TOKENS`
environment variable to be set in "env.d/development/common".
We moved `DJANGO_SERVER_TO_SERVER_API_TOKENS` from
"env.d/development/common.e2e.dist" to
"env.d/development/common.dist", by doing so,
this variable will be set by default in the
"env.d/development/common" file, the test will now run
without any additional configuration.
2025-06-26 17:09:08 +02:00
Anthony LC 9b0676ec15 (jest) fix window.location mock
We upgraded to jest 30.0.3.
This upgrade updated jsdom and jsdom now do not
allows to mock window.location.
See: https://github.com/jsdom/jsdom/issues/3492
This commit fixes this issue.
2025-06-26 16:49:13 +02:00
Anthony LC 9f222bbaa3 ⬇️(dependencies) downgrade to docx 9.5.0
Prob compatibility issue with docx 9.5.1 and
BlockNote. We downgrade to 9.5.0 for now until
BlockNote is updated to support docx 9.5.1.
2025-06-26 16:47:51 +02:00
renovate[bot] f0b253f0ff ⬆️(dependencies) update js dependencies 2025-06-26 12:14:34 +00:00
Timothee Gosselin 1e76e6e04c Documentation for self-hosting with docker compose (#855)
## Purpose

Make self hosting of Docs easier with an example of a deployment
procedure with docker compose and document how to configure Docs.

While https://github.com/suitenumerique/docs/pull/583 propose an easy
way to deploy Docs with docker and Make, here we describe more in
details the various steps and requirements to deploy Docs.

## Proposal

- [x] example to deploy and configure keycloak
- [x] example to deploy and configure minio
- [x] example to configure proxy and certs
- [x] example to deploy and configure Docs

## Improvements
- [x] Rephrase description of environment variables and categorize
- [x] Use template for nginx conf  

Fixes https://github.com/suitenumerique/docs/issues/561
Supersedes https://github.com/suitenumerique/docs/pull/583

 A one liner quick start could be a nice addition:
- [ ] merge all services in a single compose
- [ ] scripts to generate secrets

Signed-off-by: unteem <timothee@indie.host>
2025-06-25 13:02:08 +00:00
Anthony LC a71453206b 🐛(env) update yprovider env for local development
In local development the notification to
the yprovider server was not working anymore
because of a recent change in the container name.
We adapt the env variables to match the new
container name.
2025-06-24 16:08:23 +02:00
lebaudantoine 71cd016d4d ️(frontend) optimize document fetch error handling
Reduce unnecessary fetch requests when retrieving documents with permission
or authentication issues. Previous implementation was triggering multiple
document requests despite having sufficient error information from initial
attempt to determine appropriate user redirection.

Additionally, fix issue where resetting the auth cache was triggering redundant
authentication verification requests. The responsibility for checking auth
status should belong to the 401 page component on mount, rather than being
triggered by cache resets during error handling.

Known limitations:
- Not waiting for async  function completion makes code harder to
 maintain
- Added loading spinner as temporary solution to prevent UI flicker
- Future improvement should implement consistent error-based redirects rather
 than rendering error messages directly on document page
2025-06-24 15:50:02 +02:00
lebaudantoine 2a7ffff96d ️(frontend) prevent authentication retry on 401 responses
Stop retry attempts when receiving 401 Unauthorized from /me endpoint since
this clearly indicates authentication status. The original purpose of the /me
call is simply to determine if user is authenticated, and a 401 provides
sufficient information.

Prevents unnecessary network requests caused by React Query's automatic retry
behavior when re-raising exceptions, which was hiding the 401 status. Improves
performance and reduces server load during authentication failures.
2025-06-24 15:46:48 +02:00
Erik Duxstad ff8275fb4e 📝(self-hosted) update collaboration vars (#1075)
Remove the `auth-url` annotation and add the
`COLLABORATION_BACKEND_BASE_URL` variable, introduced in 3.0.0.

Mount the development CA to the yProvider container to allow
TLS connections with the backend.

Fix the mount path for development CA in the backend container.

Signed-off-by: eduxstad <eduxstad@gmail.com>
2025-06-24 06:21:29 +00:00
188 changed files with 12499 additions and 4581 deletions
+1
View File
@@ -6,6 +6,7 @@ on:
push:
branches:
- 'main'
- 'feature/doc-dnd'
tags:
- 'v*'
pull_request:
+28 -5
View File
@@ -14,6 +14,9 @@ and this project adheres to
- 📝(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
### Changed
@@ -21,14 +24,22 @@ and this project adheres to
- 🧑‍💻(docker) add .next to .dockerignore #1055
- 🧑‍💻(docker) handle frontend development images with docker compose #1033
- 🧑‍💻(docker) add y-provider config to development environment #1057
- ⚡️(frontend) optimize document fetch error handling #1089
### Fixed
-🐛(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
- 🐛(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
### Removed
- 🔥(frontend) remove Beta from logo #1095
## [3.3.0] - 2025-05-06
@@ -74,6 +85,9 @@ and this project adheres to
## Added
- ✨(frontend) multi-pages #701
- ✨(backend) include ancestors accesses on document accesses list view #846
- ✨(backend) add ancestors links reach and role to document API #846
- 🚸(backend) make document search on title accent-insensitive #874
- 🚩 add homepage feature flag #861
- 📝(doc) update contributing policy (commit signatures are now mandatory) #895
@@ -85,22 +99,30 @@ and this project adheres to
## Changed
- ♻️(backend) stop requiring owner for non-root documents #846
- ♻️(backend) simplify roles by ranking them and return only the max role #846
- ⚡️(frontend) reduce unblocking time for config #867
- ♻️(frontend) bind UI with ability access #900
- ♻️(frontend) use built-in Quote block #908
## Fixed
- 🐛(backend) fix link definition select options linked to ancestors #846
- 🐛(nginx) fix 404 when accessing a doc #866
- 🔒️(drf) disable browsable HTML API renderer #919
- 🔒(frontend) enhance file download security #889
- 🐛(backend) race condition create doc #633
- 🐛(frontend) fix breaklines in custom blocks #908
## Fixed
- 🐛(backend) fix link definition select options linked to ancestors #846
## [3.1.0] - 2025-04-07
## Added
- ✨(backend) add ancestors links definitions to document abilities #846
- 🚩(backend) add feature flag for the footer #841
- 🔧(backend) add view to manage footer json #841
- ✨(frontend) add custom css style #771
@@ -112,6 +134,7 @@ and this project adheres to
## Fixed
- 🐛(backend) fix link definition select options linked to ancestors #846
- 🐛(back) validate document content in serializer #822
- 🐛(frontend) fix selection click past end of content #840
+1 -1
View File
@@ -348,7 +348,7 @@ frontend-lint: ## run the frontend linter
.PHONY: frontend-lint
run-frontend-development: ## Run the frontend in development mode
@$(COMPOSE) stop frontend frontend-development
@$(COMPOSE) stop frontend-development
cd $(PATH_FRONT_IMPRESS) && yarn dev
.PHONY: run-frontend-development
+4 -4
View File
@@ -11,7 +11,7 @@
<img alt="GitHub commit activity" src="https://img.shields.io/github/commit-activity/m/suitenumerique/docs"/>
<img alt="GitHub closed issues" src="https://img.shields.io/github/issues-closed/suitenumerique/docs"/>
<a href="https://github.com/suitenumerique/docs/blob/main/LICENSE">
<img alt="GitHub closed issues" src="https://img.shields.io/github/license/suitenumerique/docs"/>
<img alt="MIT License" src="https://img.shields.io/github/license/suitenumerique/docs"/>
</a>
</p>
<p align="center">
@@ -162,15 +162,15 @@ $ make superuser
We'd love to hear your thoughts, and hear about your experiments, so come and say hi on [Matrix](https://matrix.to/#/#docs-official:matrix.org).
## Roadmap
## Roadmap 💡
Want to know where the project is headed? [🗺️ Checkout our roadmap](https://github.com/orgs/numerique-gouv/projects/13/views/11)
## Licence 📝
## License 📝
This work is released under the MIT License (see [LICENSE](https://github.com/suitenumerique/docs/blob/main/LICENSE)).
While Docs is a public-driven initiative, our licence choice is an invitation for private sector actors to use, sell and contribute to the project.
While Docs is a public-driven initiative, our license choice is an invitation for private sector actors to use, sell and contribute to the project.
## Contributing 🙌
@@ -0,0 +1,112 @@
upstream docs_backend {
server ${BACKEND_HOST}:8000 fail_timeout=0;
}
upstream docs_frontend {
server ${FRONTEND_HOST}:3000 fail_timeout=0;
}
server {
listen 8083;
server_name localhost;
charset utf-8;
# Disables server version feedback on pages and in headers
server_tokens off;
proxy_ssl_server_name on;
location @proxy_to_docs_backend {
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://docs_backend;
}
location @proxy_to_docs_frontend {
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://docs_frontend;
}
location / {
try_files $uri @proxy_to_docs_frontend;
}
location /api {
try_files $uri @proxy_to_docs_backend;
}
location /admin {
try_files $uri @proxy_to_docs_backend;
}
location /static {
try_files $uri @proxy_to_docs_backend;
}
# Proxy auth for collaboration server
location /collaboration/ws/ {
# Ensure WebSocket upgrade
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
# Collaboration server
proxy_pass http://${YPROVIDER_HOST}:4444;
# Set appropriate timeout for WebSocket
proxy_read_timeout 86400;
proxy_send_timeout 86400;
# Preserve original host and additional headers
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Origin $http_origin;
proxy_set_header Host $host;
}
location /collaboration/api/ {
# Collaboration server
proxy_pass http://${YPROVIDER_HOST}:4444;
proxy_set_header Host $host;
}
# Proxy auth for media
location /media/ {
# Auth request configuration
auth_request /media-auth;
auth_request_set $authHeader $upstream_http_authorization;
auth_request_set $authDate $upstream_http_x_amz_date;
auth_request_set $authContentSha256 $upstream_http_x_amz_content_sha256;
# Pass specific headers from the auth response
proxy_set_header Authorization $authHeader;
proxy_set_header X-Amz-Date $authDate;
proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
# Get resource from Minio
proxy_pass https://${S3_HOST}/${BUCKET_NAME}/;
proxy_set_header Host ${S3_HOST};
proxy_ssl_name ${S3_HOST};
add_header Content-Security-Policy "default-src 'none'" always;
}
location /media-auth {
proxy_pass http://docs_backend/api/v1.0/documents/media-auth/;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Original-URL $request_uri;
# Prevent the body from being passed
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-Method $request_method;
}
}
+98 -97
View File
@@ -6,103 +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 |
| 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 | | |
| 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 | |
| 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 | |
## impress-frontend image
+78
View File
@@ -0,0 +1,78 @@
services:
postgresql:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
interval: 1s
timeout: 2s
retries: 300
env_file:
- env.d/postgresql
- env.d/common
environment:
- PGDATA=/var/lib/postgresql/data/pgdata
volumes:
- ./data/databases/backend:/var/lib/postgresql/data/pgdata
redis:
image: redis:8
backend:
image: lasuite/impress-backend:latest
user: ${DOCKER_USER:-1000}
restart: always
environment:
- DJANGO_CONFIGURATION=Production
env_file:
- env.d/common
- env.d/backend
- env.d/yprovider
- env.d/postgresql
healthcheck:
test: ["CMD", "python", "manage.py", "check"]
interval: 15s
timeout: 30s
retries: 20
start_period: 10s
depends_on:
postgresql:
condition: service_healthy
restart: true
redis:
condition: service_started
y-provider:
image: lasuite/impress-y-provider:latest
user: ${DOCKER_USER:-1000}
env_file:
- env.d/common
- env.d/yprovider
frontend:
image: lasuite/impress-frontend:latest
user: "101"
entrypoint:
- /docker-entrypoint.sh
command: ["nginx", "-g", "daemon off;"]
env_file:
- env.d/common
# Uncomment and set your values if using our nginx proxy example
#environment:
# - VIRTUAL_HOST=${DOCS_HOST} # used by nginx proxy
# - VIRTUAL_PORT=8083 # used by nginx proxy
# - LETSENCRYPT_HOST=${DOCS_HOST} # used by lets encrypt to generate TLS certificate
volumes:
- ./default.conf.template:/etc/nginx/templates/docs.conf.template
depends_on:
backend:
condition: service_healthy
# Uncomment if using our nginx proxy example
# networks:
# - proxy-tier
# - default
# Uncomment if using our nginx proxy example
#networks:
# proxy-tier:
# external: true
+88
View File
@@ -0,0 +1,88 @@
# Deploy and Configure Keycloak for Docs
## Installation
> \[!CAUTION\]
> We provide those instructions as an example, for production environments, you should follow the [official documentation](https://www.keycloak.org/documentation).
### Step 1: Prepare your working environment:
```bash
mkdir keycloak
curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/docs/examples/compose/keycloak/compose.yaml
curl -o env.d/kc_postgresql https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/kc_postgresql
curl -o env.d/keycloak https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/keycloak
```
### Step 2:. Update `env.d/` files
The following variables need to be updated with your own values, others can be left as is:
```env
POSTGRES_PASSWORD=<generate postgres password>
KC_HOSTNAME=https://id.yourdomain.tld # Change with your own URL
KC_BOOTSTRAP_ADMIN_PASSWORD=<generate your password>
```
### Step 3: Expose keycloak instance on https
> \[!NOTE\]
> You can skip this section if you already have your own setup.
To access your Keycloak instance on the public network, it needs to be exposed on a domain with SSL termination. You can use our [example with nginx proxy and Let's Encrypt companion](../nginx-proxy/README.md) for automated creation/renewal of certificates using [acme.sh](http://acme.sh).
If following our example, uncomment the environment and network sections in compose file and update it with your values.
```yaml
version: '3'
services:
keycloak:
...
# Uncomment and set your values if using our nginx proxy example
# environment:
# - VIRTUAL_HOST=id.yourdomain.tld # used by nginx proxy
# - VIRTUAL_PORT=8080 # used by nginx proxy
# - LETSENCRYPT_HOST=id.yourdomain.tld # used by lets encrypt to generate TLS certificate
...
# Uncomment if using our nginx proxy example
# networks:
# - proxy-tier
# - default
# Uncomment if using our nginx proxy example
#networks:
# proxy-tier:
# external: true
```
### Step 4: Start the service
```bash
`docker compose up -d`
```
Your keycloak instance is now available on https://doc.yourdomain.tld
## Creating an OIDC Client for Docs Application
### Step 1: Create a New Realm
1. Log in to the Keycloak administration console.
2. Navigate to the realm tab and click on the "Create realm" button.
3. Enter the name of the realm - `docs`.
4. Click "Create".
#### Step 2: Create a New Client
1. Navigate to the "Clients" tab.
2. Click on the "Create client" button.
3. Enter the client ID - e.g. `docs`.
4. Enable "Client authentication" option.
6. Set the "Valid redirect URIs" to the URL of your docs application suffixed with `/*` - e.g., "https://docs.example.com/*".
1. Set the "Web Origins" to the URL of your docs application - e.g. `https://docs.example.com`.
1. Click "Save".
#### Step 3: Get Client Credentials
1. Go to the "Credentials" tab.
2. Copy the client ID (`docs` in this example) and the client secret.
@@ -0,0 +1,36 @@
services:
kc_postgresql:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
interval: 1s
timeout: 2s
retries: 300
env_file:
- env.d/kc_postgresql
volumes:
- ./data/keycloak:/var/lib/postgresql/data/pgdata
keycloak:
image: quay.io/keycloak/keycloak:26.1.3
command: ["start"]
env_file:
- env.d/kc_postgresql
- env.d/keycloak
# Uncomment and set your values if using our nginx proxy example
# environment:
# - VIRTUAL_HOST=id.yourdomain.tld # used by nginx proxy
# - VIRTUAL_PORT=8080 # used by nginx proxy
# - LETSENCRYPT_HOST=id.yourdomain.tld # used by lets encrypt to generate TLS certificate
depends_on:
kc_postgresql::
condition: service_healthy
restart: true
# Uncomment if using our nginx proxy example
# networks:
# - proxy-tier
# - default
#
#networks:
# proxy-tier:
# external: true
+103
View File
@@ -0,0 +1,103 @@
# Deploy and Configure Minio for Docs
## Installation
> \[!CAUTION\]
> We provide those instructions as an example, it should not be run in production. For production environments, deploy MinIO [in a Multi-Node Multi-Drive (Distributed)](https://min.io/docs/minio/linux/operations/install-deploy-manage/deploy-minio-multi-node-multi-drive.html#minio-mnmd) topology
### Step 1: Prepare your working environment:
```bash
mkdir minio
curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/docs/examples/compose/minio/compose.yaml
```
### Step 2:. Update compose file with your own values
```yaml
version: '3'
services:
minio:
...
environment:
- MINIO_ROOT_USER=<Set minio root username>
- MINIO_ROOT_PASSWORD=<Set minio root password>
```
### Step 3: Expose MinIO instance
#### Option 1: Internal network
You may not need to expose your MinIO instance to the public if only services hosted on the same private network need to access to your MinIO instance.
You should create a docker network that will be shared between those services
```bash
docker network create storage-tier
```
#### Option 2: Public network
If you want to expose your MinIO instance to the public, it needs to be exposed on a domain with SSL termination. You can use our [example](../nginx-proxy/README.md) with an nginx proxy and Let's Encrypt companion for automated creation/renewal of Let's Encrypt certificates using [acme.sh](http://acme.sh).
If following our example, uncomment the environment and network sections in compose file and update it with your values.
```yaml
version: '3'
services:
docs:
...
minio:
...
environment:
...
# - VIRTUAL_HOST=storage.yourdomain.tld # used by nginx proxy
# - VIRTUAL_PORT=9000 # used by nginx proxy
# - LETSENCRYPT_HOST=storage.yourdomain.tld # used by lets encrypt to generate TLS certificate
...
# Uncomment if using our nginx proxy example
# networks:
# - proxy-tier
# - default
# Uncomment if using our nginx proxy example
#networks:
# proxy-tier:
# external: true
```
In this example we are only exposing MinIO API service. Follow the official documentation to configure Minio WebUI.
### Step 4: Start the service
```bash
`docker compose up -d`
```
Your minio instance is now available on https://storage.yourdomain.tld
## Creating a user and bucket for your Docs instance
### Installing mc
Follow the [official documentation](https://min.io/docs/minio/linux/reference/minio-mc.html#install-mc) to install mc
### Step 1: Configure `mc` to connect to your MinIO Server with your root user
```shellscript
mc alias set minio <MINIO_SERVER_URL> <MINIO_ROOT_USER> <MINIO_ROOT_PASSWORD>
```
Replace the values with those you have set in the previous steps
### Step 2: Create a new bucket with versioning enabled
```shellscript
mc mb --with-versioning minio/<your-bucket-name>
```
Replace `your-bucket-name` with the desired name for your bucket e.g. `docs-media-storage`
### Additional notes:
For increased security you should create a dedicated user with `readwrite` access to the Bucket. In the following example we will use MinIO root user.
+27
View File
@@ -0,0 +1,27 @@
services:
minio:
image: minio/minio
environment:
- MINIO_ROOT_USER=<set minio root username>
- MINIO_ROOT_PASSWORD=<set minio root password>
# Uncomment and set your values if using our nginx proxy example
# - VIRTUAL_HOST=storage.yourdomain.tld # used by nginx proxy
# - VIRTUAL_PORT=9000 # used by nginx proxy
# - LETSENCRYPT_HOST=storage.yourdomain.tld # used by lets encrypt to generate TLS certificate
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 1s
timeout: 20s
retries: 300
entrypoint: ""
command: minio server /data
volumes:
- ./data/minio:/data
# Uncomment if using our nginx proxy example
# networks:
# - proxy-tier
# Uncomment if using our nginx proxy example
#networks:
# proxy-tier:
# external: true
@@ -0,0 +1,39 @@
# Nginx proxy with automatic SSL certificates
> \[!CAUTION\]
> We provide those instructions as an example, for extended development or production environments, you should follow the [official documentation](https://github.com/nginx-proxy/acme-companion/tree/main/docs).
Nginx-proxy sets up a container running nginx and docker-gen. docker-gen generates reverse proxy configs for nginx and reloads nginx when containers are started and stopped.
Acme-companion is a lightweight companion container for nginx-proxy. It handles the automated creation, renewal and use of SSL certificates for proxied Docker containers through the ACME protocol.
## Installation
### Step 1: Prepare your working environment:
```bash
mkdir nginx-proxy
curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/docs/examples/compose/nginx-proxy/compose.yaml
```
### Step 2: Edit `DEFAULT_EMAIL` in the compose file.
Albeit optional, it is recommended to provide a valid default email address through the `DEFAULT_EMAIL` environment variable, so that Let's Encrypt can warn you about expiring certificates and allow you to recover your account.
### Step 3: Create docker network
Containers need share the same network for auto-discovery.
```bash
docker network create proxy-tier
```
### Step 4: Start service
```bash
docker compose up -d
```
## Usage
Once both nginx-proxy and acme-companion containers are up and running, start any container you want proxied with environment variables `VIRTUAL_HOST` and `LETSENCRYPT_HOST` both set to the domain(s) your proxied container is going to use.
@@ -0,0 +1,36 @@
services:
nginx-proxy:
image: nginxproxy/nginx-proxy
container_name: nginx-proxy
ports:
- "80:80"
- "443:443"
volumes:
- html:/usr/share/nginx/html
- certs:/etc/nginx/certs:ro
- /var/run/docker.sock:/tmp/docker.sock:ro
networks:
- proxy-tier
acme-companion:
image: nginxproxy/acme-companion
container_name: nginx-proxy-acme
environment:
- DEFAULT_EMAIL=mail@yourdomain.tld
volumes_from:
- nginx-proxy
volumes:
- certs:/etc/nginx/certs:rw
- acme:/etc/acme.sh
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- proxy-tier
networks:
proxy-tier:
external: true
volumes:
html:
certs:
acme:
@@ -85,7 +85,7 @@ backend:
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
extraVolumeMounts:
- name: certs
mountPath: /usr/local/lib/python3.12/site-packages/certifi/cacert.pem
mountPath: /usr/local/lib/python3.13/site-packages/certifi/cacert.pem
subPath: cacert.pem
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
@@ -121,6 +121,22 @@ yProvider:
COLLABORATION_SERVER_ORIGIN: https://impress.127.0.0.1.nip.io
COLLABORATION_SERVER_SECRET: my-secret
Y_PROVIDER_API_KEY: my-secret
COLLABORATION_BACKEND_BASE_URL: https://impress.127.0.0.1.nip.io
NODE_EXTRA_CA_CERTS: /usr/local/share/ca-certificates/cacert.pem
# Mount the certificate so yProvider can establish tls with the backend
extraVolumeMounts:
- name: certs
mountPath: /usr/local/share/ca-certificates/cacert.pem
subPath: cacert.pem
extraVolumes:
- name: certs
configMap:
name: certifi
items:
- key: cacert.pem
path: cacert.pem
posthog:
ingress:
@@ -135,9 +151,6 @@ ingress:
ingressCollaborationWS:
enabled: true
host: impress.127.0.0.1.nip.io
annotations:
nginx.ingress.kubernetes.io/auth-url: https://impress.127.0.0.1.nip.io/api/v1.0/documents/collaboration-auth/
ingressCollaborationApi:
enabled: true
+226
View File
@@ -0,0 +1,226 @@
# 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)
## Requirements
- A modern version of Docker and its Compose plugin.
- A domain name and DNS configured to your server.
- An Identity Provider that supports OpenID Connect protocol - we provide [an example to deploy Keycloak](../examples/compose/keycloak/README.md).
- An Object Storage that implements S3 API - we provide [an example to deploy Minio](../examples/compose/minio/README.md).
- A Postgresql database - we provide [an example in the compose file](../examples/compose/compose.yaml).
- A Redis database - we provide [an example in the compose file](../examples/compose/compose.yaml).
## Software Requirements
Ensure you have Docker Compose(v2) installed on your host server. Follow the official guidelines for a reliable setup:
Docker Compose is included with Docker Engine:
- **Docker Engine:** We suggest adhering to the instructions provided by Docker
for [installing Docker Engine](https://docs.docker.com/engine/install/).
For older versions of Docker Engine that do not include Docker Compose:
- **Docker Compose:** Install it as per the [official documentation](https://docs.docker.com/compose/install/).
> [!NOTE]
> `docker-compose` may not be supported. You are advised to use `docker compose` instead.
## Step 1: Prepare your working environment:
```bash
mkdir -p docs/env.d
curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/docs/examples/compose/compose.yaml
curl -o env.d/common https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/common
curl -o env.d/backend https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/backend
curl -o env.d/yprovider https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/yprovider
curl -o env.d/common https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/postgresql
```
## Step 2: Configuration
Docs configuration is achieved through environment variables. We provide a [detailed description of all variables](../env.md).
In this example, we assume the following services:
- OIDC provider on https://id.yourdomain.tld
- Object Storage on https://storage.yourdomain.tld
- Docs on https://docs.yourdomain.tld
- Bucket name is docs-media-storage
**Set your own values in `env.d/common`**
### OIDC
Authentication in Docs is managed through Open ID Connect protocol. A functional Identity Provider implementing this protocol is required.
For guidance, refer to our [Keycloak deployment example](../examples/compose/keycloak/README.md).
If using Keycloak as your Identity Provider, set `OIDC_RP_CLIENT_ID` and `OIDC_RP_CLIENT_SECRET` variables with those of the OIDC client created for Docs. By default we have set `docs` as the realm name, if you have named your realm differently, update the value `REALM_NAME` in `env.d/common`
For others OIDC providers, update the variables in `env.d/backend`.
### Object Storage
Files and media are stored in an Object Store that supports the S3 API.
For guidance, refer to our [Minio deployment example](../examples/compose/minio/README.md).
Set `AWS_S3_ACCESS_KEY_ID` and `AWS_S3_SECRET_ACCESS_KEY` with the credentials of a user with `readwrite` access to the bucket created for Docs.
### Postgresql
Docs uses PostgreSQL as its database. Although an external PostgreSQL can be used, our example provides a deployment method.
If you are using the example provided, you need to generate a secure key for `DB_PASSWORD` and set it in `env.d/postgresql`.
If you are using an external service or not using our default values, you should update the variables in `env.d/postgresql`
### Redis
Docs uses Redis for caching. While an external Redis can be used, our example provides a deployment method.
If you are using an external service, you need to set `REDIS_URL` environment variable in `env.d/backend`.
### Y Provider
The Y provider service enables collaboration through websockets.
Generates a secure key for `Y_PROVIDER_API_KEY` and `COLLABORATION_SERVER_SECRET` in ``env.d/yprovider``.
### Docs
The Docs backend is built on the Django Framework.
Generates a secure key for `DJANGO_SECRET_KEY` in `env.d/backend`.
### Logging
Update the following variables in `env.d/backend` if you want to change the logging levels:
```env
LOGGING_LEVEL_HANDLERS_CONSOLE=DEBUG
LOGGING_LEVEL_LOGGERS_ROOT=DEBUG
LOGGING_LEVEL_LOGGERS_APP=DEBUG
```
### Mail
The following environment variables are required in `env.d/backend` for the mail service to send invitations :
```env
DJANGO_EMAIL_HOST=<smtp host>
DJANGO_EMAIL_HOST_USER=<smtp user>
DJANGO_EMAIL_HOST_PASSWORD=<smtp password>
DJANGO_EMAIL_PORT=<smtp port>
DJANGO_EMAIL_FROM=<your email address>
#DJANGO_EMAIL_USE_TLS=true # A flag to enable or disable TLS for email sending.
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
DJANGO_EMAIL_BRAND_NAME=<brand name used in email templates> # e.g. "La Suite Numérique"
DJANGO_EMAIL_LOGO_IMG=<logo image to use in email templates.> # e.g. "https://docs.yourdomain.tld/assets/logo-suite-numerique.png"
```
### AI
Built-in AI actions let users generate, summarize, translate, and correct content.
AI is disabled by default. To enable it, the following environment variables must be set in in `env.d/backend`:
```env
AI_FEATURE_ENABLED=true # is false by default
AI_BASE_URL=https://openaiendpoint.com
AI_API_KEY=<API key>
AI_MODEL=<model used> e.g. llama
```
### Frontend theme
You can [customize your Docs instance](../theming.md) with your own theme and custom css.
The following environment variables must be set in `env.d/backend`:
```env
FRONTEND_THEME=default # name of your theme built with cuningham
FRONTEND_CSS_URL=https://storage.yourdomain.tld/themes/custom.css # custom css
```
## Step 3: Reverse proxy and SSL/TLS
> [!WARNING]
> In a production environment, configure SSL/TLS termination to run your instance on https.
If you have your own certificates and proxy setup, you can skip this part.
You can follow our [nginx proxy example](../examples/compose/nginx-proxy/README.md) with automatic generation and renewal of certificate with Let's Encrypt.
You will need to uncomment the environment and network sections in compose file and update it with your values.
```yaml
frontend:
...
# Uncomment and set your values if using our nginx proxy example
#environment:
# - VIRTUAL_HOST=${DOCS_HOST} # used by nginx proxy
# - VIRTUAL_PORT=8083 # used by nginx proxy
# - LETSENCRYPT_HOST=${DOCS_HOST} # used by lets encrypt to generate TLS certificate
...
# Uncomment if using our nginx proxy example
# networks:
# - proxy-tier
#
#networks:
# proxy-tier:
# external: true
```
## Step 4: Start Docs
You are ready to start your Docs application !
```bash
docker compose up -d
```
> [!NOTE]
> Version of the images are set to latest, you should pin it to the desired version to avoid unwanted upgrades when pulling latest image.
## Step 5: Run the database migration and create Django admin user
```bash
docker compose run --rm backend python manage.py migrate
docker compose run --rm backend python manage.py createsuperuser --email <admin email> --password <admin password>
```
Replace `<admin email>` with the email of your admin user and generate a secure password.
Your docs instance is now available on the domain you defined, https://docs.yourdomain.tld.
THe admin interface is available on https://docs.yourdomain.tld/admin with the admin user you just created.
## How to upgrade your Docs application
Before running an upgrade you must check the [Upgrade document](../../UPGRADE.md) for specific procedures that might be needed.
You can also check the [Changelog](../../CHANGELOG.md) for brief summary of the changes.
### Step 1: Edit the images tag with the desired version
### Step 2: Pull the images
```bash
docker compose pull
```
### Step 3: Restart your containers
```bash
docker compose restart
```
### Step 4: Run the database migration
Your database schema may need to be updated, run:
```bash
docker compose run --rm backend python manage.py migrate
```
+3 -2
View File
@@ -56,11 +56,12 @@ AI_API_KEY=password
AI_MODEL=llama
# Collaboration
COLLABORATION_API_URL=http://y-provider:4444/collaboration/api/
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_URL=ws://localhost:4444/collaboration/ws/
Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/
DJANGO_SERVER_TO_SERVER_API_TOKENS=server-api-token
Y_PROVIDER_API_BASE_URL=http://y-provider-development:4444/api/
Y_PROVIDER_API_KEY=yprovider-api-key
+6 -1
View File
@@ -1,4 +1,9 @@
# For the CI job test-e2e
BURST_THROTTLE_RATES="200/minute"
DJANGO_SERVER_TO_SERVER_API_TOKENS=test-e2e
COLLABORATION_API_URL=http://y-provider:4444/collaboration/api/
SUSTAINED_THROTTLE_RATES="200/hour"
Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/
THEME_CUSTOMIZATION_FILE_PATH="" #force theme_customization to be empty
#COLLABORATION_API_URL=http://y-provider:4444/collaboration/api/
#Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/
+65
View File
@@ -0,0 +1,65 @@
## Django
DJANGO_ALLOWED_HOSTS=${DOCS_HOST}
DJANGO_SECRET_KEY=<generate a random key>
DJANGO_SETTINGS_MODULE=impress.settings
DJANGO_CONFIGURATION=Production
# Logging
# Set to DEBUG level for dev only
LOGGING_LEVEL_HANDLERS_CONSOLE=ERROR
LOGGING_LEVEL_LOGGERS_ROOT=INFO
LOGGING_LEVEL_LOGGERS_APP=INFO
# Python
PYTHONPATH=/app
# Mail
DJANGO_EMAIL_HOST=<smtp host>
DJANGO_EMAIL_HOST_USER=<smtp user>
DJANGO_EMAIL_HOST_PASSWORD=<smtp password>
DJANGO_EMAIL_PORT=<smtp port>
DJANGO_EMAIL_FROM=<your email address>
#DJANGO_EMAIL_USE_TLS=true # A flag to enable or disable TLS for email sending.
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
DJANGO_EMAIL_BRAND_NAME="La Suite Numérique"
DJANGO_EMAIL_LOGO_IMG="https://${DOCS_HOST}/assets/logo-suite-numerique.png"
# Media
AWS_S3_ENDPOINT_URL=https://${S3_HOST}
AWS_S3_ACCESS_KEY_ID=<s3 access key>
AWS_S3_SECRET_ACCESS_KEY=<s3 secret key>
AWS_STORAGE_BUCKET_NAME=${BUCKET_NAME}
MEDIA_BASE_URL=https://${DOCS_HOST}
# OIDC
OIDC_OP_JWKS_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/certs
OIDC_OP_AUTHORIZATION_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/userinfo
OIDC_OP_LOGOUT_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/logout
OIDC_RP_CLIENT_ID=<client_id>
OIDC_RP_CLIENT_SECRET=<client secret>
OIDC_RP_SIGN_ALGO=RS256
OIDC_RP_SCOPES="openid email"
#USER_OIDC_FIELD_TO_SHORTNAME
#USER_OIDC_FIELDS_TO_FULLNAME
LOGIN_REDIRECT_URL=https://${DOCS_HOST}
LOGIN_REDIRECT_URL_FAILURE=https://${DOCS_HOST}
LOGOUT_REDIRECT_URL=https://${DOCS_HOST}
OIDC_REDIRECT_ALLOWED_HOSTS=["https://${DOCS_HOST}"]
# AI
#AI_FEATURE_ENABLED=true # is false by default
#AI_BASE_URL=https://openaiendpoint.com
#AI_API_KEY=<API key>
#AI_MODEL=<model used> e.g. llama
# Frontend
#FRONTEND_THEME=mytheme
#FRONTEND_CSS_URL=https://storage.yourdomain.tld/themes/custom.css
#FRONTEND_FOOTER_FEATURE_ENABLED=true
#FRONTEND_URL_JSON_FOOTER=https://docs.domain.tld/contents/footer-demo.json
+9
View File
@@ -0,0 +1,9 @@
DOCS_HOST=docs.domain.tld
KEYCLOAK_HOST=id.domain.tld
S3_HOST=storage.domain.tld
BACKEND_HOST=backend
FRONTEND_HOST=frontend
YPROVIDER_HOST=y-provider
BUCKET_NAME=docs-media-storage
REALM_NAME=docs
#COLLABORATION_WS_URL=wss://${DOCS_HOST}/collaboration/ws/
+13
View File
@@ -0,0 +1,13 @@
# Postgresql db container configuration
POSTGRES_DB=keycloak
POSTGRES_USER=keycloak
POSTGRES_PASSWORD=<generate postgres password>
PGDATA=/var/lib/postgresql/data/pgdata
# Keycloak postgresql configuration
KC_DB=postgres
KC_DB_SCHEMA=public
KC_DB_HOST=postgresql
KC_DB_NAME=${POSTGRES_DB}
KC_DB_USER=${POSTGRES_USER}
KC_DB_PASSWORD=${POSTGRES_PASSWORD}
+8
View File
@@ -0,0 +1,8 @@
# Keycloak admin user
KC_BOOTSTRAP_ADMIN_USERNAME=admin
KC_BOOTSTRAP_ADMIN_PASSWORD=<generate your password>
# Keycloak configuration
KC_HOSTNAME=https://id.yourdomain.tld # Change with your own URL
KC_PROXY_HEADERS=xforwarded # in this example we are running behind an nginx proxy
KC_HTTP_ENABLED=true # in this example we are running behind an nginx proxy
+11
View File
@@ -0,0 +1,11 @@
# App database configuration
DB_HOST=postgresql
DB_NAME=docs
DB_USER=docs
DB_PASSWORD=<generate a secure password>
DB_PORT=5432
# Postgresql db container configuration
POSTGRES_DB=docs
POSTGRES_USER=docs
POSTGRES_PASSWORD=${DB_PASSWORD}
+7
View File
@@ -0,0 +1,7 @@
Y_PROVIDER_API_BASE_URL=http://${YPROVIDER_HOST}:4444/api
Y_PROVIDER_API_KEY=<generate a random key>
COLLABORATION_SERVER_SECRET=<generate a random key>
COLLABORATION_SERVER_ORIGIN=https://${DOCS_HOST}
COLLABORATION_API_URL=https://${DOCS_HOST}/collaboration/api/
COLLABORATION_BACKEND_BASE_URL=https://${DOCS_HOST}
COLLABORATION_LOGGING=true
+1
View File
@@ -22,6 +22,7 @@
"matchPackageNames": [
"@hocuspocus/provider",
"@hocuspocus/server",
"docx",
"eslint",
"fetch-mock",
"node",
+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)
+211 -145
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,34 +114,45 @@ 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):
@@ -245,6 +165,10 @@ class DocumentSerializer(ListDocumentSerializer):
fields = [
"id",
"abilities",
"ancestors_link_reach",
"ancestors_link_role",
"computed_link_reach",
"computed_link_role",
"content",
"created_at",
"creator",
@@ -259,11 +183,15 @@ class DocumentSerializer(ListDocumentSerializer):
"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",
@@ -275,7 +203,7 @@ class DocumentSerializer(ListDocumentSerializer):
"numchild",
"path",
"updated_at",
"user_roles",
"user_role",
]
def get_fields(self):
@@ -361,6 +289,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.
@@ -517,16 +538,17 @@ class FileUploadSerializer(serializers.Serializer):
mime = magic.Magic(mime=True)
magic_mime_type = mime.from_buffer(file.read(1024))
file.seek(0) # Reset file pointer to the beginning after reading
self.context["is_unsafe"] = False
if settings.DOCUMENT_ATTACHMENT_CHECK_UNSAFE_MIME_TYPES_ENABLED:
self.context["is_unsafe"] = (
magic_mime_type in settings.DOCUMENT_UNSAFE_MIME_TYPES
)
self.context["is_unsafe"] = (
magic_mime_type in settings.DOCUMENT_UNSAFE_MIME_TYPES
)
extension_mime_type, _ = mimetypes.guess_type(file.name)
extension_mime_type, _ = mimetypes.guess_type(file.name)
# Try guessing a coherent extension from the mimetype
if extension_mime_type != magic_mime_type:
self.context["is_unsafe"] = True
# Try guessing a coherent extension from the mimetype
if extension_mime_type != magic_mime_type:
self.context["is_unsafe"] = True
guessed_ext = mimetypes.guess_extension(magic_mime_type)
# Missing extensions or extensions longer than 5 characters (it's as long as an extension
@@ -664,6 +686,50 @@ class InvitationSerializer(serializers.ModelSerializer):
return role
class RoleSerializer(serializers.Serializer):
"""Serializer validating role choices."""
role = serializers.ChoiceField(
choices=models.RoleChoices.choices, required=False, allow_null=True
)
class DocumentAskForAccessCreateSerializer(serializers.Serializer):
"""Serializer for creating a document ask for access."""
role = serializers.ChoiceField(
choices=models.RoleChoices.choices,
required=False,
default=models.RoleChoices.READER,
)
class DocumentAskForAccessSerializer(serializers.ModelSerializer):
"""Serializer for document ask for access model"""
abilities = serializers.SerializerMethodField(read_only=True)
user = UserSerializer(read_only=True)
class Meta:
model = models.DocumentAskForAccess
fields = [
"id",
"document",
"user",
"role",
"created_at",
"abilities",
]
read_only_fields = ["id", "document", "user", "role", "created_at", "abilities"]
def get_abilities(self, invitation) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
return invitation.get_abilities(request.user)
return {}
class VersionFilterSerializer(serializers.Serializer):
"""Validate version filters applied to the list endpoint."""
+342 -224
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,21 +19,25 @@ 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 _
import requests
import rest_framework as drf
from botocore.exceptions import ClientError
from csp.constants import NONE
from csp.decorators import csp_update
from lasuite.malware_detection import malware_detection
from rest_framework import filters, status, viewsets
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
from core.utils import extract_attachments, filter_descendants
from . import permissions, serializers, utils
@@ -220,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."""
@@ -235,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"""
@@ -431,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
@@ -442,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
@@ -515,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):
@@ -536,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
@@ -555,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.
@@ -565,14 +453,8 @@ 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 = queryset.annotate_is_favorite(user)
queryset = filterset.filters["is_favorite"].filter(
queryset, filter_data["is_favorite"]
)
@@ -663,7 +545,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)
@@ -731,7 +613,7 @@ class DocumentViewSet(
position = validated_data["position"]
message = None
owner_accesses = []
if position in [
enums.MoveNodePositionChoices.FIRST_CHILD,
enums.MoveNodePositionChoices.LAST_CHILD,
@@ -741,12 +623,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(
@@ -756,6 +641,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
)
@@ -802,11 +700,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
@@ -825,7 +719,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,
@@ -857,10 +761,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))
@@ -882,13 +788,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(
@@ -896,25 +795,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,
@@ -931,7 +826,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
@@ -1412,6 +1310,7 @@ class DocumentViewSet(
name="",
url_path="cors-proxy",
)
@csp_update({"img-src": [NONE, "data:"]})
def cors_proxy(self, request, *args, **kwargs):
"""
GET /api/v1.0/documents/<resource_id>/cors-proxy
@@ -1452,7 +1351,6 @@ class DocumentViewSet(
content_type=content_type,
headers={
"Content-Disposition": "attachment;",
"Content-Security-Policy": "default-src 'none'; img-src 'none' data:;",
},
status=response.status_code,
)
@@ -1469,7 +1367,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.
@@ -1496,50 +1398,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."""
@@ -1576,7 +1571,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"]
@@ -1638,7 +1633,6 @@ class TemplateAccessViewSet(
ResourceAccessViewsetMixin,
drf.mixins.CreateModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.ListModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.UpdateModelMixin,
viewsets.GenericViewSet,
@@ -1668,12 +1662,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,
@@ -1706,7 +1743,7 @@ class InvitationViewset(
pagination_class = Pagination
permission_classes = [
permissions.CanCreateInvitationPermission,
permissions.AccessPermission,
permissions.ResourceWithAccessPermission,
]
queryset = (
models.Invitation.objects.all()
@@ -1746,11 +1783,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
@@ -1772,6 +1809,87 @@ class InvitationViewset(
)
class DocumentAskForAccessViewSet(
drf.mixins.ListModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
"""API ViewSet for asking for access to a document."""
lookup_field = "id"
pagination_class = Pagination
permission_classes = [
permissions.IsAuthenticated,
permissions.ResourceWithAccessPermission,
]
queryset = models.DocumentAskForAccess.objects.all()
serializer_class = serializers.DocumentAskForAccessSerializer
_document = None
def get_document_or_404(self):
"""Get the document related to the viewset or raise a 404 error."""
if self._document is None:
try:
self._document = models.Document.objects.get(
pk=self.kwargs["resource_id"]
)
except models.Document.DoesNotExist as e:
raise drf.exceptions.NotFound("Document not found.") from e
return self._document
def get_queryset(self):
"""Return the queryset according to the action."""
document = self.get_document_or_404()
queryset = super().get_queryset()
queryset = queryset.filter(document=document)
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)
return queryset
def create(self, request, *args, **kwargs):
"""Create a document ask for access resource."""
document = self.get_document_or_404()
serializer = serializers.DocumentAskForAccessCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
queryset = self.get_queryset()
if queryset.filter(user=request.user).exists():
return drf.response.Response(
{"detail": "You already ask to access to this document."},
status=drf.status.HTTP_400_BAD_REQUEST,
)
ask_for_access = models.DocumentAskForAccess.objects.create(
document=document,
user=request.user,
role=serializer.validated_data["role"],
)
send_ask_for_access_mail.delay(ask_for_access.id)
return drf.response.Response(status=drf.status.HTTP_201_CREATED)
@drf.decorators.action(detail=True, methods=["post"])
def accept(self, request, *args, **kwargs):
"""Accept a document ask for access resource."""
document_ask_for_access = self.get_object()
serializer = serializers.RoleSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
document_ask_for_access.accept(role=serializer.validated_data.get("role"))
return drf.response.Response(status=drf.status.HTTP_204_NO_CONTENT)
class ConfigView(drf.views.APIView):
"""API ViewSet for sharing some public settings."""
+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}
+11 -1
View File
@@ -1,4 +1,3 @@
# ruff: noqa: S311
"""
Core application factories
"""
@@ -183,6 +182,17 @@ class TeamDocumentAccessFactory(factory.django.DjangoModelFactory):
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
class DocumentAskForAccessFactory(factory.django.DjangoModelFactory):
"""Create fake document ask for access for testing."""
class Meta:
model = models.DocumentAskForAccess
document = factory.SubFactory(DocumentFactory)
user = factory.SubFactory(UserFactory)
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
class TemplateFactory(factory.django.DjangoModelFactory):
"""A factory to create templates"""
@@ -0,0 +1,89 @@
# Generated by Django 5.2.3 on 2025-06-18 10:02
import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0021_activate_unaccent_extension"),
]
operations = [
migrations.CreateModel(
name="DocumentAskForAccess",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
help_text="primary key for the record as UUID",
primary_key=True,
serialize=False,
verbose_name="id",
),
),
(
"created_at",
models.DateTimeField(
auto_now_add=True,
help_text="date and time at which a record was created",
verbose_name="created on",
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True,
help_text="date and time at which a record was last updated",
verbose_name="updated on",
),
),
(
"role",
models.CharField(
choices=[
("reader", "Reader"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
(
"document",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="ask_for_accesses",
to="core.document",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="ask_for_accesses",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Document ask for access",
"verbose_name_plural": "Document ask for accesses",
"db_table": "impress_document_ask_for_access",
"constraints": [
models.UniqueConstraint(
fields=("user", "document"),
name="unique_document_ask_for_access_user",
violation_error_message="This user has already asked for access to this document.",
)
],
},
),
]
@@ -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),
),
]
+413 -235
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 defintion 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
@@ -851,7 +797,7 @@ class Document(MP_Node, BaseModel):
"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,
@@ -876,8 +822,8 @@ class Document(MP_Node, BaseModel):
)
with override(language):
msg_html = render_to_string("mail/html/invitation.html", context)
msg_plain = render_to_string("mail/text/invitation.txt", context)
msg_html = render_to_string("mail/html/template.html", context)
msg_plain = render_to_string("mail/text/template.txt", context)
subject = str(subject) # Force translation
try:
@@ -946,7 +892,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
@@ -1103,52 +1050,230 @@ 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,
}
class DocumentAskForAccess(BaseModel):
"""Relation model to ask for access to a document."""
document = models.ForeignKey(
Document, on_delete=models.CASCADE, related_name="ask_for_accesses"
)
user = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="ask_for_accesses"
)
role = models.CharField(
max_length=20, choices=RoleChoices.choices, default=RoleChoices.READER
)
class Meta:
db_table = "impress_document_ask_for_access"
verbose_name = _("Document ask for access")
verbose_name_plural = _("Document ask for accesses")
constraints = [
models.UniqueConstraint(
fields=["user", "document"],
name="unique_document_ask_for_access_user",
violation_error_message=_(
"This user has already asked for access to this document."
),
),
]
def __str__(self):
return f"{self.user!s} asked for access to document {self.document!s}"
def get_abilities(self, user):
"""Compute and return abilities for a given user."""
roles = []
if user.is_authenticated:
teams = user.teams
try:
roles = self.user_roles or []
except AttributeError:
try:
roles = self.document.accesses.filter(
models.Q(user=user) | models.Q(team__in=teams),
).values_list("role", flat=True)
except (self._meta.model.DoesNotExist, IndexError):
roles = []
is_admin_or_owner = bool(
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
return {
"destroy": is_admin_or_owner,
"update": is_admin_or_owner,
"partial_update": is_admin_or_owner,
"retrieve": is_admin_or_owner,
"accept": is_admin_or_owner,
}
def accept(self, role=None):
"""Accept a document ask for access resource."""
if role is None:
role = self.role
DocumentAccess.objects.update_or_create(
document=self.document,
user=self.user,
defaults={"role": role},
create_defaults={"role": role},
)
self.delete()
def send_ask_for_access_email(self, email, language=None):
"""
Method allowing a user to send an email notification when asking for access to a document.
"""
language = language or get_language()
sender = self.user
sender_name = sender.full_name or sender.email
sender_name_email = (
f"{sender.full_name:s} ({sender.email})"
if sender.full_name
else sender.email
)
with override(language):
context = {
"title": _("{name} would like access to a document!").format(
name=sender_name
),
"message": _(
"{name} would like access to the following document:"
).format(name=sender_name_email),
}
subject = (
context["title"]
if not self.document.title
else _("{name} is asking for access to the document: {title}").format(
name=sender_name, title=self.document.title
)
)
self.document.send_email(subject, [email], context, language)
class Template(BaseModel):
"""HTML and CSS code used for formatting the print around the MarkDown body."""
@@ -1171,10 +1296,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 []
@@ -1185,21 +1310,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,
@@ -1246,11 +1370,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):
View File
+24
View File
@@ -0,0 +1,24 @@
"""Send mail using celery task."""
from django.conf import settings
from core import models
from impress.celery_app import app
@app.task
def send_ask_for_access_mail(ask_for_access_id):
"""Send mail using celery task."""
# Send email to document owners/admins
ask_for_access = models.DocumentAskForAccess.objects.get(id=ask_for_access_id)
owner_admin_accesses = models.DocumentAccess.objects.filter(
document=ask_for_access.document, role__in=models.PRIVILEGED_ROLES
).select_related("user")
for access in owner_admin_accesses:
if access.user and access.user.email:
ask_for_access.send_ask_for_access_email(
access.user.email,
access.user.language or settings.LANGUAGE_CODE,
)
@@ -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]
@@ -0,0 +1,656 @@
"""Test API for document ask for access."""
import uuid
from django.core import mail
import pytest
from rest_framework.test import APIClient
from core.api.serializers import UserSerializer
from core.factories import (
DocumentAskForAccessFactory,
DocumentFactory,
UserDocumentAccessFactory,
UserFactory,
)
from core.models import DocumentAccess, DocumentAskForAccess, RoleChoices
pytestmark = pytest.mark.django_db
## Create
def test_api_documents_ask_for_access_create_anonymous():
"""Anonymous users should not be able to create a document ask for access."""
document = DocumentFactory()
client = APIClient()
response = client.post(f"/api/v1.0/documents/{document.id}/ask-for-access/")
assert response.status_code == 401
def test_api_documents_ask_for_access_create_invalid_document_id():
"""Invalid document ID should return a 404 error."""
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(f"/api/v1.0/documents/{uuid.uuid4()}/ask-for-access/")
assert response.status_code == 404
def test_api_documents_ask_for_access_create_authenticated():
"""
Authenticated users should be able to create a document ask for access.
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")
document = DocumentFactory(
users=[
(owner_user, RoleChoices.OWNER),
(admin_user, RoleChoices.ADMIN),
]
)
user = UserFactory()
client = APIClient()
client.force_login(user)
assert len(mail.outbox) == 0
response = client.post(f"/api/v1.0/documents/{document.id}/ask-for-access/")
assert response.status_code == 201
assert DocumentAskForAccess.objects.filter(
document=document,
user=user,
role=RoleChoices.READER,
).exists()
# Verify emails were sent to both owner and admin
assert len(mail.outbox) == 2
# Check that emails were sent to the right recipients
email_recipients = [email.to[0] for email in mail.outbox]
assert owner_user.email in email_recipients
assert admin_user.email in email_recipients
# Check email content for both users
for email in mail.outbox:
email_content = " ".join(email.body.split())
email_subject = " ".join(email.subject.split())
# Check that the requesting user's name is in the email
user_name = user.full_name or user.email
assert user_name.lower() in email_content.lower()
# Check that the subject mentions access request
assert "access" in email_subject.lower()
# Check that the document title is mentioned if it exists
if document.title:
assert document.title.lower() in email_subject.lower()
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.
"""
document = DocumentFactory()
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/ask-for-access/",
data={"role": RoleChoices.EDITOR},
)
assert response.status_code == 201
assert DocumentAskForAccess.objects.filter(
document=document,
user=user,
role=RoleChoices.EDITOR,
).exists()
def test_api_documents_ask_for_access_create_authenticated_already_has_access():
"""Authenticated users with existing access can ask for access with a different role."""
user = UserFactory()
document = DocumentFactory(users=[(user, RoleChoices.READER)])
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/ask-for-access/",
data={"role": RoleChoices.EDITOR},
)
assert response.status_code == 201
assert DocumentAskForAccess.objects.filter(
document=document,
user=user,
role=RoleChoices.EDITOR,
).exists()
def test_api_documents_ask_for_access_create_authenticated_already_has_ask_for_access():
"""
Authenticated users with existing ask for access can not ask for a new access on this document.
"""
user = UserFactory()
document = DocumentFactory(users=[(user, RoleChoices.READER)])
DocumentAskForAccessFactory(document=document, user=user, role=RoleChoices.READER)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/ask-for-access/",
data={"role": RoleChoices.EDITOR},
)
assert response.status_code == 400
assert response.json() == {"detail": "You already ask to access to this document."}
## List
def test_api_documents_ask_for_access_list_anonymous():
"""Anonymous users should not be able to list document ask for access."""
document = DocumentFactory()
DocumentAskForAccessFactory.create_batch(
3, document=document, role=RoleChoices.READER
)
client = APIClient()
response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/")
assert response.status_code == 401
def test_api_documents_ask_for_access_list_authenticated():
"""Authenticated users should be able to list document ask for access."""
document = DocumentFactory()
DocumentAskForAccessFactory.create_batch(
3, document=document, role=RoleChoices.READER
)
client = APIClient()
client.force_login(UserFactory())
response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/")
assert response.status_code == 200
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
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()
DocumentAskForAccessFactory.create_batch(
3, document=document, role=RoleChoices.READER
)
user = UserFactory()
user_data = UserSerializer(instance=user).data
document_ask_for_access = DocumentAskForAccessFactory(
document=document, user=user, role=RoleChoices.READER
)
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/")
assert response.status_code == 200
assert response.json() == {
"count": 1,
"next": None,
"previous": None,
"results": [
{
"id": str(document_ask_for_access.id),
"document": str(document.id),
"user": user_data,
"role": RoleChoices.READER,
"created_at": document_ask_for_access.created_at.isoformat().replace(
"+00:00", "Z"
),
"abilities": {
"accept": False,
"destroy": False,
"update": False,
"partial_update": False,
"retrieve": False,
},
}
],
}
def test_api_documents_ask_for_access_list_authenticated_other_document():
"""Authenticated users should not be able to list document ask for access of other documents."""
document = DocumentFactory()
DocumentAskForAccessFactory.create_batch(
3, document=document, role=RoleChoices.READER
)
client = APIClient()
client.force_login(UserFactory())
other_document = DocumentFactory()
DocumentAskForAccessFactory.create_batch(
3, document=other_document, role=RoleChoices.READER
)
response = client.get(f"/api/v1.0/documents/{other_document.id}/ask-for-access/")
assert response.status_code == 200
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
@pytest.mark.parametrize("role", [RoleChoices.READER, RoleChoices.EDITOR])
def test_api_documents_ask_for_access_list_non_owner_or_admin(role):
"""Non owner or admin users should not be able to list document ask for access."""
user = UserFactory()
document = DocumentFactory(users=[(user, role)])
DocumentAskForAccessFactory.create_batch(
3, document=document, role=RoleChoices.READER
)
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/")
assert response.status_code == 200
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
@pytest.mark.parametrize("role", [RoleChoices.OWNER])
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()
document = DocumentFactory(users=[(user, role)])
document_ask_for_accesses = DocumentAskForAccessFactory.create_batch(
3, document=document, role=RoleChoices.READER
)
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/")
assert response.status_code == 200
assert response.json() == {
"count": 3,
"next": None,
"previous": None,
"results": [
{
"id": str(document_ask_for_access.id),
"document": str(document.id),
"user": UserSerializer(instance=document_ask_for_access.user).data,
"role": RoleChoices.READER,
"created_at": document_ask_for_access.created_at.isoformat().replace(
"+00:00", "Z"
),
"abilities": {
"accept": True,
"destroy": True,
"update": True,
"partial_update": True,
"retrieve": True,
},
}
for document_ask_for_access in document_ask_for_accesses
],
}
## Retrieve
def test_api_documents_ask_for_access_retrieve_anonymous():
"""Anonymous users should not be able to retrieve document ask for access."""
document = DocumentFactory()
document_ask_for_access = DocumentAskForAccessFactory(
document=document, role=RoleChoices.READER
)
client = APIClient()
response = client.get(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
)
assert response.status_code == 401
def test_api_documents_ask_for_access_retrieve_authenticated():
"""Authenticated users should not be able to retrieve document ask for access."""
document = DocumentFactory()
document_ask_for_access = DocumentAskForAccessFactory(
document=document, role=RoleChoices.READER
)
client = APIClient()
client.force_login(UserFactory())
response = client.get(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
)
assert response.status_code == 404
@pytest.mark.parametrize("role", [RoleChoices.READER, RoleChoices.EDITOR])
def test_api_documents_ask_for_access_retrieve_authenticated_non_owner_or_admin(role):
"""Non owner or admin users should not be able to retrieve document ask for access."""
user = UserFactory()
document = DocumentFactory(users=[(user, role)])
document_ask_for_access = DocumentAskForAccessFactory(
document=document, role=RoleChoices.READER
)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
)
assert response.status_code == 404
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
def test_api_documents_ask_for_access_retrieve_owner_or_admin(role):
"""Owner or admin users should be able to retrieve document ask for access."""
user = UserFactory()
document = DocumentFactory(users=[(user, role)])
document_ask_for_access = DocumentAskForAccessFactory(
document=document, role=RoleChoices.READER
)
user_data = UserSerializer(instance=document_ask_for_access.user).data
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
)
assert response.status_code == 200
assert response.json() == {
"id": str(document_ask_for_access.id),
"document": str(document.id),
"user": user_data,
"role": RoleChoices.READER,
"created_at": document_ask_for_access.created_at.isoformat().replace(
"+00:00", "Z"
),
"abilities": {
"accept": True,
"destroy": True,
"update": True,
"partial_update": True,
"retrieve": True,
},
}
## Delete
def test_api_documents_ask_for_access_delete_anonymous():
"""Anonymous users should not be able to delete document ask for access."""
document = DocumentFactory()
document_ask_for_access = DocumentAskForAccessFactory(
document=document, role=RoleChoices.READER
)
client = APIClient()
response = client.delete(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
)
assert response.status_code == 401
def test_api_documents_ask_for_access_delete_authenticated():
"""Authenticated users should not be able to delete document ask for access."""
document = DocumentFactory()
document_ask_for_access = DocumentAskForAccessFactory(
document=document, role=RoleChoices.READER
)
client = APIClient()
client.force_login(UserFactory())
response = client.delete(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
)
assert response.status_code == 404
@pytest.mark.parametrize("role", [RoleChoices.READER, RoleChoices.EDITOR])
def test_api_documents_ask_for_access_delete_authenticated_non_owner_or_admin(role):
"""Non owner or admin users should not be able to delete document ask for access."""
user = UserFactory()
document = DocumentFactory(users=[(user, role)])
document_ask_for_access = DocumentAskForAccessFactory(
document=document, role=RoleChoices.READER
)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
)
assert response.status_code == 404
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
def test_api_documents_ask_for_access_delete_owner_or_admin(role):
"""Owner or admin users should be able to delete document ask for access."""
user = UserFactory()
document = DocumentFactory(users=[(user, role)])
document_ask_for_access = DocumentAskForAccessFactory(
document=document, role=RoleChoices.READER
)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
)
assert response.status_code == 204
assert not DocumentAskForAccess.objects.filter(
id=document_ask_for_access.id
).exists()
## Accept
def test_api_documents_ask_for_access_accept_anonymous():
"""Anonymous users should not be able to accept document ask for access."""
document = DocumentFactory()
document_ask_for_access = DocumentAskForAccessFactory(
document=document, role=RoleChoices.READER
)
client = APIClient()
response = client.post(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/accept/"
)
assert response.status_code == 401
def test_api_documents_ask_for_access_accept_authenticated():
"""Authenticated users should not be able to accept document ask for access."""
document = DocumentFactory()
document_ask_for_access = DocumentAskForAccessFactory(
document=document, role=RoleChoices.READER
)
client = APIClient()
client.force_login(UserFactory())
response = client.post(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/accept/"
)
assert response.status_code == 404
@pytest.mark.parametrize("role", [RoleChoices.READER, RoleChoices.EDITOR])
def test_api_documents_ask_for_access_accept_authenticated_non_owner_or_admin(role):
"""Non owner or admin users should not be able to accept document ask for access."""
user = UserFactory()
document = DocumentFactory(users=[(user, role)])
document_ask_for_access = DocumentAskForAccessFactory(
document=document, role=RoleChoices.READER
)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/accept/"
)
assert response.status_code == 404
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
def test_api_documents_ask_for_access_accept_owner_or_admin(role):
"""Owner or admin users should be able to accept document ask for access."""
user = UserFactory()
document = DocumentFactory(users=[(user, role)])
document_ask_for_access = DocumentAskForAccessFactory(
document=document, role=RoleChoices.READER
)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/accept/"
)
assert response.status_code == 204
assert not DocumentAskForAccess.objects.filter(
id=document_ask_for_access.id
).exists()
assert DocumentAccess.objects.filter(
document=document, user=document_ask_for_access.user, role=RoleChoices.READER
).exists()
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
def test_api_documents_ask_for_access_accept_authenticated_specific_role(role):
"""
Owner or admin users should be able to accept document ask for access with a specific role.
"""
user = UserFactory()
document = DocumentFactory(users=[(user, role)])
document_ask_for_access = DocumentAskForAccessFactory(
document=document, role=RoleChoices.READER
)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/accept/",
data={"role": RoleChoices.EDITOR},
)
assert response.status_code == 204
assert not DocumentAskForAccess.objects.filter(
id=document_ask_for_access.id
).exists()
assert DocumentAccess.objects.filter(
document=document, user=document_ask_for_access.user, role=RoleChoices.EDITOR
).exists()
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
def test_api_documents_ask_for_access_accept_authenticated_owner_or_admin_update_access(
role,
):
"""
Owner or admin users should be able to accept document ask for access and update the access.
"""
user = UserFactory()
document = DocumentFactory(users=[(user, role)])
document_access = UserDocumentAccessFactory(
document=document, role=RoleChoices.READER
)
document_ask_for_access = DocumentAskForAccessFactory(
document=document, user=document_access.user, role=RoleChoices.EDITOR
)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/accept/",
data={"role": RoleChoices.EDITOR},
)
assert response.status_code == 204
assert not DocumentAskForAccess.objects.filter(
id=document_ask_for_access.id
).exists()
document_access.refresh_from_db()
assert document_access.role == RoleChoices.EDITOR
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
# pylint: disable=line-too-long
def test_api_documents_ask_for_access_accept_authenticated_owner_or_admin_update_access_with_specific_role(
role,
):
"""
Owner or admin users should be able to accept document ask for access and update the access
with a specific role.
"""
user = UserFactory()
document = DocumentFactory(users=[(user, role)])
document_access = UserDocumentAccessFactory(
document=document, role=RoleChoices.READER
)
document_ask_for_access = DocumentAskForAccessFactory(
document=document, user=document_access.user, role=RoleChoices.EDITOR
)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/accept/",
data={"role": RoleChoices.ADMIN},
)
assert response.status_code == 204
assert not DocumentAskForAccess.objects.filter(
id=document_ask_for_access.id
).exists()
document_access.refresh_from_db()
assert document_access.role == RoleChoices.ADMIN
@@ -439,3 +439,56 @@ def test_api_documents_attachment_upload_unsafe():
"application/octet-stream",
]
assert file_head["ContentDisposition"] == 'attachment; filename="script.exe"'
def test_api_documents_attachment_upload_unsafe_mime_types_disabled(settings):
"""A file with an unsafe mime type but checking disabled should not be tagged as unsafe."""
settings.DOCUMENT_ATTACHMENT_CHECK_UNSAFE_MIME_TYPES_ENABLED = False
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "owner")])
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
file = SimpleUploadedFile(
name="script.exe", content=b"\x4d\x5a\x90\x00\x03\x00\x00\x00"
)
with mock.patch.object(malware_detection, "analyse_file") as mock_analyse_file:
response = client.post(url, {"file": file}, format="multipart")
assert response.status_code == 201
pattern = re.compile(rf"^{document.id!s}/attachments/(.*)\.exe")
url_parsed = urlparse(response.json()["file"])
assert url_parsed.path == f"/api/v1.0/documents/{document.id!s}/media-check/"
query = parse_qs(url_parsed.query)
assert query["key"][0] is not None
file_path = query["key"][0]
match = pattern.search(file_path)
file_id = match.group(1)
document.refresh_from_db()
assert document.attachments == [f"{document.id!s}/attachments/{file_id!s}.exe"]
assert "-unsafe" not in file_id
# Validate that file_id is a valid UUID
uuid.UUID(file_id)
key = file_path.replace("/media/", "")
mock_analyse_file.assert_called_once_with(key, document_id=document.id)
# Now, check the metadata of the uploaded file
file_head = default_storage.connection.meta.client.head_object(
Bucket=default_storage.bucket_name, Key=key
)
assert file_head["Metadata"] == {
"owner": str(user.id),
"status": "processing",
}
# Depending the libmagic version, the content type may change.
assert file_head["ContentType"] in [
"application/x-dosexec",
"application/octet-stream",
]
assert file_head["ContentDisposition"] == 'attachment; filename="script.exe"'
@@ -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,25 @@ def test_api_docs_cors_proxy_valid_url():
assert response.status_code == 200
assert response.headers["Content-Type"] == "image/png"
assert response.headers["Content-Disposition"] == "attachment;"
assert (
response.headers["Content-Security-Policy"]
== "default-src 'none'; img-src 'none' data:;"
)
policy_list = sorted(response.headers["Content-Security-Policy"].split("; "))
assert policy_list == [
"base-uri 'none'",
"child-src 'none'",
"connect-src 'none'",
"default-src 'none'",
"font-src 'none'",
"form-action 'none'",
"frame-ancestors 'none'",
"frame-src 'none'",
"img-src 'none' data:",
"manifest-src 'none'",
"media-src 'none'",
"object-src 'none'",
"prefetch-src 'none'",
"script-src 'none'",
"style-src 'none'",
"worker-src 'none'",
]
assert response.streaming_content
@@ -77,10 +92,25 @@ def test_api_docs_cors_proxy_authenticated_user_accessing_protected_doc():
assert response.status_code == 200
assert response.headers["Content-Type"] == "image/png"
assert response.headers["Content-Disposition"] == "attachment;"
assert (
response.headers["Content-Security-Policy"]
== "default-src 'none'; img-src 'none' data:;"
)
policy_list = sorted(response.headers["Content-Security-Policy"].split("; "))
assert policy_list == [
"base-uri 'none'",
"child-src 'none'",
"connect-src 'none'",
"default-src 'none'",
"font-src 'none'",
"form-action 'none'",
"frame-ancestors 'none'",
"frame-src 'none'",
"img-src 'none' data:",
"manifest-src 'none'",
"media-src 'none'",
"object-src 'none'",
"prefetch-src 'none'",
"script-src 'none'",
"style-src 'none'",
"worker-src 'none'",
]
assert response.streaming_content
@@ -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
@@ -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(
@@ -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
@@ -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
@@ -45,7 +46,7 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": ["reader", "editor"],
"restricted": None,
},
"media_auth": True,
"media_check": True,
@@ -59,6 +60,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 +78,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 +96,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": {
@@ -110,7 +116,9 @@ def test_api_documents_retrieve_anonymous_public_parent():
"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
),
"media_auth": True,
"media_check": True,
"move": False,
@@ -123,6 +131,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 +149,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,
}
@@ -209,7 +221,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": ["reader", "editor"],
"restricted": None,
},
"media_auth": True,
"media_check": True,
@@ -223,6 +235,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 +253,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 +279,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": {
@@ -281,10 +298,12 @@ 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
),
"move": False,
"media_auth": True,
"media_check": True,
"move": False,
"partial_update": grand_parent.link_role == "editor",
"restore": False,
"retrieve": True,
@@ -294,6 +313,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 +331,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 +427,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 +445,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 +471,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": {
@@ -462,7 +490,9 @@ 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
),
"media_auth": True,
"media_check": True,
"move": access.role in ["administrator", "owner"],
@@ -475,6 +505,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 +523,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 +619,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 +661,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 +679,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 +727,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 +745,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 +793,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 +811,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 +838,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):
@@ -88,7 +88,7 @@ def test_api_documents_trashbin_format():
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": ["reader", "editor"],
"restricted": None,
},
"media_auth": True,
"media_check": True,
@@ -102,6 +102,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 +118,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,
}
@@ -155,6 +155,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",
@@ -270,6 +274,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",
@@ -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
+19
View File
@@ -62,6 +62,25 @@ def test_api_config(is_authenticated):
"AI_FEATURE_ENABLED": False,
"theme_customization": {},
}
policy_list = sorted(response.headers["Content-Security-Policy"].split("; "))
assert policy_list == [
"base-uri 'none'",
"child-src 'none'",
"connect-src 'none'",
"default-src 'none'",
"font-src 'none'",
"form-action 'none'",
"frame-ancestors 'none'",
"frame-src 'none'",
"img-src 'none'",
"manifest-src 'none'",
"media-src 'none'",
"object-src 'none'",
"prefetch-src 'none'",
"script-src 'none'",
"style-src 'none'",
"worker-src 'none'",
]
@override_settings(
+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)
+106 -156
View File
@@ -171,7 +171,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,
@@ -229,7 +229,7 @@ def test_models_documents_get_abilities_reader(
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": ["reader", "editor"],
"restricted": None,
},
"media_auth": True,
"media_check": True,
@@ -252,7 +252,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"]
)
@@ -292,7 +292,7 @@ def test_models_documents_get_abilities_editor(
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": ["reader", "editor"],
"restricted": None,
},
"media_auth": True,
"media_check": True,
@@ -314,7 +314,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"]
)
@@ -344,7 +344,7 @@ 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,
},
"media_auth": True,
"media_check": True,
@@ -393,7 +393,7 @@ 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,
},
"media_auth": True,
"media_check": True,
@@ -415,7 +415,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"]
)
@@ -445,7 +445,7 @@ 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,
},
"media_auth": True,
"media_check": True,
@@ -467,7 +467,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"]
)
@@ -504,7 +504,7 @@ def test_models_documents_get_abilities_reader_user(
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": ["reader", "editor"],
"restricted": None,
},
"media_auth": True,
"media_check": True,
@@ -528,7 +528,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"]
)
@@ -561,7 +561,7 @@ 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,
},
"media_auth": True,
"media_check": True,
@@ -1176,184 +1176,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},
],
}
+6
View File
@@ -27,6 +27,12 @@ document_related_router.register(
basename="invitations",
)
document_related_router.register(
"ask-for-access",
viewsets.DocumentAskForAccessViewSet,
basename="ask_for_access",
)
# - Routes nested under a template
template_related_router = DefaultRouter()
+53 -11
View File
@@ -18,9 +18,13 @@ from django.utils.translation import gettext_lazy as _
import sentry_sdk
from configurations import Configuration, values
from csp.constants import NONE
from lasuite.configuration.values import SecretFileValue
from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.integrations.logging import ignore_logger
# pylint: disable=too-many-lines
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DATA_DIR = os.getenv("DATA_DIR", os.path.join("/", "data"))
@@ -65,7 +69,7 @@ class Base(Configuration):
# Security
ALLOWED_HOSTS = values.ListValue([])
SECRET_KEY = values.Value(None)
SECRET_KEY = SecretFileValue(None)
SERVER_TO_SERVER_API_TOKENS = values.ListValue([])
# Application definition
@@ -76,7 +80,7 @@ class Base(Configuration):
DATABASES = {
"default": {
"ENGINE": values.Value(
"django.db.backends.postgresql_psycopg2",
"django.db.backends.postgresql",
environ_name="DB_ENGINE",
environ_prefix=None,
),
@@ -84,7 +88,7 @@ class Base(Configuration):
"impress", environ_name="DB_NAME", environ_prefix=None
),
"USER": values.Value("dinum", environ_name="DB_USER", environ_prefix=None),
"PASSWORD": values.Value(
"PASSWORD": SecretFileValue(
"pass", environ_name="DB_PASSWORD", environ_prefix=None
),
"HOST": values.Value(
@@ -122,10 +126,10 @@ class Base(Configuration):
AWS_S3_ENDPOINT_URL = values.Value(
environ_name="AWS_S3_ENDPOINT_URL", environ_prefix=None
)
AWS_S3_ACCESS_KEY_ID = values.Value(
AWS_S3_ACCESS_KEY_ID = SecretFileValue(
environ_name="AWS_S3_ACCESS_KEY_ID", environ_prefix=None
)
AWS_S3_SECRET_ACCESS_KEY = values.Value(
AWS_S3_SECRET_ACCESS_KEY = SecretFileValue(
environ_name="AWS_S3_SECRET_ACCESS_KEY", environ_prefix=None
)
AWS_S3_REGION_NAME = values.Value(
@@ -212,7 +216,11 @@ class Base(Configuration):
"application/x-msdownload",
"application/xml",
]
DOCUMENT_ATTACHMENT_CHECK_UNSAFE_MIME_TYPES_ENABLED = values.BooleanValue(
True,
environ_name="DOCUMENT_ATTACHMENT_CHECK_UNSAFE_MIME_TYPES_ENABLED",
environ_prefix=None,
)
# Document versions
DOCUMENT_VERSIONS_PAGE_SIZE = 50
@@ -285,6 +293,7 @@ class Base(Configuration):
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"dockerflow.django.middleware.DockerflowMiddleware",
"csp.middleware.CSPMiddleware",
]
AUTHENTICATION_BACKENDS = [
@@ -318,6 +327,7 @@ class Base(Configuration):
# OIDC third party
"mozilla_django_oidc",
"lasuite.malware_detection",
"csp",
]
# Cache
@@ -384,7 +394,7 @@ class Base(Configuration):
EMAIL_BRAND_NAME = values.Value(None)
EMAIL_HOST = values.Value(None)
EMAIL_HOST_USER = values.Value(None)
EMAIL_HOST_PASSWORD = values.Value(None)
EMAIL_HOST_PASSWORD = SecretFileValue(None)
EMAIL_LOGO_IMG = values.Value(None)
EMAIL_PORT = values.PositiveIntegerValue(None)
EMAIL_USE_TLS = values.BooleanValue(False)
@@ -407,7 +417,7 @@ class Base(Configuration):
COLLABORATION_API_URL = values.Value(
None, environ_name="COLLABORATION_API_URL", environ_prefix=None
)
COLLABORATION_SERVER_SECRET = values.Value(
COLLABORATION_SERVER_SECRET = SecretFileValue(
None, environ_name="COLLABORATION_SERVER_SECRET", environ_prefix=None
)
COLLABORATION_WS_URL = values.Value(
@@ -482,7 +492,7 @@ class Base(Configuration):
OIDC_RP_CLIENT_ID = values.Value(
"impress", environ_name="OIDC_RP_CLIENT_ID", environ_prefix=None
)
OIDC_RP_CLIENT_SECRET = values.Value(
OIDC_RP_CLIENT_SECRET = SecretFileValue(
None,
environ_name="OIDC_RP_CLIENT_SECRET",
environ_prefix=None,
@@ -597,7 +607,7 @@ class Base(Configuration):
AI_FEATURE_ENABLED = values.BooleanValue(
default=False, environ_name="AI_FEATURE_ENABLED", environ_prefix=None
)
AI_API_KEY = values.Value(None, environ_name="AI_API_KEY", environ_prefix=None)
AI_API_KEY = SecretFileValue(None, environ_name="AI_API_KEY", environ_prefix=None)
AI_BASE_URL = values.Value(None, environ_name="AI_BASE_URL", environ_prefix=None)
AI_MODEL = values.Value(None, environ_name="AI_MODEL", environ_prefix=None)
AI_ALLOW_REACH_FROM = values.Value(
@@ -618,7 +628,7 @@ class Base(Configuration):
}
# Y provider microservice
Y_PROVIDER_API_KEY = values.Value(
Y_PROVIDER_API_KEY = SecretFileValue(
environ_name="Y_PROVIDER_API_KEY",
environ_prefix=None,
)
@@ -717,6 +727,38 @@ class Base(Configuration):
environ_prefix=None,
)
# Content Security Policy
# See https://content-security-policy.com/ for more information.
CONTENT_SECURITY_POLICY = {
"EXCLUDE_URL_PREFIXES": values.ListValue(
["/admin"],
environ_name="CONTENT_SECURITY_POLICY_EXCLUDE_URL_PREFIXES",
environ_prefix=None,
),
"DIRECTIVES": values.DictValue(
default={
"default-src": [NONE],
"script-src": [NONE],
"style-src": [NONE],
"img-src": [NONE],
"connect-src": [NONE],
"font-src": [NONE],
"object-src": [NONE],
"media-src": [NONE],
"frame-src": [NONE],
"child-src": [NONE],
"form-action": [NONE],
"frame-ancestors": [NONE],
"base-uri": [NONE],
"worker-src": [NONE],
"manifest-src": [NONE],
"prefetch-src": [NONE],
},
environ_name="CONTENT_SECURITY_POLICY_DIRECTIVES",
environ_prefix=None,
),
}
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):
+12 -11
View File
@@ -26,16 +26,17 @@ readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"beautifulsoup4==4.13.4",
"boto3==1.38.36",
"boto3==1.38.46",
"Brotli==1.1.0",
"celery[redis]==5.5.3",
"django-configurations==2.5.1",
"django-cors-headers==4.7.0",
"django-countries==7.6.1",
"django-csp==4.0",
"django-filter==25.1",
"django-lasuite[all]==0.0.9",
"django-lasuite[all]==0.0.10",
"django-parler==2.3",
"django-redis==5.4.0",
"django-redis==6.0.0",
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django==5.2.3",
@@ -47,18 +48,18 @@ dependencies = [
"factory_boy==3.3.3",
"gunicorn==23.0.0",
"jsonschema==4.24.0",
"lxml==5.4.0",
"markdown==3.8",
"lxml==6.0.0",
"markdown==3.8.2",
"mozilla-django-oidc==4.0.1",
"nested-multipart-parser==1.5.0",
"openai==1.86.0",
"openai==1.93.0",
"psycopg[binary]==3.2.9",
"pycrdt==0.12.21",
"pycrdt==0.12.23",
"PyJWT==2.10.1",
"python-magic==0.4.27",
"redis<6.0.0",
"requests==2.32.4",
"sentry-sdk==2.30.0",
"sentry-sdk==2.32.0",
"whitenoise==6.9.0",
]
@@ -76,16 +77,16 @@ dev = [
"freezegun==1.5.2",
"ipdb==0.13.13",
"ipython==9.3.0",
"pyfakefs==5.8.0",
"pyfakefs==5.9.1",
"pylint-django==2.6.1",
"pylint==3.3.7",
"pytest-cov==6.2.1",
"pytest-django==4.11.1",
"pytest==8.4.0",
"pytest==8.4.1",
"pytest-icdiff==0.9",
"pytest-xdist==3.7.0",
"responses==0.25.7",
"ruff==0.11.13",
"ruff==0.12.1",
"types-requests==2.32.4.20250611",
]
@@ -1,5 +1,7 @@
import { Page, expect } from '@playwright/test';
export const BROWSERS = ['chromium', 'webkit', 'firefox'];
export const CONFIG = {
AI_FEATURE_ENABLED: true,
CRISP_WEBSITE_ID: null,
@@ -76,12 +78,15 @@ export const createDoc = async (
docName: string,
browserName: string,
length: number = 1,
isChild: 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 (!isChild) {
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
}
await page
.getByRole('button', {
@@ -208,7 +213,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 getWaitForCreateDoc = (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 (
@@ -218,12 +243,15 @@ export const mockedDocument = async (page: Page, json: object) => {
!request.url().includes('accesses') &&
!request.url().includes('invitations')
) {
const { abilities, ...rest } = 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,
@@ -234,10 +262,22 @@ 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',
user_roles: ['owner'],
...rest,
},
});
} else {
@@ -246,7 +286,41 @@ export const mockedDocument = async (page: Page, json: object) => {
});
};
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('page=')) {
await route.fulfill({
json: {
count: data.length,
next: null,
previous: null,
results: data,
},
});
}
});
};
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 (
@@ -254,70 +328,75 @@ export const mockedInvitations = async (page: Page, json?: object) => {
request.url().includes('invitations') &&
request.url().includes('page=')
) {
console.log('GET');
await route.fulfill({
json: {
count: 1,
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: 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') &&
request.url().includes('page=')
request.url().includes('accesses')
) {
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,
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();
@@ -328,4 +407,6 @@ export const mockedAccesses = async (page: Page, json?: object) => {
export const expectLoginPage = async (page: Page) =>
await expect(
page.getByRole('heading', { name: 'Collaborative writing' }),
).toBeVisible();
).toBeVisible({
timeout: 10000,
});
@@ -47,6 +47,10 @@ test.describe('Config', () => {
await expect(image).toBeVisible();
// Wait for the media-check to be processed
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(1000);
// Check src of image
expect(await image.getAttribute('src')).toMatch(
/http:\/\/localhost:8083\/media\/.*\/attachments\/.*.png/,
@@ -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 },
);
@@ -39,6 +39,7 @@ test.describe('Doc Create: Not logged', () => {
browserName,
request,
}) => {
const SERVER_TO_SERVER_API_TOKENS = 'server-api-token';
const markdown = `This is a normal text\n\n# And this is a large heading`;
const [title] = randomName('My server way doc create', browserName, 1);
const data = {
@@ -53,7 +54,7 @@ test.describe('Doc Create: Not logged', () => {
{
data,
headers: {
Authorization: 'Bearer test-e2e',
Authorization: `Bearer ${SERVER_TO_SERVER_API_TOKENS}`,
format: 'json',
},
},
@@ -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',
@@ -275,6 +276,10 @@ test.describe('Doc Editor', () => {
await expect(image).toBeVisible();
// Wait for the media-check to be processed
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(1000);
// Check src of image
expect(await image.getAttribute('src')).toMatch(
/http:\/\/localhost:8083\/media\/.*\/attachments\/.*.png/,
@@ -452,7 +457,17 @@ test.describe('Doc Editor', () => {
page.getByText('This file is flagged as unsafe.'),
).toBeVisible();
await page.getByRole('button', { name: 'Download' }).click();
await expect(
page.getByRole('button', {
name: 'Download',
}),
).toBeVisible();
void page
.getByRole('button', {
name: 'Download',
})
.click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toContain(`-unsafe.html`);
@@ -52,10 +52,6 @@ test.describe('Doc Export', () => {
1,
);
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
});
await verifyDocName(page, randomDoc);
const editor = page.locator('.ProseMirror.bn-editor');
@@ -79,6 +75,10 @@ test.describe('Doc Export', () => {
})
.click();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
});
void page
.getByRole('button', {
name: 'Download',
@@ -98,11 +98,6 @@ test.describe('Doc Export', () => {
test('it exports the doc to docx', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1);
const fileChooserPromise = page.waitForEvent('filechooser');
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.docx`);
});
await verifyDocName(page, randomDoc);
await page.locator('.ProseMirror.bn-editor').click();
@@ -111,6 +106,8 @@ test.describe('Doc Export', () => {
await page.keyboard.press('Enter');
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Resizable image with caption').click();
const fileChooserPromise = page.waitForEvent('filechooser');
await page.getByText('Upload image').click();
const fileChooser = await fileChooserPromise;
@@ -129,6 +126,16 @@ test.describe('Doc Export', () => {
await page.getByRole('combobox', { name: 'Format' }).click();
await page.getByRole('option', { name: 'Docx' }).click();
await expect(
page.getByRole('button', {
name: 'Download',
}),
).toBeVisible();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.docx`);
});
void page
.getByRole('button', {
name: 'Download',
@@ -149,16 +156,6 @@ test.describe('Doc Export', () => {
test('it exports the docs with images', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1);
const responseCorsPromise = page.waitForResponse(
(response) =>
response.url().includes('/cors-proxy/') && response.status() === 200,
);
const fileChooserPromise = page.waitForEvent('filechooser');
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
});
await verifyDocName(page, randomDoc);
await page.locator('.ProseMirror.bn-editor').click();
@@ -167,8 +164,9 @@ test.describe('Doc Export', () => {
await page.keyboard.press('Enter');
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Resizable image with caption').click();
await page.getByText('Upload image').click();
const fileChooserPromise = page.waitForEvent('filechooser');
await page.getByText('Upload image').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(path.join(__dirname, 'assets/test.svg'));
@@ -206,6 +204,21 @@ test.describe('Doc Export', () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
await expect(
page.getByRole('button', {
name: 'Download',
}),
).toBeVisible();
const responseCorsPromise = page.waitForResponse(
(response) =>
response.url().includes('/cors-proxy/') && response.status() === 200,
);
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
});
void page
.getByRole('button', {
name: 'Download',
@@ -227,11 +240,7 @@ test.describe('Doc Export', () => {
test('it exports the doc with quotes', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'export-quotes', browserName, 1);
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
});
const editor = page.locator('.ProseMirror');
const editor = page.locator('.ProseMirror.bn-editor');
// Trigger slash menu to show menu
await editor.click();
await editor.fill('/');
@@ -241,7 +250,9 @@ test.describe('Doc Export', () => {
editor.locator('.bn-block-content[data-content-type="quote"]'),
).toBeVisible();
await editor.fill('Hello World');
await editor
.locator('.bn-block-content[data-content-type="quote"]')
.fill('Hello World');
await expect(editor.getByText('Hello World')).toHaveCSS(
'font-style',
@@ -254,6 +265,16 @@ test.describe('Doc Export', () => {
})
.click();
await expect(
page.getByRole('button', {
name: 'Download',
}),
).toBeVisible();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
});
void page
.getByRole('button', {
name: 'Download',
@@ -276,10 +297,6 @@ test.describe('Doc Export', () => {
test('it exports the doc with divider', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'export-divider', browserName, 1);
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
});
const editor = page.locator('.ProseMirror');
await editor.click();
await editor.fill('Hello World');
@@ -298,6 +315,16 @@ test.describe('Doc Export', () => {
})
.click();
await expect(
page.getByRole('button', {
name: 'Download',
}),
).toBeVisible();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
});
void page
.getByRole('button', {
name: 'Download',
@@ -0,0 +1,314 @@
import { expect, test } from '@playwright/test';
import { createDoc, mockedListDocs } from './common';
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();
});
});
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',
},
];
@@ -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'],
},
],
},
@@ -168,6 +169,8 @@ test.describe('Document grid item options', () => {
},
link_reach: 'restricted',
created_at: '2021-09-01T09:00:00Z',
user_roles: ['editor'],
user_role: 'editor',
},
],
},
@@ -54,6 +54,7 @@ test.describe('Doc Header', () => {
retrieve: true,
},
link_reach: 'public',
computed_link_reach: 'public',
created_at: '2021-09-01T09:00:00Z',
});
@@ -96,7 +97,9 @@ test.describe('Doc Header', () => {
).toBeVisible();
await expect(
page.getByText(`Are you sure you want to delete this document ?`),
page.getByText(
`This document will be permanently deleted. This action is irreversible.`,
),
).toBeVisible();
await page
@@ -158,32 +161,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 }) => {
@@ -0,0 +1,154 @@
import { expect, test } from '@playwright/test';
import { createDoc } from './common';
import { updateShareLink } from './share-utils';
import { createRootSubPage } from './sub-pages-utils';
test.describe('Inherited share accesses', () => {
test('it checks inherited accesses', async ({ page, browserName }) => {
await page.goto('/');
await createDoc(page, 'root-doc', browserName, 1);
const docTree = page.getByTestId('doc-tree');
// 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 createRootSubPage(page, browserName, 'sub-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 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}.e2e`,
);
await expect(user).toBeVisible();
await expect(user.getByText('E2E Chromium')).toBeVisible();
await expect(user.getByText('Owner')).toBeVisible();
});
});
test.describe('Inherited share link', () => {
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 restricted and reader
await page.getByRole('button', { name: 'Share' }).click();
// await expect(page.getByText('Inherited share')).toBeVisible();
const docVisibilityCard = page.getByLabel('Doc visibility card');
await expect(docVisibilityCard).toBeVisible();
await expect(docVisibilityCard.getByText('Connected')).toBeVisible();
await expect(docVisibilityCard.getByText('Reading')).toBeVisible();
});
/**
* These tests are temporarily removed because we hide the ability to modify this parameter in sub-pages for now.
* There is a high probability that this feature will not change and therefore the test won't either.
*/
// test('it checks warning message when sharing rules differ', 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');
// await page.getByRole('button', { name: 'Share' }).click();
// // Update share link to public and edition
// await updateShareLink(page, 'Public', 'Edition');
// await expect(page.getByText('Sharing rules differ from the')).toBeVisible();
// const restoreButton = page.getByRole('button', { name: 'Restore' });
// await expect(restoreButton).toBeVisible();
// await restoreButton.click();
// await expect(
// page.getByText('The document visibility has been updated').first(),
// ).toBeVisible();
// await expect(page.getByText('Sharing rules differ from the')).toBeHidden();
// });
// test('it checks inherited link possibilities', 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();
// await expect(
// page.getByText('Document accessible to any connected person'),
// ).toBeVisible();
// // Create sub page
// const { item: subPageItem } = await createRootSubPage(
// page,
// browserName,
// 'sub-page',
// );
// await expect(
// page.getByText('Document accessible to any connected person'),
// ).toBeVisible();
// // Update share link to public and edition
// await page.getByRole('button', { name: 'Share' }).click();
// await verifyLinkReachIsDisabled(page, 'Private');
// await updateShareLink(page, 'Public', 'Edition');
// await page.getByRole('button', { name: 'OK' }).click();
// await expect(page.getByText('Public document')).toBeVisible();
// // Create sub page
// await createSubPageFromParent(
// page,
// browserName,
// subPageItem.id,
// 'sub-page-2',
// );
// await expect(page.getByText('Public document')).toBeVisible();
// // Verify share link and role
// await page.getByRole('button', { name: 'Share' }).click();
// await verifyLinkReachIsDisabled(page, 'Private');
// await verifyLinkReachIsDisabled(page, 'Connected');
// await verifyLinkReachIsEnabled(page, 'Public');
// await verifyLinkRoleIsDisabled(page, 'Reading');
// await verifyLinkRoleIsEnabled(page, 'Edition');
// });
});
@@ -1,12 +1,18 @@
import { expect, test } from '@playwright/test';
import { createDoc, randomName } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
import {
BROWSERS,
createDoc,
keyCloakSignIn,
randomName,
verifyDocName,
} from './common';
test.describe('Document create member', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('it selects 2 users and 1 invitation', async ({ page, browserName }) => {
const inputFill = 'user ';
const responsePromise = page.waitForResponse(
@@ -203,3 +209,89 @@ test.describe('Document create member', () => {
await expect(userInvitation).toBeHidden();
});
});
test.describe('Document create member: Multiple login', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('It creates a member from a request coming from a 403 page', async ({
page,
browserName,
}) => {
test.slow();
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docTitle] = await createDoc(
page,
'Member access request',
browserName,
1,
);
await verifyDocName(page, docTitle);
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();
await page.goto(urlDoc);
await expect(
page.getByText('Insufficient access rights to view the document.'),
).toBeVisible({
timeout: 10000,
});
await page.getByRole('button', { name: 'Request access' }).click();
await expect(
page.getByText('Your access request for this document is pending.'),
).toBeVisible();
await page
.getByRole('button', {
name: 'Logout',
})
.click();
await page.goto('/');
await keyCloakSignIn(page, browserName);
await expect(
page.getByRole('link', { name: 'Docs Logo Docs' }),
).toBeVisible();
await page.goto(urlDoc);
await page.getByRole('button', { name: 'Share' }).click();
await expect(page.getByText('Access Requests')).toBeVisible();
await expect(page.getByText(`E2E ${otherBrowser}`)).toBeVisible();
const emailRequest = `user@${otherBrowser}.test`;
await expect(page.getByText(emailRequest)).toBeVisible();
const container = page.getByTestId(
`doc-share-access-request-row-${emailRequest}`,
);
await container.getByLabel('doc-role-dropdown').click();
await page.getByLabel('Administrator').click();
await container.getByRole('button', { name: 'Approve' }).click();
await expect(page.getByText('Access Requests')).toBeHidden();
await expect(page.getByText('Share with 2 users')).toBeVisible();
await expect(page.getByText(`E2E ${otherBrowser}`)).toBeVisible();
});
});
@@ -8,47 +8,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 +68,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();
});
@@ -177,17 +184,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,26 +201,23 @@ 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('You do not have permission to view this document.'),
page.getByText('Insufficient access rights to view the document.'),
).toBeVisible();
});
});
@@ -60,7 +60,7 @@ 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', browserName, 1);
await verifyDocName(page, docTitle);
const responsePromise = page.route(
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { createDoc, verifyDocName } from './common';
import { createDoc, randomName, verifyDocName } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -25,7 +25,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 +97,85 @@ test.describe('Document search', () => {
page.getByLabel('Search modal').getByText('search'),
).toBeHidden();
});
test("it checks we don't see filters in search modal", async ({ page }) => {
const searchButton = page
.getByTestId('left-panel-desktop')
.getByRole('button', { name: 'search' });
await expect(searchButton).toBeVisible();
await page.getByRole('button', { name: 'search', exact: true }).click();
await expect(
page.getByRole('combobox', { name: 'Quick search input' }),
).toBeVisible();
await expect(page.getByTestId('doc-search-filters')).toBeHidden();
});
});
test.describe('Sub page search', () => {
test('it check the presence of filters in search modal', async ({
page,
browserName,
}) => {
await page.goto('/');
const [doc1Title] = await createDoc(
page,
'My sub page search',
browserName,
1,
);
await verifyDocName(page, doc1Title);
const searchButton = page
.getByTestId('left-panel-desktop')
.getByRole('button', { name: 'search' });
await searchButton.click();
const filters = page.getByTestId('doc-search-filters');
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: 'Current doc' }).click();
await expect(page.getByRole('button', { name: 'Reset' })).toBeVisible();
});
test('it searches sub pages', async ({ page, browserName }) => {
await page.goto('/');
const [doc1Title] = await createDoc(
page,
'My sub page search',
browserName,
1,
);
await verifyDocName(page, doc1Title);
await page.getByRole('button', { name: 'New doc' }).click();
await verifyDocName(page, '');
await page.getByRole('textbox', { name: 'doc title input' }).click();
await page
.getByRole('textbox', { name: 'doc title input' })
.press('ControlOrMeta+a');
const [randomDocName] = randomName('doc-sub-page', browserName, 1);
await page
.getByRole('textbox', { name: 'doc title input' })
.fill(randomDocName);
const searchButton = page
.getByTestId('left-panel-desktop')
.getByRole('button', { name: 'search' });
await searchButton.click();
await expect(
page.getByRole('button', { name: 'Current doc' }),
).toBeVisible();
await page.getByRole('combobox', { name: 'Quick search input' }).click();
await page
.getByRole('combobox', { name: 'Quick search input' })
.fill('sub');
await expect(page.getByLabel(randomDocName)).toBeVisible();
});
});
@@ -0,0 +1,261 @@
/* eslint-disable playwright/no-conditional-in-test */
import { expect, test } from '@playwright/test';
import {
createDoc,
expectLoginPage,
keyCloakSignIn,
randomName,
updateDocTitle,
verifyDocName,
} from './common';
import { clickOnAddRootSubPage, createRootSubPage } from './sub-pages-utils';
test.describe('Doc Tree', () => {
test('create new sub pages', async ({ page, browserName }) => {
await page.goto('/');
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 page.goto('/');
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 }) => {
await page.goto('/');
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.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();
});
});
@@ -116,11 +116,7 @@ test.describe('Doc Version', () => {
await expect(panel).toBeVisible();
await expect(page.getByText('History', { exact: true })).toBeVisible();
await expect(page.getByRole('status')).toBeVisible();
await expect(page.getByRole('status')).toBeHidden();
const items = await panel.locator('.version-item').all();
expect(items.length).toBe(1);
await items[0].click();
await panel.getByRole('button', { name: 'version item' }).click();
await expect(modal.getByText('World')).toBeHidden();
@@ -1,14 +1,13 @@
import { expect, test } from '@playwright/test';
import {
BROWSERS,
createDoc,
expectLoginPage,
keyCloakSignIn,
verifyDocName,
} from './common';
const browsersName = ['chromium', 'webkit', 'firefox'];
test.describe('Doc Visibility', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -118,18 +117,20 @@ test.describe('Doc Visibility: Restricted', () => {
})
.click();
const otherBrowser = browsersName.find((b) => b !== browserName);
const otherBrowser = BROWSERS.find((b) => b !== browserName);
await keyCloakSignIn(page, otherBrowser!);
await expect(
page.getByRole('link', { name: 'Docs Logo Docs' }),
).toBeVisible();
).toBeVisible({
timeout: 10000,
});
await page.goto(urlDoc);
await expect(
page.getByText('You do not have permission to view this document.'),
page.getByText('Insufficient access rights to view the document.'),
).toBeVisible({
timeout: 10000,
});
@@ -150,7 +151,7 @@ test.describe('Doc Visibility: Restricted', () => {
name: 'Quick search input',
});
const otherBrowser = browsersName.find((b) => b !== browserName);
const otherBrowser = BROWSERS.find((b) => b !== browserName);
const username = `user@${otherBrowser}.test`;
await inputSearch.fill(username);
await page.getByRole('option', { name: username }).click();
@@ -261,12 +262,22 @@ 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: 'New padoce' })).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();
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.',
),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Request access' }),
).toBeHidden();
});
test('It checks a public doc in editable mode', async ({
@@ -430,7 +441,7 @@ test.describe('Doc Visibility: Authenticated', () => {
})
.click();
const otherBrowser = browsersName.find((b) => b !== browserName);
const otherBrowser = BROWSERS.find((b) => b !== browserName);
await keyCloakSignIn(page, otherBrowser!);
await expect(
@@ -443,6 +454,18 @@ test.describe('Doc Visibility: Authenticated', () => {
await page.getByRole('button', { name: 'Share' }).click();
await page.getByRole('button', { name: 'Copy link' }).click();
await expect(page.getByText('Link Copied !')).toBeVisible();
await expect(
page.getByText(
'You do not have permission to view users sharing this document or modify link settings.',
),
).toBeVisible();
await page.getByRole('button', { name: 'Request access' }).click();
await expect(
page.getByRole('button', { name: 'Request access' }),
).toBeDisabled();
});
test('It checks a authenticated doc in editable mode', async ({
@@ -490,7 +513,7 @@ test.describe('Doc Visibility: Authenticated', () => {
})
.click();
const otherBrowser = browsersName.find((b) => b !== browserName);
const otherBrowser = BROWSERS.find((b) => b !== browserName);
await keyCloakSignIn(page, otherBrowser!);
await expect(
@@ -108,7 +108,6 @@ test.describe('Footer', () => {
await expect(footer.getByAltText('Gouvernement Logo')).toBeVisible();
await expect(footer.getByRole('heading', { name: 'Docs' })).toBeHidden();
await expect(footer.getByText('BETA')).toBeHidden();
await expect(
footer.getByRole('link', { name: 'legifrance.gouv.fr' }),
@@ -80,7 +80,6 @@ test.describe('Home page', () => {
).toBeVisible();
await expect(header.getByRole('img', { name: 'Docs logo' })).toBeVisible();
await expect(header.getByRole('heading', { name: 'Docs' })).toBeVisible();
await expect(header.getByText('BETA')).toBeVisible();
// Check the titles
const h2 = page.locator('h2');
@@ -79,28 +79,19 @@ test.describe.serial('Language', () => {
}) => {
await createDoc(page, 'doc-toolbar', browserName, 1);
const header = page.locator('header').first();
const editor = page.locator('.ProseMirror');
// Trigger slash menu to show english menu
await editor.click();
await editor.fill('/');
await expect(page.getByText('Headings', { exact: true })).toBeVisible();
await header.click();
await expect(page.getByText('Headings', { exact: true })).toBeHidden();
// Reset menu
await editor.click();
await editor.fill('');
// Change language to French
await waitForLanguageSwitch(page, TestLanguage.French);
// Trigger slash menu to show french menu
await editor.click();
await editor.fill('/');
await editor.locator('.bn-block-outer').last().fill('/');
await expect(page.getByText('Titres', { exact: true })).toBeVisible();
await header.click();
await expect(page.getByText('Titres', { exact: true })).toBeHidden();
});
});
@@ -0,0 +1,158 @@
import { Locator, Page, expect } from '@playwright/test';
export type UserSearchResult = {
email: string;
full_name?: string | null;
};
export type Role = 'Administrator' | 'Owner' | 'Member' | 'Editor' | 'Reader';
export type LinkReach = 'Private' | 'Connected' | 'Public';
export type LinkRole = 'Reading' | 'Edition';
export const searchUserToInviteToDoc = async (
page: Page,
inputFill?: string,
): Promise<UserSearchResult[]> => {
const inputFillValue = inputFill ?? 'user ';
const responsePromise = page.waitForResponse(
(response) =>
response
.url()
.includes(`/users/?q=${encodeURIComponent(inputFillValue)}`) &&
response.status() === 200,
);
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByRole('combobox', {
name: 'Quick search input',
});
await expect(inputSearch).toBeVisible();
await inputSearch.fill(inputFillValue);
const response = await responsePromise;
const users = (await response.json()) as UserSearchResult[];
return users;
};
export const addMemberToDoc = async (
page: Page,
role: Role,
users: UserSearchResult[],
) => {
const list = page.getByTestId('doc-share-add-member-list');
await expect(list).toBeHidden();
const quickSearchContent = page.getByTestId('doc-share-quick-search');
for (const user of users) {
await quickSearchContent
.getByTestId(`search-user-row-${user.email}`)
.click();
}
await list.getByLabel('doc-role-dropdown').click();
await expect(page.getByLabel(role)).toBeVisible();
await page.getByLabel(role).click();
await page.getByRole('button', { name: 'Invite' }).click();
};
export const verifyMemberAddedToDoc = async (
page: Page,
user: UserSearchResult,
role: Role,
): Promise<Locator> => {
const container = page.getByLabel('List members card');
await expect(container).toBeVisible();
const userRow = container.getByTestId(`doc-share-member-row-${user.email}`);
await expect(userRow).toBeVisible();
await expect(userRow.getByText(role)).toBeVisible();
await expect(userRow.getByText(user.full_name || user.email)).toBeVisible();
return userRow;
};
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 verifyLinkReachIsDisabled = async (
page: Page,
linkReach: LinkReach,
) => {
await page.getByRole('button', { name: 'Visibility', exact: true }).click();
const item = page.getByRole('menuitem', { name: linkReach });
await expect(item).toBeDisabled();
await page.click('body');
};
export const verifyLinkReachIsEnabled = async (
page: Page,
linkReach: LinkReach,
) => {
await page.getByRole('button', { name: 'Visibility', exact: true }).click();
const item = page.getByRole('menuitem', { name: linkReach });
await expect(item).toBeEnabled();
await page.click('body');
};
export const verifyLinkRoleIsDisabled = async (
page: Page,
linkRole: LinkRole,
) => {
await page
.getByRole('button', { name: 'Visibility mode', exact: true })
.click();
const item = page.getByRole('menuitem', { name: linkRole });
await expect(item).toBeDisabled();
await page.click('body');
};
export const verifyLinkRoleIsEnabled = async (
page: Page,
linkRole: LinkRole,
) => {
await page
.getByRole('button', { name: 'Visibility mode', exact: true })
.click();
const item = page.getByRole('menuitem', { name: linkRole });
await expect(item).toBeEnabled();
await page.click('body');
};
export const verifyShareLink = async (
page: Page,
linkReach: LinkReach,
linkRole?: LinkRole | null,
) => {
const visibilityDropdownButton = page.getByRole('button', {
name: 'Visibility',
exact: true,
});
await expect(visibilityDropdownButton).toBeVisible();
await expect(visibilityDropdownButton.getByText(linkReach)).toBeVisible();
if (linkRole) {
const visibilityModeButton = page.getByRole('button', {
name: 'Visibility mode',
exact: true,
});
await expect(visibilityModeButton).toBeVisible();
await expect(page.getByText(linkRole)).toBeVisible();
}
};
@@ -0,0 +1,82 @@
import { Page, expect } from '@playwright/test';
import { getWaitForCreateDoc, randomName, updateDocTitle } from './common';
export const createRootSubPage = async (
page: Page,
browserName: string,
docName: string,
) => {
// Get add button
// Get response
const responsePromise = getWaitForCreateDoc(page);
await clickOnAddRootSubPage(page);
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
const subPageJson = (await response.json()) as { id: string };
// 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();
// 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();
};
export const createSubPageFromParent = async (
page: Page,
browserName: string,
parentId: string,
subPageName: string,
) => {
// Get parent doc tree item
const parentDocTreeItem = page.getByTestId(`doc-sub-page-item-${parentId}`);
await expect(parentDocTreeItem).toBeVisible();
await parentDocTreeItem.hover();
// Create sub page
const responsePromise = getWaitForCreateDoc(page);
await parentDocTreeItem.getByRole('button', { name: 'add_box' }).click();
// Get response
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
const subPageJson = (await response.json()) as { id: string };
// 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();
// Update sub page name
const subPageTitle = randomName(subPageName, browserName, 1)[0];
await updateDocTitle(page, subPageTitle);
// Return sub page data
return { name: subPageTitle, docTreeItem: subPageItem, item: subPageJson };
};
+1 -1
View File
@@ -12,7 +12,7 @@
"test:ui::chromium": "yarn test:ui --project=chromium"
},
"devDependencies": {
"@playwright/test": "1.52.0",
"@playwright/test": "1.53.1",
"@types/node": "*",
"@types/pdf-parse": "1.1.5",
"eslint-config-impress": "*",
+10 -2
View File
@@ -58,13 +58,22 @@ tokens.themes.default.components = {
...{
'la-gaufre': false,
'home-proconnect': false,
beta: false,
'image-system-filter': '',
favicon: {
ico: '/assets/favicon-light.ico',
'png-light': '/assets/favicon-light.png',
'png-dark': '/assets/favicon-dark.png',
},
button: {
...tokens.themes.default.components.button,
primary: {
...tokens.themes.default.components.button.primary,
...{
'background--disabled': 'var(--c--theme--colors--greyscale-100)',
},
disabled: 'var(--c--theme--colors--greyscale-400)',
},
},
},
};
@@ -84,7 +93,6 @@ const dsfrTheme = {
components: {
'la-gaufre': true,
'home-proconnect': true,
beta: true,
favicon: {
ico: '/assets/favicon-dsfr.ico',
'png-light': '/assets/favicon-dsfr.png',
+25 -23
View File
@@ -16,22 +16,24 @@
},
"dependencies": {
"@ag-media/react-pdf-table": "2.0.3",
"@blocknote/code-block": "0.31.1",
"@blocknote/core": "0.31.1",
"@blocknote/mantine": "0.31.1",
"@blocknote/react": "0.31.1",
"@blocknote/xl-docx-exporter": "0.31.1",
"@blocknote/xl-pdf-exporter": "0.31.1",
"@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",
"@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.7.0",
"@gouvfr-lasuite/ui-kit": "0.8.1",
"@hocuspocus/provider": "2.15.2",
"@openfun/cunningham-react": "3.1.0",
"@react-pdf/renderer": "4.3.0",
"@sentry/nextjs": "9.26.0",
"@tanstack/react-query": "5.80.5",
"@sentry/nextjs": "9.32.0",
"@tanstack/react-query": "5.81.2",
"canvg": "4.0.3",
"clsx": "2.1.1",
"cmdk": "1.1.1",
@@ -39,33 +41,33 @@
"docx": "9.5.0",
"emoji-mart": "5.6.0",
"i18next": "25.2.1",
"i18next-browser-languagedetector": "8.1.0",
"i18next-browser-languagedetector": "8.2.0",
"idb": "8.0.3",
"lodash": "4.17.21",
"luxon": "3.6.1",
"next": "15.3.3",
"posthog-js": "1.249.3",
"next": "15.3.4",
"posthog-js": "1.255.1",
"react": "*",
"react-aria-components": "1.9.0",
"react-aria-components": "1.10.1",
"react-dom": "*",
"react-i18next": "15.5.2",
"react-i18next": "15.5.3",
"react-intersection-observer": "9.16.0",
"react-select": "5.10.1",
"styled-components": "6.1.18",
"use-debounce": "10.0.4",
"styled-components": "6.1.19",
"use-debounce": "10.0.5",
"y-protocols": "1.0.6",
"yjs": "*",
"zustand": "5.0.5"
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.80.5",
"@tanstack/react-query-devtools": "5.81.2",
"@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": "29.5.14",
"@types/lodash": "4.17.17",
"@types/jest": "30.0.0",
"@types/lodash": "4.17.19",
"@types/luxon": "3.6.2",
"@types/node": "*",
"@types/react": "*",
@@ -74,11 +76,11 @@
"dotenv": "16.5.0",
"eslint-config-impress": "*",
"fetch-mock": "9.11.0",
"jest": "29.7.0",
"jest-environment-jsdom": "29.7.0",
"jest": "30.0.3",
"jest-environment-jsdom": "30.0.2",
"node-fetch": "2.7.0",
"prettier": "3.5.3",
"stylelint": "16.20.0",
"prettier": "3.6.1",
"stylelint": "16.21.0",
"stylelint-config-standard": "38.0.0",
"stylelint-prettier": "5.0.3",
"typescript": "*",
@@ -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,
@@ -0,0 +1,67 @@
import { Button, Modal, ModalSize } from '@openfun/cunningham-react';
import { useTranslation } from 'react-i18next';
import { Box } from '../Box';
import { Text } from '../Text';
export type AlertModalProps = {
isOpen: boolean;
onClose: () => void;
title: string;
description: string | React.ReactNode;
onConfirm: () => void;
confirmLabel?: string;
cancelLabel?: string;
};
export const AlertModal = ({
isOpen,
onClose,
title,
description,
onConfirm,
confirmLabel,
cancelLabel,
}: AlertModalProps) => {
const { t } = useTranslation();
return (
<Modal
isOpen={isOpen}
size={ModalSize.MEDIUM}
onClose={onClose}
title={
<Text $size="h6" $align="flex-start" $variation="1000">
{title}
</Text>
}
rightActions={
<>
<Button
aria-label={t('Close the modal')}
color="secondary"
fullWidth
onClick={() => onClose()}
>
{cancelLabel ?? t('Cancel')}
</Button>
<Button
aria-label={confirmLabel ?? t('Confirm')}
color="danger"
onClick={onConfirm}
>
{confirmLabel ?? t('Confirm')}
</Button>
</>
}
>
<Box
aria-label={t('Confirmation button')}
className="--docs--alert-modal"
>
<Box>
<Text $variation="600">{description}</Text>
</Box>
</Box>
</Modal>
);
};
@@ -1,4 +1,5 @@
import { PropsWithChildren, useRef, useState } from 'react';
import { HorizontalSeparator } from '@gouvfr-lasuite/ui-kit';
import { Fragment, PropsWithChildren, useRef, useState } from 'react';
import { css } from 'styled-components';
import { Box, BoxButton, BoxProps, DropButton, Icon, Text } from '@/components';
@@ -8,11 +9,13 @@ export type DropdownMenuOption = {
icon?: string;
label: string;
testId?: string;
value?: string;
callback?: () => void | Promise<unknown>;
danger?: boolean;
isSelected?: boolean;
disabled?: boolean;
show?: boolean;
showSeparator?: boolean;
};
export type DropdownMenuProps = {
@@ -23,6 +26,8 @@ export type DropdownMenuProps = {
buttonCss?: BoxProps['$css'];
disabled?: boolean;
topMessage?: string;
selectedValues?: string[];
afterOpenChange?: (isOpen: boolean) => void;
};
export const DropdownMenu = ({
@@ -34,6 +39,8 @@ export const DropdownMenu = ({
buttonCss,
label,
topMessage,
afterOpenChange,
selectedValues,
}: PropsWithChildren<DropdownMenuProps>) => {
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const [isOpen, setIsOpen] = useState(false);
@@ -41,6 +48,7 @@ export const DropdownMenu = ({
const onOpenChange = (isOpen: boolean) => {
setIsOpen(isOpen);
afterOpenChange?.(isOpen);
};
if (disabled) {
@@ -93,6 +101,9 @@ export const DropdownMenu = ({
$size="xs"
$weight="bold"
$padding={{ vertical: 'xs', horizontal: 'base' }}
$css={css`
white-space: pre-line;
`}
>
{topMessage}
</Text>
@@ -103,70 +114,76 @@ export const DropdownMenu = ({
}
const isDisabled = option.disabled !== undefined && option.disabled;
return (
<BoxButton
role="menuitem"
aria-label={option.label}
data-testid={option.testId}
$direction="row"
disabled={isDisabled}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onOpenChange?.(false);
void option.callback?.();
}}
key={option.label}
$align="center"
$justify="space-between"
$background={colorsTokens['greyscale-000']}
$color={colorsTokens['primary-600']}
$padding={{ vertical: 'xs', horizontal: 'base' }}
$width="100%"
$gap={spacingsTokens['base']}
$css={css`
border: none;
${index === 0 &&
css`
border-top-left-radius: 4px;
border-top-right-radius: 4px;
`}
${index === options.length - 1 &&
css`
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
`}
font-size: var(--c--theme--font--sizes--sm);
color: var(--c--theme--colors--greyscale-1000);
font-weight: 500;
cursor: ${isDisabled ? 'not-allowed' : 'pointer'};
user-select: none;
&:hover {
background-color: var(--c--theme--colors--greyscale-050);
}
`}
>
<Box
<Fragment key={option.label}>
<BoxButton
role="menuitem"
aria-label={option.label}
data-testid={option.testId}
$direction="row"
disabled={isDisabled}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onOpenChange?.(false);
void option.callback?.();
}}
key={option.label}
$align="center"
$justify="space-between"
$background={colorsTokens['greyscale-000']}
$color={colorsTokens['primary-600']}
$padding={{ vertical: 'xs', horizontal: 'base' }}
$width="100%"
$gap={spacingsTokens['base']}
$css={css`
border: none;
${index === 0 &&
css`
border-top-left-radius: 4px;
border-top-right-radius: 4px;
`}
${index === options.length - 1 &&
css`
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
`}
font-size: var(--c--theme--font--sizes--sm);
color: var(--c--theme--colors--greyscale-1000);
font-weight: 500;
cursor: ${isDisabled ? 'not-allowed' : 'pointer'};
user-select: none;
&:hover {
background-color: var(--c--theme--colors--greyscale-050);
}
`}
>
{option.icon && (
<Icon
$size="20px"
$theme="greyscale"
$variation={isDisabled ? '400' : '1000'}
iconName={option.icon}
/>
<Box
$direction="row"
$align="center"
$gap={spacingsTokens['base']}
>
{option.icon && (
<Icon
$size="20px"
$theme="greyscale"
$variation={isDisabled ? '400' : '1000'}
iconName={option.icon}
/>
)}
<Text $variation={isDisabled ? '400' : '1000'}>
{option.label}
</Text>
</Box>
{(option.isSelected ||
selectedValues?.includes(option.value ?? '')) && (
<Icon iconName="check" $size="20px" $theme="greyscale" />
)}
<Text $variation={isDisabled ? '400' : '1000'}>
{option.label}
</Text>
</Box>
{option.isSelected && (
<Icon iconName="check" $size="20px" $theme="greyscale" />
</BoxButton>
{option.showSeparator && (
<HorizontalSeparator withPadding={false} />
)}
</BoxButton>
</Fragment>
);
})}
</Box>
@@ -1,3 +1,4 @@
import { Loader } from '@openfun/cunningham-react';
import { useTranslation } from 'react-i18next';
import { Box } from './Box';
@@ -34,3 +35,9 @@ export const LoadMoreText = ({
</Box>
);
};
export const Loading = () => (
<Box $align="center" $justify="center" $height="100%">
<Loader />
</Box>
);
@@ -0,0 +1,63 @@
import { css } from 'styled-components';
import { Box } from '../Box';
import { DropdownMenu, DropdownMenuOption } from '../DropdownMenu';
import { Icon } from '../Icon';
import { Text } from '../Text';
export type FilterDropdownProps = {
options: DropdownMenuOption[];
selectedValue?: string;
};
export const FilterDropdown = ({
options,
selectedValue,
}: FilterDropdownProps) => {
const selectedOption = options.find(
(option) => option.value === selectedValue,
);
if (options.length === 0) {
return null;
}
return (
<DropdownMenu
selectedValues={selectedValue ? [selectedValue] : undefined}
options={options}
>
<Box
$css={css`
border: 1px solid
${selectedOption
? 'var(--c--theme--colors--primary-500)'
: 'var(--c--theme--colors--greyscale-250)'};
border-radius: 4px;
background-color: ${selectedOption
? 'var(--c--theme--colors--primary-100)'
: 'var(--c--theme--colors--greyscale-000)'};
gap: var(--c--theme--spacings--2xs);
padding: var(--c--theme--spacings--2xs) var(--c--theme--spacings--xs);
`}
color="secondary"
$direction="row"
$align="center"
>
<Text
$weight={400}
$variation={selectedOption ? '800' : '600'}
$theme={selectedOption ? 'primary' : 'greyscale'}
>
{selectedOption?.label ?? options[0].label}
</Text>
<Icon
$size="16px"
iconName="keyboard_arrow_down"
$variation={selectedOption ? '800' : '600'}
$theme={selectedOption ? 'primary' : 'greyscale'}
/>
</Box>
</DropdownMenu>
);
};
@@ -6,7 +6,7 @@ export * from './DropdownMenu';
export * from './Icon';
export * from './InfiniteScroll';
export * from './Link';
export * from './LoadMoreText';
export * from './Loading';
export * from './SideModal';
export * from './separators';
export * from './Text';
@@ -18,7 +18,7 @@ export const QuickSearchGroup = <T,>({
renderElement,
}: Props<T>) => {
return (
<Box $margin={{ top: 'base' }}>
<Box $margin={{ top: 'sm' }}>
<Command.Group
key={group.groupName}
heading={group.groupName}
@@ -44,7 +44,7 @@ export const QuickSearchInput = ({
$align="center"
className="quick-search-input"
$gap={spacingsTokens['2xs']}
$padding={{ all: 'base' }}
$padding={{ horizontal: 'base', vertical: 'sm' }}
>
{!loading && <Icon iconName="search" $variation="600" />}
{loading && (
@@ -56,6 +56,9 @@ export const QuickSearchInput = ({
/* eslint-disable-next-line jsx-a11y/no-autofocus */
autoFocus={true}
aria-label={t('Quick search input')}
onClick={(e) => {
e.stopPropagation();
}}
value={inputValue}
role="combobox"
placeholder={placeholder ?? t('Search')}
@@ -24,7 +24,7 @@ export const QuickSearchItemContent = ({
<Box
$direction="row"
$align="center"
$padding={{ horizontal: '2xs', vertical: '3xs' }}
$padding={{ horizontal: '2xs', vertical: '4xs' }}
$justify="space-between"
$width="100%"
>
@@ -65,9 +65,7 @@ export const QuickSearchStyle = createGlobalStyle`
[cmdk-list] {
padding: 0 var(--c--theme--spacings--base) var(--c--theme--spacings--base)
var(--c--theme--spacings--base);
flex:1;
overflow-y: auto;
overscroll-behavior: contain;
@@ -1,4 +1,5 @@
import { useCunninghamTheme } from '@/cunningham';
import { Spacings } from '@/utils';
import { Box } from '../Box';
@@ -10,19 +11,25 @@ export enum SeparatorVariant {
type Props = {
variant?: SeparatorVariant;
$withPadding?: boolean;
customPadding?: Spacings;
};
export const HorizontalSeparator = ({
variant = SeparatorVariant.LIGHT,
$withPadding = true,
customPadding,
}: Props) => {
const { colorsTokens } = useCunninghamTheme();
const padding = $withPadding
? (customPadding ?? 'base')
: ('none' as Spacings);
return (
<Box
$height="1px"
$width="100%"
$margin={{ vertical: $withPadding ? 'base' : 'none' }}
$margin={{ vertical: padding }}
$background={
variant === SeparatorVariant.DARK
? '#e5e5e533'

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