Compare commits

...

414 Commits

Author SHA1 Message Date
Cyril
0144044c55 (frontend) add pdf export regression test with embedded image fixtures
adds base64 png/jpg/svg image fixtures to stabilize pdf export snapshots

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-01 11:46:43 +01:00
Cyril
a6da37e231 (frontend) add test to compare generated PDF against reference template
ensures no regression by validating PDF content matches the expected pattern

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-01 11:46:31 +01:00
Anthony LC
9aeedd1d03 ️(frontend) improve UploadFile process
We notices that `context.getChanges` was very
greedy, on a large document with multiple
users collaborating, it caused performance issues.
We change the way that we track a upload by
listening onUploadEnd event instead of tracking
all changes in the document.
When a doc opens, we check if there are any ongoing
uploads and resume them.
We fix as well a race condition that could happen
when multiple collaborators were on a document
during an upload.
2025-12-01 10:31:46 +01:00
Anthony LC
f7d4e6810b ️(frontend) enhance Table of Contents
- the Table of Contents stickiness now covers the
full height of the viewport, before it was limited to
100vh
- we listen the scroll to highlight the heading
in the Table of Contents only when the Table of Contents
is open
- We debounce the editor change to avoid excessive updates
to the Table of Contents
2025-12-01 10:31:45 +01:00
Anthony LC
b740ffa52c 📌(frontend) Bump body-parser from 2.2.0 to 2.2.1
Bumps [body-parser](https://github.com/expressjs/body-parser)
from 2.2.0 to 2.2.1.
- [Release notes](https://github.com/expressjs/body-parser/releases)
- [Changelog](https://github.com/expressjs/body-parser/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/body-parser/compare/v2.2.0...v2.2.1)

---
updated-dependencies:
- dependency-name: body-parser
  dependency-version: 2.2.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-01 09:52:23 +01:00
Anthony LC
f555e36e98 ⬆️(dependencies) Jump js-yaml from 3.14.1 to 3.14.2
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 3.14.1 to 3.14.2.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/3.14.1...3.14.2)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 3.14.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-01 09:51:11 +01:00
Anthony LC
de11ab508f ⬆️(dependencies) Bump glob from 10.4.5 to 10.5.0 in /src/mail
Bumps [glob](https://github.com/isaacs/node-glob) from 10.4.5 to 10.5.0.
- [Changelog](https://github.com/isaacs/node-glob/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/node-glob/compare/v10.4.5...v10.5.0)

---
updated-dependencies:
- dependency-name: glob
  dependency-version: 10.5.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-01 09:11:53 +01:00
Anthony LC
dc2fe4905b ⬆️(frontend) upgrade some gitHub actions
Some github actions were using outdated versions.
This commit upgrades them to use a common versions
for all our workflows.
2025-11-28 17:36:16 +01:00
Anthony LC
2864669dde 🚸(frontend) do not show comments button on resources
The comments does not seems to work on
resources (images, pdf, ...), so we hide the button
when the selected block is not a text block.
2025-11-26 12:03:09 +01:00
Anthony LC
7dae3a3c02 🍱(frontend) Docs icon overridable from theme
Depend the theme, you can have different Docs icons
in the header.
A customization was already possible from the
theme customization file, but now it is as
well possible to override the icon from the theme
itself, making it easier to manage different themes
with different icons.
We change the theme customization variable name
to "icon" instead of "logo", "logo" was already
used for the main logo of the application inside
the theme configuration.
2025-11-26 12:03:09 +01:00
Anthony LC
bdf62e2172 🔥(frontend) remove button delete invitation
We can already remove a invitation directly from
the role dropdown, so having a delete button in the
more actions menu is redundant.
2025-11-26 12:03:09 +01:00
Anthony LC
29104dfe2d 💥(frontend) upgrade to ui-kit v2
Upgrade Docs to Ui-Kit v2 and apply new color
scheme from LaSuite design system.
This commit will probably create breaking changes if
user has custom styles applied to their docs.
2025-11-26 11:32:18 +01:00
Anthony LC
785c9b21cf ️(sw) stop to cache external resources likes videos
Some videos from external sources can be very
large and slow to cache. To improve performance, we
decided to stop caching these resources in the
service worker.
We will cache only images and fonts from external
sources.
The videos will maybe not be available when offline
mode.
2025-11-26 10:27:15 +01:00
Cyril
3fee1f2081 (frontend) prevent duplicate emoji when used as first char in title
ensures icon and title are visually distinct in sub-document headers

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-25 15:38:30 +01:00
Cyril
5f9968d81e (frontend) improve screen reader support in DocShare modal
adds relevant aria-labels to enhance accessibility for assistive technologies

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-25 15:02:36 +01:00
Anthony LC
f7baf238e3 🐛(frontend) fix toolbar not activated when reader
When user was a reader of the document, the toolbar
of the BlockNote editor was not activated,
making it impossible to download resources like images.
We add the toolbar even in viewer mode.
We block as well automatic document mutation
from custom blocks when the editor is in viewer mode
to avoid unwanted modifications.
2025-11-25 12:58:26 +01:00
renovate[bot]
bab42efd08 ⬆️(dependencies) update @sentry/nextjs to v10.27.0 [SECURITY] 2025-11-24 21:59:02 +00:00
Jacques ROUSSEL
175d80db16 ♻️(helmfile) rename helmfile
Rename helmfile to helmfile.yaml.gotmpl to be compatible with helmfile
0.162 and 1.1.9
2025-11-20 15:13:55 +01:00
Manuel Raynaud
f8b8390758 ♻️(backend) UserSerializer fallback strategy from UserLightSerializer
In the UserLightSerializer we were fallbacking on a strategy to never
have a full_name or short_name empty. We use the part of the email
befire the @. We are doing the same thing now in the main
UserSerializer.
2025-11-20 14:41:48 +01:00
Anthony LC
a1463e0a10 🐛(frontend) fix button markdown not visible
On smaller screens, the markdown button in the
toolbar was not every time visible.
We fix this issue.
2025-11-20 14:41:48 +01:00
Anthony LC
0b555eed9f ♻️(frontend) replace default comment toolbar button
Replace the default comment toolbar button with
a custom one to follow the design system.
2025-11-20 14:41:47 +01:00
Anthony LC
1bf810d596 ♻️(frontend) add user avatar to thread comments
We extracted the UserAvatar component from the
doc-share feature and integrated it into
the users feature. It will be used in the
thread comments feature as well.
2025-11-20 14:41:47 +01:00
Anthony LC
48e1370ba3 (frontend) add comments feature
Implemented the comments feature for the document
editor.
We are now able to add, view, and manage comments
within the document editor interface.
2025-11-20 14:41:47 +01:00
Anthony LC
b13571c6df (backend) implement thread and reactions API
In order to use comment we also have to implement a thread and reactions
API. A thread has multiple comments and comments can have multiple
reactions.
2025-11-20 14:41:47 +01:00
Manuel Raynaud
a2a63cd13e (backend) add comment viewset
This commit add the CRUD part to manage comment lifeycle. Permissions
are relying on the Document and Comment abilities. Comment viewset
depends on the Document route and is added to the
document_related_router. Dedicated serializer and permission are
created.
2025-11-20 14:20:06 +01:00
Manuel Raynaud
3ebb62d786 (backend) add Comment model
In order to store the comments on a document, we created a new model
Comment. User is nullable because anonymous users can comment a Document
is this one is public with a link_role commentator.
2025-11-20 14:20:06 +01:00
Manuel Raynaud
0caee61d86 (backend) add commentator role
To allow a user to comment a document we added a new role: commentator.
Commentator is higher than reader but lower than editor.
2025-11-20 14:20:06 +01:00
Cyril
10a319881d (frontend) preserve left panel width on window resize
prevents automatic resizing to keep user-defined width stable

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-20 10:07:17 +01:00
Cyril
26620f3471 (frontend) improve share modal button accessibility
Added aria-labels to remove and invite buttons

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-20 08:28:18 +01:00
Anthony LC
0d0e17c8d5 🔖(minor) release 3.10.0
Added:
- (frontend) enable ODT export for documents
- (frontend) improve mobile UX by showing subdocs count

Changed:
- ♻️(frontend) preserve @ character when esc is pressed
  after typing it
- ♻️(frontend) make summary button fixed to remain visible
  during scroll
- ♻️(frontend) pdf embed use full width

Fixed:
- (frontend) improve accessibility:
  - (frontend) improve ARIA in doc grid and editor
    for a11y
  - (frontend) improve accessibility and styling
    of summary table
  - (frontend) add focus trap and enter key support
    to remove doc modal
- 🐛(docx) fix image overflow by limiting width to
  600px during export
- 🐛(frontend) fix fallback translations with Trans
- 🐛(pdf) fix table cell alignment issue in exported
  documents
- 🐛(pdf) preserve image aspect ratio in PDF export
2025-11-19 14:48:08 +01:00
Anthony LC
257de6d068 📌(frontend) pin Blocknote to 0.42.3
We were pinned to a specific package registry
coming from a pull request, the new version is now
published to npm, so we can use the version from there.
2025-11-19 14:48:08 +01:00
AntoLC
5a4c02a978 🌐(i18n) update translated strings
Update translated files with new translations
2025-11-19 12:26:04 +01:00
Manuel Raynaud
0090ccc981 🔥(backend) remove api managing templates
A complete API was able to manage templates lifecycle, from the creation
to the deletion and managing accesses on them. This API is not used by
the frontend application, is not finished. A connected user can interact
with this API and lead to unwanted behavior in the interface. Refering
ot issue #1222 templates can maybe totaly remove in the future. While
it's here and used, we only keep list and retrive endpoints. The
template management can still be done in the admin interface.
2025-11-19 11:23:31 +00:00
Anthony LC
d403878f8c 🐛(frontend) fix alignment of side menu
Recent refactoring moved the side menu position.
This commit fixes its alignment.
2025-11-19 10:39:35 +01:00
Anthony LC
191b046641 📌(frontend) pin Blocknote to fix mounting issue
We have a mounting issue with the Blocknote
version 0.42.1. To not wait for the next Blocknote
release, we pin the Blocknote packages
to a specific pull request version.
2025-11-19 10:05:25 +01:00
Anthony LC
aeac49d760 🔥(frontend) remove fix about color in export
The last version of Blocknote fixed the issue
with colors in exports.
We can remove our workaround now.
2025-11-18 16:19:56 +01:00
Anthony LC
b5dcbbb057 ⬆️(frontend) upgrade Blocknote to 0.42.1
We upgraded Blocknote to version 0.42.1 to benefit
from the latest features and bug fixes.
There is some compatibility issues with
tiptap extensions, so we had to pin tiptap
to "3.10.2" to ensure everything works smoothly.
There is as well some issues with prosemirror,
we pinned the problematic packages.
2025-11-18 16:19:56 +01:00
Cyril
2e64298ff4 (pdf) preserve image aspect ratio in PDF export
images were distorted in PDF exports; height is now computed to fix that

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-18 15:36:42 +01:00
Manuel Raynaud
8dad9ea6c4 🔧(helm) create a symlink between helmfile config
We want to have both helmfile.yaml and helmfile.gotmpl present.
helmfile.gotmpl is for now a symlink of helmfile.yaml
2025-11-18 11:10:03 +00:00
Anthony LC
3ae8046ffc 🐛(frontend) fix fallback translations with Trans
The fallback of the Trans component didn't work
anymore after upgrade to 16.2.3 of react-i18next.
Upgrading to 16.3.3 fixed the issue.
We added a test to cover this case.
2025-11-17 15:23:48 +01:00
Anthony LC
a4e3168682 🐛(export) fix export unsupported colors
Some colors bind to a text style are not supported.
It comes often from a paste style, we don't
display them if they are not supported by the
editor.
2025-11-17 10:33:17 +01:00
renovate[bot]
c8955133a4 ⬆️(dependencies) update python dependencies 2025-11-17 08:29:46 +00:00
Anthony LC
b069310bf0 🛂(frontend) disabled role not allowed to be assigned
We disable roles that the current user is not allowed
to assign when sharing a document. This prevents
users from selecting roles they cannot actually
assign, improving the user experience and reducing
confusion.
2025-11-17 08:48:16 +01:00
Manuel Raynaud
1292c33a58 ♻️(backend) rely on set_role_to from DocumentAskForAccess abilities
Like in other abilities, we compute a set_role_to property on the
abilities. This set_role_to contains all the roles lower or equal than
the current user role. We rely on this propoerty to validate the accept
endpoint and it will be used by the front allpication to built the role
select list.
2025-11-17 08:48:16 +01:00
Manuel Raynaud
bf68a5ae40 🔒️(backend) remove owner as valid role for ask_for_access serializer
When a ask_for_access creation is made, we explicitly remove the owner
role to prevent role escalation.
2025-11-17 08:47:15 +01:00
Manuel Raynaud
8799b4aa2f 🔒️(backend) role in ask_for_access must be lower than user role
We check that the role set in a ask_for_access is not higher than the
user's role accepting the request. We prevent case where ad min will
grant a user owner in order to take control of the document. Only owner
can accept an owner role.
2025-11-17 08:47:14 +01:00
Cyril
d96abb1ccf (frontend) make summary button fixed to remain visible during scroll
ensures persistent access to table of contents by fixing button position

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-14 13:42:56 +01:00
Cyril
dc12a99d4a (pdf) fix table cell alignment issue in exported documents
ensures correct horizontal alignment of text based on cell props

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-14 13:10:03 +01:00
Cyril
82a0c1a770 (frontend) add unit test for mobile rendering in docheaderinfo
ensures numchild count is displayed correctly on mobile interface

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-13 13:41:35 +01:00
Cyril
a758254b60 (frontend) improve mobile UX by showing subdocs count
helps users notice root documents have children in mobile view

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-13 13:41:25 +01:00
Cyril
6314cb3a18 (frontend) add focus trap and enter key support to remove doc modal
improves a11y by enabling keyboard-triggered modal with proper focus trap

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-13 12:59:11 +01:00
Cyril
3e410e3519 (frontend) enable ODT export for documents
provides ODT export with support for callout, upload, interlinking and tests

Signed-off-by: Cyril <c.gromoff@gmail.com>

(frontend) add image and interlinking support for odt export

Added image mapping with SVG conversion and clickable document links.

Signed-off-by: Cyril <c.gromoff@gmail.com>

(e2e) add e2e tests for odt export and interlinking features

covers odt document export and cross-section interlinking use cases

Signed-off-by: Cyril <c.gromoff@gmail.com>

(odt) add generic helper and style callout block for odt export

create odtRegisterParagraphStyleForBlock and apply background/padding styles

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-13 11:14:09 +01:00
Cyril
aba7959344 (frontend) fix pdf embed to use full width
Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-12 11:28:10 +01:00
Cyril
3d45c7c215 (frontend) improve accessibility and styling of summary table
adds semantic structure, aria attributes, and token-based focus styling

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-12 10:02:05 +01:00
Cyril
cdb26b480a (frontend) preserve @ character when esc is pressed after typing it
improves user experience by keeping @ symbol after cancelling mention trigger

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-12 09:24:14 +01:00
Cyril
23a0f2761f (docx) fix image overflow by limiting width to 600px during export
ensures all images keep proportions and stay within page bounds in docx export

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-12 08:40:05 +01:00
Cyril
0d596e338c (frontend) move editor button out of grid and fix roles/aria-label
improves accessibility and layout consistency of editor interface

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-11-10 18:13:33 +01:00
Anthony LC
3ab01c98c8 🔖(minor) release 3.9.0
Added
- (frontend) create skeleton component for DocEditor
- (frontend) add an EmojiPicker in the document
tree and title
- (frontend) ajustable left panel

Changed:
- ♻️(frontend) adapt custom blocks to new implementation
- ♻️(backend) increase user short_name field length
- 🚸(frontend) separate viewers from editors

Fixed:
- 🐛(frontend) fix duplicate document entries in grid
- 🐛(backend) fix trashbin list
- (frontend) improve accessibility:
  - (frontend) remove empty alt on logo due to Axe
  a11y error
- 🐛(backend) fix s3 version_id validation
- 🐛(frontend) retry check media status after page reload
- 🐛(frontend) fix Interlinking memory leak
- 🐛(frontend) button new doc UI fix
- 🐛(frontend) interlinking UI fix
2025-11-10 11:35:03 +01:00
AntoLC
6445c05e29 🌐(i18n) update translated strings
Update translated files with new translations
2025-11-10 11:35:03 +01:00
renovate[bot]
b9b25eb1f6 ⬆️(dependencies) update django to v5.2.8 [SECURITY] 2025-11-10 11:19:53 +01:00
renovate[bot]
de157b4f52 ⬆️(dependencies) update Brotli to v1.2.0 [SECURITY] 2025-11-10 11:19:53 +01:00
Anthony LC
e5581e52f7 ♻️(frontend) better handling css doc states
We simplify the way we handle different doc
states (deleted / readonly) in the CSS, we avoid
props drilling and are more component focused.
2025-11-07 15:31:05 +01:00
Anthony LC
b91840c819 🩹(frontend) set correctly query data when 401
When receiving a 401 error, we should set the
auth query data to null, not to an object
with user: null and authenticated: false.
This ensures that components relying on the
auth state can correctly interpret the
unauthenticated status.
2025-11-07 09:54:10 +01:00
Anthony LC
a9b77fb9a7 💄(frontend) visual improvements around the Icon
With time some visual inconsistencies have crept
into the DropButton and Icon component.
This commit aims to harmonize the appearance
with the design system.
2025-11-07 09:25:48 +01:00
Anthony LC
66f83db0e5 (frontend) add "Add Emoji" button to doc options menu
- Add "Add Emoji" button to doc options menu
- Remove default emoji when none selected
- Improve doc options styling
2025-11-07 09:24:58 +01:00
Anthony LC
f9ff578c6b 🥅(frontend) improve error handling during upload
Catch and log errors when replacing blocks during
file upload.
2025-11-06 13:21:36 +01:00
Anthony LC
1372438f8e 🐛(frontend) fix memory leak in Interlinking
When doing collaborative editing, doc?.title might
be out of sync for other users when updated by
another user.
This causes the useEffect to run repeatedly,
causing an infinite loop of updates.
We now trigger the effect only when doc?.title changes,
not when the customInlineContent changes.
2025-11-06 13:21:36 +01:00
Anthony LC
c5d5d3dec4 ️(frontend) improve unsubscribe logic when unmounting components
We can now unsubscribe on the editor events,
improving performance and preventing memory leaks.
2025-11-06 13:21:35 +01:00
Anthony LC
ad16c0843c (test) adapt tests with updated dependencies
- update e2e tests to match changed function signatures
- remove unused pdf-parse type definitions
- fix type error in hocuspocusWS tests
2025-11-06 12:43:34 +01:00
renovate[bot]
78a6307656 ⬆️(dependencies) update js dependencies 2025-11-05 16:17:04 +01:00
Anthony LC
d7d468f51f ✏️(frontend) fix typo listInvalidQueries
In many places the property name "listInvalidQueries"
was misspelled.
2025-11-05 12:23:35 +01:00
Anthony LC
eb71028f6b 🚸(frontend) separate viewers from editors
We are now totally separating the viewers with
the editors. We will not load the provider
when we are in viewer mode, meaning the
viewers will not be aware of other users and
will not show their cursors anymore.
We still get the document updates in real-time.
2025-11-05 12:23:35 +01:00
Anthony LC
39c22b074d 🚚(frontend) better separation concern doc-versioning features
We move some components related to doc versioning
into the doc-versioning feature folder to have a
better separation of concerns.
We don't need a provider for the doc versioning components
since they will receive the doc data directly via
a request.
2025-11-05 12:09:20 +01:00
Anthony LC
d5c3f248a5 🐛(frontend) fix flaky test
Depend the month the test could fail if the current
month has 30 or 31 days, so change 30 to 35 to be sure
that it will always be at least 1 month ago.
2025-11-04 10:28:48 +01:00
Anthony LC
91217b3c4f 🐛(frontend) retry check media status after page reload
Previous refactoring removed the retry logic for
checking media status after a page reload.
This commit reintroduces that functionality
to ensure uploads are properly processed even after
a page reload. We improve the test coverage
to validate this behavior.
2025-11-04 10:28:48 +01:00
Manuel Raynaud
ab271bc90d (backend) fix test_backend authentication test suite
The last version of django-lasuite introduces a modification on the
query made. We have to update the tests because there is one more sql
query made.
2025-10-30 15:30:12 +01:00
Manuel Raynaud
82e1783317 📌(backend) pint pylint to version < 4.0.0
django-pylint is not compatible with pylint 4. We have to pin the pylint
version while the compatibility is not effective.
See https://github.com/pylint-dev/pylint-django/issues/467
2025-10-30 15:30:12 +01:00
renovate[bot]
aa2b9ed5f2 ⬆️(dependencies) update python dependencies 2025-10-30 15:30:12 +01:00
Manuel Raynaud
1c96d645ba 🐛(backend) fix s3 version_id validation
The regex used on the version_detail endpoint path is not fully
compatible with the S3 spec. In the S3 specs, Version IDs are Unicode,
UTF-8 encoded, URL-ready, opaque strings that are no more than 1,024
bytes long. We don't accept all unicode characters but enough to be
compliant.
2025-10-30 15:29:11 +01:00
Cyril
2f010cf36d (frontend) set empty alt on logo due to Axe a11y error
image is decorative; alt was redundant with link aria-label

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-10-27 07:34:14 +01:00
Olivier Laurendeau
9d3c1eb9d5 🐛(frontend) emoji-picker fix lack of overlay
The EmojiPicker component now displays an overlay
when opened, it fixes an issue when multiple pickers
are present on the same page and we click on one of them,
the others were not closing.
2025-10-23 17:29:45 +02:00
Olivier Laurendeau
08f3ceaf3f (frontend) add EmojiPicker in DocumentTitle
We can now add emojis to the document title using
the EmojiPicker component.
2025-10-23 17:29:45 +02:00
Olivier Laurendeau
b1d033edc9 🩹(frontend) handle properly emojis in interlinking
Emoji in interlinking were not replacing
the default icon when present.
2025-10-23 17:29:18 +02:00
Olivier Laurendeau
192fa76b54 (frontend) can remove emoji in the tree item actions
Add action button to remove emoji
from a document title from the document tree.
2025-10-23 17:29:18 +02:00
Olivier Laurendeau
b667200ebd (frontend) add an EmojiPicker in the document tree
This allows users to easily add emojis easily to
their documents from the tree, enhancing the
overall user experience.
2025-10-23 17:29:17 +02:00
Olivier Laurendeau
294922f966 🩹(frontend) do not display emoji as page icon on main pages
We decided to not display the leading emoji
as page icon on the main pages to keep consistency
in the document list.
2025-10-23 17:29:17 +02:00
Cyril
8b73aa3644 (frontend) create skeleton feature
creating a skeleton to be display during doc creation

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-10-23 14:41:09 +02:00
Anthony LC
dd56a8abeb 🐛(backend) fix trashbin list
Fix listing of deleted documents in trashbin for
users without owner access
2025-10-23 12:03:31 +02:00
Anthony LC
145c688830 🐛(frontend) fix lost content during sync
The tests e2e highlighted a problem where content
was lost during synchronization. This bug
started to occurs after upgrading Blocknote to
0.41.1 version.
It seems to happen only when the initial document
is empty and 2 users are collaborating, so before
the first minute.
We now initialize the editor only when the y-doc
has attempted to sync. This should ensure that
all updates are applied before the editor
is initialized.
2025-10-22 14:47:11 +02:00
Anthony LC
950d215632 🚸(frontend) fresh data on share modal open
When we open the share modal, the requests were
then in cache, if other users where interacting
with the share settings in parallel,
we would not see the changes until the cache expired.
We now force a fresh fetch of the data when opening
the share modal, it ensures we always have the
latest data when opening the modal.
2025-10-22 14:47:11 +02:00
Anthony LC
7d5cc4e84b 🚚(frontend) move useUpdateDocLink to doc-share feature
Move the `useUpdateDocLink` hook from the
`doc-management` feature to the `doc-share` feature
to better align with its functionality related
to document sharing.
2025-10-22 14:47:11 +02:00
Anthony LC
3e5bcf96ea ⬆️(y-provider) update hocuspocus to 3.2.5
The last version of Blocknote seems to have a
conflict with hocuspocus 2.15.2, it is a good
moment to upgrade to hocuspocus 3.2.5.
2025-10-22 14:47:10 +02:00
Anthony LC
fe24c00178 ♻️(frontend) adapt custom blocks to new implementation
Last release of Blocknote introduced breaking
changes for custom blocks.
We adapted our custom blocks to the new
implementation.
"code-block" is considered as a block now, we
update the way to import and use it.
The custom blocks should be now more tiptap friendly.
2025-10-22 13:53:55 +02:00
Anthony LC
aca334f81f 🔥(frontend) remove custom DividerBlock
Blocknote now has a built-in divider block, so we
can remove our custom implementation.
2025-10-22 13:52:34 +02:00
Anthony LC
2003e41c22 🚨(frontend) adapt signatures to @tanstack/react-query to >5.90
Recent upgrade of @tanstack/react-query to
version >5.90 introduced a breaking change in the
onSuccess and onError callback signatures for
the useMutation hook.
The context parameter has been replaced with an
onMutateResult parameter, which provides
information about the result of the
onMutate callback.
2025-10-22 13:52:34 +02:00
Anthony LC
5ebdf4b4d4 ⬇️(dependencies) downgrade to cunningham 3.2.3
Version 4.0.0 is not yet compatible with UiKit,
better to wait.
2025-10-22 13:52:34 +02:00
renovate[bot]
35e771a1ce ⬆️(dependencies) update js dependencies 2025-10-22 13:52:33 +02:00
Manuel Raynaud
2b5a9e1af8 ♻️(backend) increase user short_name field length
The user's short_name field length was set to 20. This is not enought
and we have some users who cannot register because of that. We changed
this length to a higher one, 100, like the full_name.
2025-10-22 11:44:39 +02:00
Cyril
a833fdc7a1 (frontend) add resizable left panel on desktop with persistence
mainlayout and leftpanel updated with resizable panel saved in localstorage

Signed-off-by: Cyril <c.gromoff@gmail.com>

(frontend) show full nested doc names with horizontal scroll support

horizontal overflow enabled and opacity used for sticky actions visibility

Signed-off-by: Cyril <c.gromoff@gmail.com>

(frontend) show full nested doc names with horizontal scroll support

horizontal overflow enabled and opacity used for sticky actions visibility

Signed-off-by: Cyril <c.gromoff@gmail.com>

(frontend) add resizable-panels lib also used in our shared ui kit

needed for adaptable ui consistent with our shared ui kit components

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-10-21 10:59:24 +02:00
Anthony LC
b3cc2bf833 🚨(eslint) add missing rules
We recently upgraded to Eslint v9, it seems that
it is missing some rules that we had previously.
We add them back:
- @typescript-eslint/no-inferrable-types
- @typescript-eslint/no-floating-promises
2025-10-20 21:53:10 +02:00
Anthony LC
18feab10cb (e2e) reduce flakinees
- Because of parallel test execution, some tests
were flaky when using goToGridDoc, the title
changed between the time we got the document list
and the time we clicked on the document.
- Improve addChild function.
2025-10-20 10:17:03 +02:00
Anthony LC
2777488d24 🐛(frontend) fix duplicate document entries in grid
The tests e2e were failing sometimes because
the documents list was containing duplicates.
This was happening when multiple users were
modifying the documents list (creation, update, ...).
We now deduplicate documents by their ID
before displaying them.
2025-10-20 10:17:03 +02:00
Anthony LC
a11258f778 🔖(patch) release 3.8.2
Fixed:

- 🐛(service-worker) fix sw registration and page reload
  logic
2025-10-17 15:54:56 +02:00
Anthony LC
33647f124f 🐛(service-worker) fix sw registration and page reload logic
When a new service worker is installed, the page
was reloaded to ensure the new service worker took
control, it is not a big issue in normal browsing mode
because the service worker is only updated once in a
while (every release).
However, in incognito mode, the service worker has to be
re-registered on each new session, which means that
the page was reloading each time the user opened a
new incognito window, creating a bad user experience.
We now take in consideration the case where the
service-worker is installed for the first time, and don't
reload if it is this case.
2025-10-17 15:14:04 +02:00
Anthony LC
e339cda5c6 🔖(patch) release 3.8.1
Fixed:
- ️(backend) improve trashbin endpoint performance
- 🐛(backend) manage invitation partial update without email
- (frontend) improve accessibility:
  -  add missing aria-label to add sub-doc button
  for accessibility
  -  add missing aria-label to more options button
  on sub-docs

Removed:
- 🔥(backend) remove treebeard form for the document admin
2025-10-17 10:41:38 +02:00
Manuel Raynaud
4ce65c654f 🔥(backend) remove treebeard form for the document admin
The document change admin page is unusable. The django treebeard library
can change the form used by one provided but this one is really slow.
And it is collapsing the configuration made with the other fields and
readonly fields declared on the DocumentAdmin class. In a first time we
remove the form usage, it seems useless. Later we have to provide more
information on this admin page.
2025-10-17 08:35:22 +00:00
Manuel Raynaud
c048b2ae95 🐛(backend) manage invitation partial update without email
An invitation can be updated to change its role. The front use a PATCH
sending only the changed role, so the email is missing in the
InivtationSerializer.validate method. We have to check first if an email
is present before working on it.
2025-10-16 15:26:02 +00:00
Manuel Raynaud
5908afb098 ️(backend) improve trashbin endpoint performance (#1495)
The trashbin endpoint is slow. To filter documents the user has owner
access, we use a subquery to compute the roles and then filter on this
subquery. This is very slow. To improve it, we use the same way to
filter children used in the tree endpoint. First we look for all highest
ancestors the user has access on with the owner role. Then we create one
queryset filtering on all the docs starting by the given path and are
deleted.
2025-10-16 17:06:47 +02:00
Cyril
e2298a3658 (frontend) add missing aria-label to more options button on sub-docs
improves accessibility by making the options button screen reader friendly

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-10-16 15:02:04 +02:00
Cyril
278eb233e9 (frontend) add missing aria-label to add sub-doc button for a11y
improves screen reader support for the add sub-doc action in the document tree

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-10-16 14:18:55 +02:00
Anthony LC
b056dbfad4 🔖(minor) release 3.8.0
Added:
- (frontend) add pdf block to the editor
- List and restore deleted docs

Changed:
- ♻️(frontend) Refactor Auth component for improved redirection logic
- ♻️(frontend) replace Arial font-family with token font
- (frontend) improve accessibility:
  - (frontend) enable enter key to open documentss
  - (frontend) improve modal a11y: structure, labels, title
  - improve NVDA navigation in DocShareModal
  -  improve accessibility by adding landmark roles to layout
  -  add document visible in list and openable via enter key
  -  add pdf outline property to enable bookmarks display
  -  hide decorative icons from assistive tech with aria-hidden
  -  fix rgaa 1.9.1: convert to figure/figcaption structure
  -  remove redundant aria-label to avoid over-accessibility
  -  remove redundant aria-label on hidden icons and update tests
  -  improve semantic structure and aria roles of leftpanel
  -  add default background to left panel for better accessibility
  -  restyle checked checkboxes: removing strikethrough
  -  add h1 for SR on 40X pages and remove alt texts
  -  update labels and shared document icon accessibility
- 🍱(frontend) Fonts GDPR compliants
- ♻️(service-worker) improve SW registration and update handling

Fixed:
- 🐛(backend) duplicate sub docs as root for reader users
- ⚗️(service-worker) remove index from cache first strategy
- 🐛(frontend) fix 404 page when reload 403 page
- 🐛(frontend) fix legacy role computation
- 🛂(frontend) block editing title when not allowed
- 🐛(frontend) scroll back to top when navigate to a document
- 🐛(frontend) fix export pdf emoji problem
- 🐛(frontend) fix attachment download filename
- 🐛(frontend) exclude h4-h6 headings from table of contents
- 🔒(frontend) prevent readers from changing callout emoji
- 🐛(frontend) fix overlapping placeholders in multi-column layout
- 🐛(backend) filter invitation with case insensitive email
- 🐛(frontend) reduce no access image size from 450 to 300
- 🐛(frontend) preserve interlink style on drag-and-drop in editor
- (frontend) load docs logo from public folder via url
- 🔧(keycloak) Fix https required issue in dev mode
2025-10-14 17:12:45 +02:00
AntoLC
771ef2417f 🌐(i18n) update translated strings
Update translated files with new translations
2025-10-14 17:12:45 +02:00
Anthony LC
8d5262c2f2 (e2e) fix flaky tests
Some tests were flaky, we improved their stability.
2025-10-14 16:55:51 +02:00
Anthony LC
1125f441dc 🔇(e2e) remove unnecessary console log
A test had a console log that was not useful.
2025-10-14 14:48:54 +02:00
Anthony LC
16f2de4c75 ♻️(frontend) logo theme customization optional
To not create a breaking change, the logo in the
theme customization is now optional, meaning that
if no logo is provided, the default logo will be used.

We add some documentation about this feature.
2025-10-14 14:48:54 +02:00
Anthony LC
f19fa93600 💄(frontend) fix gap and alignment icons
Fix some gaps and alignment of the icons.
2025-10-14 14:48:54 +02:00
Anthony LC
af3d90db3b 🐛(frontend) fix double scrollbar on document grid
The document grid was showing a double scrollbar.
It was due to the sr-only class having a width and
height of 1px.
We changed it to 0px, it is now fixed.
2025-10-14 14:48:54 +02:00
Anthony LC
127c90ca5f 🐛(frontend) fix circular dependency problems
A circular dependency was introduced in the previous
commit.
This commit resolves the circular dependency by
refactoring the code to remove the circular reference.
2025-10-14 08:56:12 +02:00
Anthony LC
fa7cf7a594 💄(frontend) add a theme focus visible on BoxButton
We want to improve the accessibility of our BoxButton
component by adding a theme focus visible style.
This will help users who navigate using the
keyboard to easily identify which button is currently
focused.
To do so we have to move some theme styles to
the Box component to be able to use them in
BoxButton.
2025-10-14 08:56:12 +02:00
Anthony LC
6523165ea0 (frontend) doc page when deleted
Whe the doc is deleted, the doc page is a bit
different, we have to adapt the doc header
to add some information and actions that
are relevant for a deleted doc.
2025-10-14 08:56:11 +02:00
Anthony LC
de4d11732f (frontend) can restore from trashbin list actions
We can now restore a doc from the trashbin list actions.
2025-10-14 08:55:26 +02:00
Anthony LC
37138c1a23 (frontend) add trashbin list
List the docs deleted in the trashbin list,
it is displayed in the docs grid.
2025-10-14 08:52:50 +02:00
Anthony LC
2c1a9ff74f 🍱(frontend) add material-symbols-outlined font
The design uses Material Symbols for icons.
This commit adds the font to the project and
updates the Icon component to be able to use it.
2025-10-14 08:52:50 +02:00
Manuel Raynaud
31389bcae2 ♻️(backend) open tree endpoint to deleted documents only for owners
The tree endpoint will now return a result only for owners. For other
users the endpoint still returns a 403. Also, the endpoint does look for
ancestors anymore, it only stay on the current document.
2025-10-13 11:15:22 +02:00
Manuel Raynaud
f772801fd0 ♻️(backend) change abilities for deleted document
The abilities for a deleted document were too open. We want to restrict
them. Only the restore, retrieve and tree is allowed. The tree method
will need some modifications to get the right informations.
2025-10-13 11:15:22 +02:00
Manuel Raynaud
390a615f48 (backend) expose deleted_at information in serializer
The front needs to know when a document has been deleted. We expose the
deleted_at property on a document object,
2025-10-13 11:15:21 +02:00
Anthony LC
5bdf5d2210 🔧(backend) expose TRASHBIN_CUTOFF_DAYS
To know when a document in the trashbin will be
permanently deleted.
2025-10-13 11:15:21 +02:00
Anthony LC
ed336558ac 🧑‍💻(config) THEME_CUSTOMIZATION_CACHE_TIMEOUT to 15 seconds
In development mode, we will reduce the cache timeout
for theme customization to 15 seconds.
This change allows developers to see updates
to theme settings more quickly without needing
to clear the cache manually.
2025-10-13 08:16:19 +02:00
Cyril
4fbd588198 (frontend) load docs logo from public folder via url instead of svg
allows logo override at deploy-time using k8s configmaps and static assets

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-10-13 08:15:57 +02:00
rvveber
546f97c956 ♻️(frontend) Refactor Auth component for improved redirection logic
Move redirects from render
to a guarded useEffect
to avoid triggering multiple redirects
on every re-render.
2025-10-09 21:49:41 +02:00
Anthony LC
af01c6e466 ♻️(service-worker) improve SW registration and update handling
It is apparently a bad practice to add the version
number to the service worker file name.
This prevents the browser from properly updating
the service worker when a new version is available.

We improve the update handling by a more usual
pattern.
2025-10-09 21:34:32 +02:00
Anthony LC
8023720da3 🙈(frontend) ignore public/assets/fonts with stylelint
The app build can sometimes fail because stylelint
linter warns about css font files in
public/assets/fonts.
We do not need to lint these files as they are
third-party files.
2025-10-09 16:23:52 +02:00
Cyril
91eba31735 (frontend) preserve interlink style on drag-and-drop in editor
adds hook to normalize dropped blocks and restore internal link format

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-10-09 13:39:56 +02:00
Manuel Raynaud
45d6c1beef 🔧(action) publish a comment when label preview is added (#1467)
The label preview will deploy a full environment. This environment is
accessible using a specific url. This commit will publish a comment with
the good url.
2025-10-09 12:17:17 +02:00
Tom Kaltenbrunner
dc25f3f39c 🚚(docker) use static path for certifi ca certificate
The certifi ca certificate is now stored under a static path
(/cert/cacert.pem) to avoid issues when python is upgraded and the path
to the certificate changes.
2025-10-08 12:50:31 +00:00
Cyril
529e7f1737 (frontend) reduce no access image size from 450 to 300
image now matches mockups (300x300px)

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-10-08 10:43:13 +02:00
Cyril
51c5c4ee63 (frontend) fix overlapping placeholders in multi-column layout
placeholders no longer overlap when adding 2–3 column, text wraps correctly

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-10-08 09:37:58 +02:00
Anthony LC
72f098c667 🐛(frontend) email case sensitive search modal
When a user was searching for an email in the share modal,
the search was case sensitive, so we were proposing
to send an invitation to a new user when in
fact the user was already registered.
The search is now case insensitive, so the only
choice is to add the existing user in the share list.
2025-10-07 14:51:46 +02:00
Manuel Raynaud
3b08ba4de1 ♻️(backend) force saving invitation email in lowercase
We want to enforce that invitation email are saved in lower case.
2025-10-07 14:51:46 +02:00
Manuel Raynaud
590b67fd71 🐛(backend) filter invitation with case insensitive email
A user can be invited and no control is made on the email case. Then,
when a new user is created, we are looking if there are pending
invitation and the lookup used is case sensitive. We change it using
__iexact which is case insensitive.
2025-10-07 14:51:45 +02:00
rouja
b3980e7bf1 ♻️(ci) add feature app environment
Add a special environment to prepare feature app deployment
2025-10-07 09:42:34 +00:00
Anthony LC
e3b2fdbdf5 🔥(mail) replace google font Roboto
Google fonts are not GDPR. We remove them and use
system fonts instead.
2025-10-07 09:06:39 +02:00
Anthony LC
314a7fa7b0 🍱(frontend) manage Inter font locally
We now use @fontsource-variable/inter package
instead of relying on a Google CDN.
2025-10-07 09:06:39 +02:00
Anthony LC
93227466d2 🍱(frontend) manage export emoji locally
We now use exported emoji from emoji-datasource-apple
package instead of relying on a CDN.
During a build or dev command, the emoji images
are copied from node_modules to the public assets
folder. They are not versionned.
2025-10-07 09:06:39 +02:00
Anthony LC
db7ae350ec ♻️(frontend) Marianne single source of truth
We now use Marianne font from @gouvfr-lasuite/ui-kit
package instead of a versionned local copies.
This ensures we always use the latest version of
the font.
During a build or dev command, Marianne font files
are copied from node_modules to the public assets
folder. They can stop being versionned.
2025-10-06 18:54:20 +02:00
Anthony LC
236c8df5ae 🚨(eslint) add no-unnecessary-type-assertion lint rule
Add no-unnecessary-type-assertion rule to eslint
config to avoid automatically unnecessary type
assertions in the codebase.
2025-10-06 16:49:58 +02:00
Cyril
ae1b05189e (frontend) fix attachment download filename
use the document title instead of the uuid when downloading attachments

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-10-06 16:49:58 +02:00
Cyril
431c331154 (frontend) exclude h4-h6 headings from table of contents
filters out h4-h6 so they no longer appear in the document outline

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-10-06 11:28:18 +02:00
Cyril
5184723862 (frontend) prevent readers from changing callout emoji
restrict callout emoji editing to maintain proper permissions

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-10-06 10:45:10 +02:00
virgile-dev
ca10fb9a12 💬(copy) wrong permission mentionned on move doc (#1377)
## Purpose

To move a doc you have to be at least admin of the destination doc.
It was written editor in the alert banner.


## Proposal
Change editor for administrator

## External contributions

Thank you for your contribution! 🎉  

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

Signed-off-by: virgile-deville <virgile.deville@beta.gouv.fr>
2025-10-03 14:16:43 +00:00
dependabot[bot]
59e875764c ⬆️(backend) bump django from 5.2.6 to 5.2.7
Upgrade to django 5.2.7
It is a security release with a fix for 2 CVE
https://docs.djangoproject.com/en/5.2/releases/5.2.7/
2025-10-03 08:29:34 +00:00
Anthony LC
7ed46ab225 🛂(frontend) block editing title when not allowed
We had a case where the title input was editable
even when the user did not have the right to
edit it because of websocket problem during
collaboration. We fixed this issue by checking
the collaboration status before allowing the
edition of the title.
2025-10-02 16:07:58 +02:00
Cyril
18f4ab880f (frontend) update labels and shared document icon accessibility
remove aria-labels from decorative icons and add sr-only text to shared doc icon

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

So that users have more options to choose from


## Proposal
Add mosa.cloud docs instance url

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-09 15:25:38 +02:00
Anthony LC
0a1eaa3c40 🚨(i18n) upgrade eslint to v9 with i18n package
We upgraded ESLint to version 9 in the i18n package,
which includes several improvements and fixes.
This change also involves updating the ESLint
configuration files to the new format and ensuring
compatibility with the latest ESLint features.
2025-09-09 12:27:32 +02:00
Anthony LC
da72a1601a 🚨(y-provider) upgrade eslint to v9 with y-provider server
We upgraded ESLint to version 9 in the y-provider server,
which includes several improvements and fixes.
This change also involves updating the ESLint
configuration files to the new format and ensuring
compatibility with the latest ESLint features.
2025-09-09 12:27:32 +02:00
Anthony LC
9a51e02cd7 🚨(e2e) upgrade eslint to v9 with e2e app
We upgraded ESLint to version 9 in the e2e app,
which includes several improvements and fixes.
This change also involves updating the ESLint
configuration files to the new format and ensuring
compatibility with the latest ESLint features.
2025-09-09 12:27:31 +02:00
Anthony LC
4184c339eb 🚨(docs) upgrade eslint to v9 with Docs app
We upgraded ESLint to version 9 in the Docs app,
which includes several improvements and fixes.
This change also involves updating the ESLint
configuration files to the new format and ensuring
compatibility with the latest ESLint features.
2025-09-09 12:27:31 +02:00
Anthony LC
3688591dd1 ⬆️(dependency) upgrade eslint to v9
We upgraded ESLint to version 9 in the
eslint-config-impress package.
We rename it to eslint-plugin-docs.
2025-09-09 11:03:54 +02:00
Sylvain Zimmer
25783182b8 🗑️(convert) cleanup old content route
Remove rout /api/content, there is no more controller behind and is not
used anymore.
2025-09-08 14:25:10 +02:00
Sylvain Zimmer
80a62bcbc1 (convert) improve tests with stricter tests and less ipsum
Use real example data to run convert handler tests.
2025-09-08 14:24:11 +02:00
Sylvain Zimmer
ede0a77665 ♻️(convert) reuse existing convert yprovider endpoint for content API
reuse convert service instead of renaming it in content
2025-09-08 14:23:42 +02:00
Sylvain Zimmer
8a8a1460e5 (api) add API route to fetch document content
This allows API users to process document content, enabling the
use of Docs as a headless CMS for instance, or any kind of document
processing. Fixes #1206.
2025-09-08 14:21:38 +02:00
Manuel Raynaud
0ac9f059b6 🔒️(backend) configure throttle on every viewsets
We want to configure the throttle on all doc's viewsets. In order to
monitor them, we use the MonitoredScopedRateThrottle class and a custom
callback caputing the message in sentry at the warning level.
2025-09-08 09:23:17 +02:00
Manuel Raynaud
179a84150b ⬆️(backend) upgrade django-lasuite to version 0.0.14
To use monitored throttling
2025-09-08 08:16:32 +02:00
Cyril
084d0c1089 (frontend) make delete buttons nvda-accessible
add aria-labels and include close button in title prop so NVDA announces actions

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-05 17:49:18 +02:00
Cyril
c9a6c4d4c6 (frontend) improve placeholder contrast in blocknote for wcag
fixes insufficient contrast

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-05 16:27:33 +02:00
Quentin BEY
9db7d0af8d 🔒️(all) refactor Docker Hub login to use official GitHub actions
Replace custom Docker Hub authentication with standard, secure,
official GitHub actions for improved security and maintainability.

Uses officially supported actions that follow security best practices
and receive regular updates from GitHub.

Avoid unsecure handling of GitHub secrets.

Thanks to @lebaudantoine
2025-09-05 16:05:10 +02:00
Anthony LC
9135dff088 🔖(minor) release 3.6.0
Added:
- 👷(CI) add bundle size check job
- (frontend) use title first emoji as doc icon in tree

Changed:
- ♻️(docs-app) Switch from Jest tests to Vitest
- (frontend) improve accessibility:
  - 🌐(frontend) set html lang attribute dynamically
  - (frontend) inject language attribute to pdf export
  - (frontend) improve accessibility of search modal
  - (frontend) add correct attributes to decorative and interactive icons
  - 🎨(frontend) improve nav structure
  - ️(frontend) keyboard interaction with menu
  - (frontend) improve header accessibility
  - (frontend) improve accessibility for decorative images in editor
- ♻️(backend) fallback to email identifier when no name
- 🐛(backend) allow ASCII characters in user sub field
- ️(frontend) improve fallback width calculation

Fixed:
- 🐛(makefile) Windows compatibility fix for Docker volume mounting
- 🐛(minio) fix user permission error with Minio and Windows
- 🐛(frontend) fix export when quote block and inline code
- 🐛(frontend) fix base64 font
- 🐛(backend) allow editor to delete subpages
- 🐛(frontend) fix dnd conflict with tree and Blocknote
- 🐛(frontend) fix display bug on homepage
2025-09-05 09:11:58 +02:00
Anthony LC
cc4c67d15b 🩹(frontend) add computed_link_reach on PUT link-configuration
By default a document is "restricted", a restricted
document cannot have a role "editor" or "reader".
With inheritance, a child document could have a computed
link reach different than "restricted" though.

We pass the computed link reach when we update the
link role, to be sure if follows the parent computed
link reach.
2025-09-05 09:11:58 +02:00
Anthony LC
63a2bde11e (e2e) fix e2e tests
After translating to french, a selector was not
accessible anymore because the aria label
was in english. We update the selector to use the
french aria label.
We increase as well the timeout of another test
that was flaky.
2025-09-04 16:15:08 +02:00
Anthony LC
b317a2a596 🌐(i18n) update translated strings
Update translated files with new translations
2025-09-04 13:56:23 +02:00
Anthony LC
39ef6d10ff 🐛(frontend) fix display bug on homepage
A section in the homepage was not displaying
correctly anymore. This commit fixes the issue.
2025-09-04 10:44:35 +02:00
Anthony LC
961ae3c39e 🐛(frontend) remove padding from ul in left panel favorites
We recently change from a div to a ul for better semantics.
ul include by default padding, we remove it to
align with the design.
2025-09-04 10:27:53 +02:00
Anthony LC
726b50d6b5 ️(frontend) improve fallback width calculation
Sometimes we do not have the width of some
columns in a table. In such cases, we need to
calculate a fallback width to ensure the table
is rendered correctly.
We were previously using 120 points as the
fallback width, but this has been improved
to better fit the content.
We now check the size left and distribute it
among the unknown columns.
2025-09-03 18:11:43 +02:00
Anthony LC
814eb1f1a1 🐛(frontend) fix modal backdrop z-index
Recent upgrade of ui-kit removed the z-index for
the modal backdrop, causing it to be hidden behind
other elements. This commit restores the z-index
 to ensure the modal backdrop is displayed correctly.
2025-09-03 10:21:27 +02:00
Anthony LC
648528499c 🐛(frontend) fix dnd conflict with tree and Blocknote
We have different DND system in the page, one on the
menu tree and one in the Blocknote editor.
The menu tree was adding a transparent layer
when user were dragging element on the Blocknote
editor, blocking the Blocknote DND.
We update the ui-kit to add the prop dndRootElement,
dndRootElement is now used to specify the root element
for DND, this transparent layer is now only applied
when a drag is made from the menu tree.

It stabilize as well the drop position, making it easier to drop element.
2025-09-03 10:21:27 +02:00
Anthony LC
474e5ac0c0 🐛(frontend) fix background color during dnd
When we were dragging an item in the doc tree,
the background color was opaque, making it difficult
to see the underlying content. The cause was
that we were overriding the transparent background
color.
2025-09-03 10:21:27 +02:00
Henry Hiles
a799d77643 📝(doc) add Federated Nexus docs instance (#1320)
## Purpose

Adds a new Docs instance, as suggested here:
https://github.com/suitenumerique/docs/discussions/1316

## External contributions

Thank you for your contribution! 🎉  

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

Signed-off-by: Henry-Hiles <henry@henryhiles.com>
2025-09-02 14:38:52 +00:00
Manuel Raynaud
2e04b63d2d 🐛(backend) allow creator to delete subpages
An editor who created a subpages should be allowed to delete it.
We change the abilities to be coherent between the creation and the
deletion.
Fixes #1193
2025-09-01 22:43:44 +02:00
Anthony LC
eec419bdba 🐛(renovate) fix renovate
Fix renovate configuration:
- schedule at 7am the monday
- avoid wiping manual fixes unless needed
- only create during schedule
2025-09-01 17:05:49 +02:00
Anthony LC
baa5630344 🐛(CI) fix npm fallback on bundle-size-check
Adding install-script to ensure Yarn is used explicitly
during compressed-size-action to prevent npm peer conflict.
2025-09-01 17:05:49 +02:00
Anthony LC
e7b551caa4 🍱(frontend) update material-icons font
We don't use the material-icons font from
ui-kit anymore, we need to import it directly
in order to use both the outlined and filled variants.
2025-09-01 17:05:48 +02:00
Anthony LC
4dfc1584bd ️(frontend) add back Marianne font
Marianne font was in base64 in the ui-kit,
it was not an optimize way to do it.
We do not have a CDN yet so the best
is to put them back in the project in
waiting for a CDN options.
2025-09-01 17:05:48 +02:00
renovate[bot]
09eddfc339 ⬆️(dependencies) update js dependencies 2025-09-01 17:05:48 +02:00
Olivier Laurendeau
75f2e547e0 🔧(frontend) increase test-e2e-other-browser action timeout
It was failing at 20min, increase the timeout to 30 min
2025-09-01 16:35:17 +02:00
Olivier Laurendeau
d1cbdfd819 (frontend) use title first emoji as doc icon in tree
Implemented emoji detection system, new DocIcon component.
2025-09-01 16:35:17 +02:00
Manuel Raynaud
0b64417058 🔧(ci) always run all git-lint steps (#1323)
git-lint steps are independant and we would like to have all checks at
once. Using the `if: always()` instruction should ensure all steps
should be run event if the previous fails.
2025-09-01 12:29:47 +00:00
Anthony LC
57a505a80c ⬆️(dependencies) Bump next from 15.4.6 to 15.4.7
Bumps [next](https://github.com/vercel/next.js) from 15.4.6
to 15.4.7.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v15.4.6...v15.4.7)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 15.4.7
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-01 11:56:40 +02:00
Anthony LC
21ee38c218 🐛(frontend) fix export when quote block and inline code
When exporting documents, if a inline code was inside
a quote block, the PDF export was failing because the
inline code was searching the GeistMono font in
italics, which was not available.
We switch to the core "Courier" font for code marks,
which is available in italics.
2025-08-29 18:42:27 +02:00
Manuel Raynaud
09de014a43 🐛(back) allow ASCII characters in user sub field
All ASCII characters are allowed in a sub, we change the sub validator
to reflect this.
2025-08-29 13:59:06 +00:00
Anthony LC
8d42149304 👷(CI) trigger bundle-size-check only if necessary
We don't need to run the bundle-size-check job if
the app didn't change.
If the yarn.lock file or the app have changed, the
bundle-size-check job will be triggered.
2025-08-29 10:21:13 +02:00
Maximilian Bosch
2451a6a322 📦️(mail) make DIR_MAILS variable configurable
That allows downstream packages to e.g. split the builds of
frontend/backend/mail, as it's the case in nixpkgs:
5597a4f8f9
2025-08-29 08:00:43 +00:00
Manuel Raynaud
d5c9eaca5a ♻️(backend) fallback to email identifier when no name (#1298)
In the UserlightSerializer, if the user has no short_name or full_name,
we have no info about the user. We decided to use the email identifier
and slugify it to have a little bit information.
2025-08-29 07:39:55 +00:00
Loïc Tosser
1491012969 🔧(project) update OIDC_OP_LOGOUT_ENDPOINT to match keycloak
Update default value of OIDC_OP_LOGOUT_ENDPOINT
to match keycloak.
2025-08-28 17:06:07 +02:00
Karl Horky
9dcf478dd3 ✏️(frontend) fix GitHub capitalization
The capitalization of the product GitHub has a capital H

Signed-off-by: Karl Horky <karl.horky@gmail.com>
2025-08-28 16:44:28 +02:00
Manuel Raynaud
586825aafa ♻️(back) stop returning a 500 on cors_proxy on request failure
On the cors_proxy endpoint, if the fetched url fails we were returning
an error 500. Instead, we log the exception and return a 400 to not
give back information to the frontend application.
2025-08-28 16:06:35 +02:00
Manuel Raynaud
247550fc13 ♻️(back) validate url used in cors_proxy endpoint
The url used by the cors_proxy was not validated, other value than a
http url can be used. We use the built in URLValidator to validate it is
a valid url.
2025-08-28 16:06:35 +02:00
Manuel Raynaud
781c85b66b ️(ci) use setup-python cache option
The setup-python action is able to cache the dependencies and reuse this
cache while the pyproject file has not changed. It is easy to setup,
just the package manager used has to be declared in the cache settings.
2025-08-28 14:00:16 +00:00
Anthony LC
64f967cd29 (frontend) assert DocToolBox depends the licence
Thanks to Vitest we can now assert more complicated
parts of the code without too much mocking.
2025-08-28 11:20:12 +02:00
Anthony LC
1eee24dc19 ♻️(docs-app) Switch from Jest tests to Vitest
We have migrated the testing framework from Jest
to Vitest for the Docs application.
This change includes updates to test files,
configuration files, and the addition of new setup
files for Vitest.
2025-08-28 11:11:29 +02:00
Anthony LC
ff9e13ca03 👷(CI) add bundle size check job
Job that will give a report on the bundle size
of the frontend application.
Good to know if the bundle size is increasing or
decreasing and if the changes are acceptable.
2025-08-28 10:37:54 +02:00
Anthony LC
7758e64f40 💄(makefile) add information messages during bootstrap
Add nicely formatted messages to the Makefile to
indicate the start and end of the bootstrap process.
This will help users understand when the bootstrap
process begins and ends, improving the
overall user experience.
2025-08-28 10:16:14 +02:00
virgile-dev
4ab9edcd57 📝(doc) update readme and add community installation methods (#1292)
## Purpose

To showcase the work of the community to make docs deployment easier


## Proposal

- [x] Added README.md in /docs/installation with methods contributed by
the community
- [x] Added a list of public instances on the main README.md
- [x] Updated the warnings around Blocknote XL packages from AGPL to GPL

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

---------

Signed-off-by: virgile-dev <virgile.deville@beta.gouv.fr>
2025-08-26 16:03:39 +03:00
Luca Weiss
0892c05321 📝(compose) Increase attachment upload size to 10 MB
Mostly give this as an example how a person deploying this knows which
knob to turn.

Signed-off-by: Luca Weiss <luca@lucaweiss.eu>
2025-08-18 19:02:51 +02:00
Luca Weiss
2375bc136c 📝(compose) Whitespace cleanup in default.conf.template
Signed-off-by: Luca Weiss <luca@lucaweiss.eu>
2025-08-18 19:02:50 +02:00
Luca Weiss
e1c2053697 📝(compose) Remove double colon from yaml
It doesn't look like this is supposed to be there.

Signed-off-by: Luca Weiss <luca@lucaweiss.eu>
2025-08-18 19:02:49 +02:00
Luca Weiss
58f68d86e1 📝(compose) Reindent compose.yaml files
Follow yamlllint's suggestions and use the correct indentation for
lists.

Signed-off-by: Luca Weiss <luca@lucaweiss.eu>
2025-08-18 19:02:48 +02:00
Luca Weiss
7c97719907 📝(compose) Update deprecated USER_OIDC_FIELD* variables in example
Signed-off-by: Luca Weiss <luca@lucaweiss.eu>
2025-08-18 19:02:40 +02:00
Cyril
d0c9de9d96 (frontend) set empty alt for decorative images in blocknote editor
ensure decorative images have empty alt to comply with RGAA 1.2 accessibility

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-08-14 14:39:42 +02:00
Cyril
81f3997628 (frontend) improve accessibility of search modal for screen readers
added clearer sr-only translations and aria-hidden for non-essential content

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-08-08 08:58:22 +02:00
Anthony LC
0cf8b9da1a 🐛(minio) fix user permission error with Minio and Windows
With Minio Docker and Windows, the user ID needs
 to be set to `0:0` to avoid permission issues.
 This change ensures that the Minio container
 runs with root privileges on Windows, which
 is necessary for proper file access and management.
2025-08-07 12:37:00 +02:00
Anthony LC
7be761ce84 🐛(makefile) Windows compatibility fix for Docker volume mounting
On Windows systems, Docker volume paths starting
with a single / can be interpreted incorrectly
by the Docker daemon. The double slash (//) helps
Docker on Windows properly interpret the path as
an absolute path within the container, ensuring
that the working directory is correctly set
when running mail-related yarn commands.
2025-08-07 12:36:29 +02:00
Cyril
5181bba083 ️(a11y) improve keyboard access for language menu and action buttons
Enhances nav for language switch and makes DocsGridActions buttons accessible

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-08-07 11:58:47 +02:00
Anthony LC
f434d78b5d ⬆️(dependencies) update js dependencies
- Update js dependencies
- Fix linters
2025-08-07 11:06:34 +02:00
Cyril
e07f709dd4 (frontend) improve accessibility of global docs home link at top
moved aria-label and added aria-hidden for better accessibility

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-08-06 15:37:51 +02:00
Cyril G
afbacb0a24 ️(frontend) improve left panel accessibility (#1262)
Improve overall accessibility of the left panel:
- ️(frontend) make LeftPanelTargetFilter accessible and use Box as nav
- ️(frontend) improve accessibility in left panel components
- (e2e) fix e2e test to expect aria-current instead of aria-selected
- (frontend) add semantic ul/li to LeftPanel
- (frontend) improve favorite item a11y and update e2e test accordingly
2025-08-06 14:20:53 +02:00
Anthony LC
409e073192 🤡(e2e) mock PATCH language switch
We add some flaky tests because the aria label
selectors were not everytime in english language.
It was because the language switch was not mocked
in the e2e tests, impacting the consistency of
other concurrent tests.
We mock the language switch in the e2e tests
to ensure that the other tests are not impacted
by the language switch.
2025-08-05 12:42:13 +02:00
Jan Conen
886dcb75d5 📝(self-hosted) commands copy-pastable
Make bash commands copy-pastable by prepending
the foldername to the commands.

Signed-off-by: Jan Conen <janconen@hotmail.com>
2025-08-05 11:47:44 +02:00
Jan Conen
bb4d2a9fea 📝(self-hosted) default.conf.template when using nginx-proxy
Add step to copy default.conf.template
hen using nginx-proxy.

Signed-off-by: Jan Conen <janconen@hotmail.com>
2025-08-05 11:47:08 +02:00
Moritz Schlarb
5e5054282e 📝(doc) Fix commands in docker compose section
Corrected the commands in step 1

Signed-off-by: Moritz Schlarb <schlarbm@uni-mainz.de>
2025-08-05 11:14:40 +02:00
timo
f497e75426 🔧(project) Add trailing slash to yprovider api path
The value in the production environment .env example was missing a
trailing slash in the path. This commit adjusts this to be in the same
format as in other places.
2025-08-05 10:02:31 +02:00
Cyril
97ab13ded6 (e2e) fix broken e2e tests by updating selectors
selectors were updated to stabilize and fix the failing e2e tests

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-08-04 16:00:17 +02:00
Cyril
99d674c615 ️(frontend) add correct attributes to decorative and interactive icons
Add aria-hidden and aria-label to improve screen reader accessibility

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-08-04 13:35:48 +02:00
Cyril
1cdb6b62c8 (e2e) ensure i18n.language is injected into generated PDF
Adds an end-to-end test to verify language injection in the generated PDF.

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-08-04 09:35:13 +02:00
Cyril
2bf53301d2 ️(frontend) inject language attribute to pdf export
added language="fr-FR" to <Document /> in ModalExport.tsx via cloneElement()
to improve accessibility and ensure correct screen reader pronunciation

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-08-04 09:35:13 +02:00
Cyril
ec84f31bc7 ️(frontend) set html lang attribute dynamically based on current loc
ensures proper language tag is set for accessibility and SEO compliance

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-08-04 08:42:56 +02:00
rouja
7813219b86 ♻️(documentation) remove unused environment variables
Yesterday during a deployment, we discovered that these variables are
unused:
POSTGRES_DB
POSTGRES_USER
POSTGRES_PASSWORD
2025-08-01 12:42:02 +00:00
Anthony LC
cecb4f5756 🔖(minor) release 3.5.0
Added:
- (helm) Service Account support for K8s Resources in Helm Charts
- (backend) allow masking documents from the list view
- (frontend) subdocs can manage link reach
- (frontend) add duplicate action to doc tree
- (frontend) Interlinking doc
- (frontend) add multi columns support for editor

Changed:
- ♻️(frontend) search on all docs if no children
- ♻️(frontend) redirect to doc after duplicate
- 🔧(project) change env.d system by using local files
- ️(frontend) improve tree stability
- ️(frontend) improve accessibility
- 🛂(frontend) block drag n drop when not desktop

Fixed:
- 🐛(service-worker) Fix useOffline Maximum update depth exceeded
- 🐛(frontend) fix empty left panel after deleting root doc
- 🐛(helm) charts generate invalid YAML for collaboration API / WS
- 🐛(frontend) 401 redirection overridden
- 🐛(frontend) include root parent in search
2025-08-01 09:45:02 +02:00
Anthony LC
63efe40a7b 🐛(frontend) fix interlinking click with Firefox
Fixed Firefox requiring double-click on
interlinks by adding draggable="false" to prevent
drag detection conflicts in contenteditable areas.
2025-08-01 09:45:02 +02:00
AntoLC
e26c3dff35 🌐(i18n) update translated strings
Update translated files with new translations
2025-07-31 14:54:06 +02:00
Anthony LC
f5f9d8a877 (frontend) interlinking export
Create interlinking link mapping for docx and pdf export.
2025-07-31 13:26:09 +02:00
Anthony LC
e7709badbb (frontend) create editor shortcuts hook
We created the editor shortcuts hook to handle
the shortcuts for the editor.
We implemented the following shortcuts:
- "@" to open the interlinking inline content
2025-07-31 13:26:09 +02:00
Anthony LC
2a7c0ef800 (frontend) create page from dropdown search
We are now able to create a new page from
the dropdown search.
2025-07-31 13:26:09 +02:00
Anthony LC
155e7dfe22 (frontend) interlinking custom inline content
We want to be able to interlink documents in the editor.
We created a custom inline content that allows
users to interlink documents.
2025-07-31 13:00:11 +02:00
Anthony LC
afa48b6675 (frontend) create page from slash menu
We are now able to create a new page from
the slash menu.
2025-07-31 12:57:25 +02:00
Anthony LC
f12d30cffa 🚚(frontend) reduce features coupling
Move some components and assets to `doc-management`
to reduce coupling between features:
- SimpleDocItem from `doc-grid` to `doc-management`
- useCreateChildDoc from `doc-tree` to `doc-management`
- isOwnerOrAdmin from `doc-tree` to `doc-management`
2025-07-30 15:11:37 +02:00
Anthony LC
30dfea744a 🐛(frontend) include root parent in search
When searching for documents, the root parent
document is now included in the search
results if it matches the search query.
2025-07-30 14:56:30 +02:00
Anthony LC
2cbe363a5f 🛂(frontend) block drag n drop when not desktop
Scrolling on mobile devices was causing issues
with drag and drop functionality, documents were
being moved unintentionally.
This commit disables drag and drop on mobile devices
to prevent this issue.
2025-07-30 14:06:39 +02:00
Anthony LC
7f450e8aa8 ⬆️(frontend) Bump linkifyjs from 4.3.1 to 4.3.2
Bumps linkifyjs from 4.3.1 to 4.3.2.

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Signed-off-by: Stephan Meijer <me@stephanmeijer.com>
2025-07-02 14:49:02 +02:00
Manuel Raynaud
1d741871d7 (helm) allow to configure cronjobs using backend image
We want to configure cronjobs. Instead of declaring them one by one, we
use a CronJobList, the will all have the same pattern, mostly the
command and the schedule will change.
2025-07-01 14:51:29 +02:00
567 changed files with 57580 additions and 29764 deletions

View File

@@ -1,6 +0,0 @@
<!---
Thanks for filing an issue 😄 ! Before you submit, please read the following:
Check the other issue templates if you are trying to submit a bug report, feature request, or question
Search open/closed issues before submitting since someone might have asked the same thing before!
-->

View File

@@ -6,6 +6,10 @@ labels: ["bug", "triage"]
## Bug Report ## Bug Report
**Before you file your issue**
- Check the other [issues](https://github.com/suitenumerique/docs/issues) before filing your own
- If your report is related to the ([BlockNote](https://github.com/TypeCellOS/BlockNote)) text editor, [file it on their repo](https://github.com/TypeCellOS/BlockNote/issues). If you're not sure whether your issue is with BlockNote or Docs, file it on our repo: if we support it, we'll backport it upstream ourselves 😊, otherwise we'll ask you to do so.
**Problematic behavior** **Problematic behavior**
A clear and concise description of the behavior. A clear and concise description of the behavior.

View File

@@ -23,9 +23,10 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
# Backend i18n # Backend i18n
- name: Install Python - name: Install Python
uses: actions/setup-python@v3 uses: actions/setup-python@v5
with: with:
python-version: "3.13.3" python-version: "3.13.3"
cache: "pip"
- name: Upgrade pip and setuptools - name: Upgrade pip and setuptools
run: pip install --upgrade pip setuptools run: pip install --upgrade pip setuptools
- name: Install development dependencies - name: Install development dependencies

View File

@@ -31,8 +31,11 @@ jobs:
images: lasuite/impress-backend images: lasuite/impress-backend
- -
name: Login to DockerHub name: Login to DockerHub
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- -
name: Run trivy scan name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main uses: numerique-gouv/action-trivy-cache@main
@@ -46,7 +49,7 @@ jobs:
context: . context: .
target: backend-production target: backend-production
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000 build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
@@ -64,8 +67,11 @@ jobs:
images: lasuite/impress-frontend images: lasuite/impress-frontend
- -
name: Login to DockerHub name: Login to DockerHub
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- -
name: Run trivy scan name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main uses: numerique-gouv/action-trivy-cache@main
@@ -82,7 +88,7 @@ jobs:
build-args: | build-args: |
DOCKER_USER=${{ env.DOCKER_USER }}:-1000 DOCKER_USER=${{ env.DOCKER_USER }}:-1000
PUBLISH_AS_MIT=false PUBLISH_AS_MIT=false
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
@@ -100,7 +106,7 @@ jobs:
images: lasuite/impress-y-provider images: lasuite/impress-y-provider
- -
name: Login to DockerHub name: Login to DockerHub
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
- -
name: Run trivy scan name: Run trivy scan
@@ -116,7 +122,7 @@ jobs:
file: ./src/frontend/servers/y-provider/Dockerfile file: ./src/frontend/servers/y-provider/Dockerfile
target: y-provider target: y-provider
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000 build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
@@ -125,7 +131,7 @@ jobs:
- build-and-push-frontend - build-and-push-frontend
- build-and-push-backend - build-and-push-backend
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
steps: steps:
- uses: numerique-gouv/action-argocd-webhook-notification@main - uses: numerique-gouv/action-argocd-webhook-notification@main
id: notify id: notify

View File

@@ -21,10 +21,10 @@ jobs:
shell: bash shell: bash
run: | run: |
set -e set -e
HELMFILE=src/helm/helmfile.yaml HELMFILE=src/helm/helmfile.yaml.gotmpl
environments=$(awk 'BEGIN {in_env=0} /^environments:/ {in_env=1; next} /^---/ {in_env=0} in_env && /^ [^ ]/ {gsub(/^ /,""); gsub(/:.*$/,""); print}' "$HELMFILE") environments=$(awk 'BEGIN {in_env=0} /^environments:/ {in_env=1; next} /^---/ {in_env=0} in_env && /^ [^ ]/ {gsub(/^ /,""); gsub(/:.*$/,""); print}' "$HELMFILE")
for env in $environments; do for env in $environments; do
echo "################### $env lint ###################" echo "################### $env lint ###################"
helmfile -e $env -f $HELMFILE lint || exit 1 helmfile -e $env lint -f $HELMFILE || exit 1
echo -e "\n" echo -e "\n"
done done

View File

@@ -80,7 +80,7 @@ jobs:
fail-on-cache-miss: true fail-on-cache-miss: true
- name: Set e2e env variables - name: Set e2e env variables
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist run: cat env.d/development/common.e2e >> env.d/development/common.local
- name: Install Playwright Browsers - name: Install Playwright Browsers
run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright chromium run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright chromium
@@ -101,7 +101,7 @@ jobs:
test-e2e-other-browser: test-e2e-other-browser:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: test-e2e-chromium needs: test-e2e-chromium
timeout-minutes: 20 timeout-minutes: 30
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -119,7 +119,7 @@ jobs:
fail-on-cache-miss: true fail-on-cache-miss: true
- name: Set e2e env variables - name: Set e2e env variables
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist run: cat env.d/development/common.e2e >> env.d/development/common.local
- name: Install Playwright Browsers - name: Install Playwright Browsers
run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright firefox webkit chromium run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright firefox webkit chromium
@@ -136,3 +136,54 @@ jobs:
name: playwright-other-report name: playwright-other-report
path: src/frontend/apps/e2e/report/ path: src/frontend/apps/e2e/report/
retention-days: 7 retention-days: 7
bundle-size-check:
runs-on: ubuntu-latest
needs: install-dependencies
if: github.event_name == 'pull_request'
permissions:
contents: read
pull-requests: write
issues: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Detect relevant changes
id: changes
uses: dorny/paths-filter@v2
with:
filters: |
lock:
- 'src/frontend/**/yarn.lock'
app:
- 'src/frontend/apps/impress/**'
- name: Restore the frontend cache
uses: actions/cache@v4
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
fail-on-cache-miss: true
- name: Setup Node.js
if: steps.changes.outputs.lock == 'true' || steps.changes.outputs.app == 'true'
uses: actions/setup-node@v4
with:
node-version: "22.x"
- name: Check bundle size changes
if: steps.changes.outputs.lock == 'true' || steps.changes.outputs.app == 'true'
uses: preactjs/compressed-size-action@v2
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"
build-script: "app:build"
pattern: "apps/impress/out/**/*.{css,js,html}"
exclude: "{**/*.map,**/node_modules/**}"
minimum-change-threshold: 500
compression: "gzip"
cwd: "./src/frontend"
show-total: true
strip-hash: "[-_.][a-f0-9]{8,}(?=\\.(?:js|css|html)$)"
omit-unchanged: true
install-script: "yarn install --frozen-lockfile"

View File

@@ -19,20 +19,24 @@ jobs:
if: github.event_name == 'pull_request' # Makes sense only for pull requests if: github.event_name == 'pull_request' # Makes sense only for pull requests
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v2 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: show - name: show
run: git log run: git log
- name: Enforce absence of print statements in code - name: Enforce absence of print statements in code
if: always()
run: | run: |
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/impress.yml' | grep "print(" ! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/impress.yml' | grep "print("
- name: Check absence of fixup commits - name: Check absence of fixup commits
if: always()
run: | run: |
! git log | grep 'fixup!' ! git log | grep 'fixup!'
- name: Install gitlint - name: Install gitlint
if: always()
run: pip install --user requests gitlint run: pip install --user requests gitlint
- name: Lint commit messages added to main - name: Lint commit messages added to main
if: always()
run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
check-changelog: check-changelog:
@@ -42,7 +46,7 @@ jobs:
github.event_name == 'pull_request' github.event_name == 'pull_request'
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3 uses: actions/checkout@v4
with: with:
fetch-depth: 50 fetch-depth: 50
- name: Check that the CHANGELOG has been modified in the current branch - name: Check that the CHANGELOG has been modified in the current branch
@@ -52,7 +56,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v2 uses: actions/checkout@v4
- name: Check CHANGELOG max line length - name: Check CHANGELOG max line length
run: | run: |
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L) max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
@@ -66,7 +70,7 @@ jobs:
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v2 uses: actions/checkout@v4
- name: Install codespell - name: Install codespell
run: pip install --user codespell run: pip install --user codespell
- name: Check for typos - name: Check for typos
@@ -75,6 +79,7 @@ jobs:
--check-filenames \ --check-filenames \
--ignore-words-list "Dokument,afterAll,excpt,statics" \ --ignore-words-list "Dokument,afterAll,excpt,statics" \
--skip "./git/" \ --skip "./git/" \
--skip "**/*.pdf" \
--skip "**/*.po" \ --skip "**/*.po" \
--skip "**/*.pot" \ --skip "**/*.pot" \
--skip "**/*.json" \ --skip "**/*.json" \
@@ -87,11 +92,12 @@ jobs:
working-directory: src/backend working-directory: src/backend
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v2 uses: actions/checkout@v4
- name: Install Python - name: Install Python
uses: actions/setup-python@v3 uses: actions/setup-python@v5
with: with:
python-version: "3.13.3" python-version: "3.13.3"
cache: "pip"
- name: Upgrade pip and setuptools - name: Upgrade pip and setuptools
run: pip install --upgrade pip setuptools run: pip install --upgrade pip setuptools
- name: Install development dependencies - name: Install development dependencies
@@ -184,9 +190,10 @@ jobs:
mc version enable impress/impress-media-storage" mc version enable impress/impress-media-storage"
- name: Install Python - name: Install Python
uses: actions/setup-python@v3 uses: actions/setup-python@v5
with: with:
python-version: "3.13.3" python-version: "3.13.3"
cache: "pip"
- name: Install development dependencies - name: Install development dependencies
run: pip install --user .[dev] run: pip install --user .[dev]

27
.github/workflows/label_preview.yml vendored Normal file
View File

@@ -0,0 +1,27 @@
name: Label Preview
on:
pull_request:
types: [labeled, opened]
permissions:
pull-requests: write
jobs:
comment:
runs-on: ubuntu-latest
if: contains(github.event.pull_request.labels.*.name, 'preview')
steps:
- uses: thollander/actions-comment-pull-request@v3
with:
message: |
:rocket: Preview will be available at [https://${{ github.event.pull_request.number }}-docs.ppr-docs.beta.numerique.gouv.fr/](https://${{ github.event.pull_request.number }}-docs.ppr-docs.beta.numerique.gouv.fr/)
You can use the existing account with these credentials:
- username: `docs`
- password: `docs`
You can also create a new account if you want to.
Once this Pull Request is merged, the preview will be destroyed.
comment-tag: preview-url

6
.gitignore vendored
View File

@@ -40,8 +40,7 @@ venv/
ENV/ ENV/
env.bak/ env.bak/
venv.bak/ venv.bak/
env.d/development/* env.d/development/*.local
!env.d/development/*.dist
env.d/terraform env.d/terraform
# npm # npm
@@ -76,3 +75,6 @@ db.sqlite3
.vscode/ .vscode/
*.iml *.iml
.devcontainer .devcontainer
# Cursor rules
.cursorrules

View File

@@ -1,5 +1,3 @@
# Changelog
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0),
@@ -10,16 +8,299 @@ and this project adheres to
### Added ### Added
-(frontend) add customization for translations #857 - Add comments feature to the editor #1330
- 📝(project) add troubleshoot doc #1066 - ✨(backend) Comments on text editor #1330
- 📝(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 ### Changed
- ⚡️(sw) stop to cache external resources likes videos #1655
- 💥(frontend) upgrade to ui-kit v2
- ⚡️(frontend) improve perf on upload and table of contents #1662
### Fixed
- ♿(frontend) improve accessibility:
- ♿(frontend) improve share modal button accessibility #1626
- ♿(frontend) improve screen reader support in DocShare modal #1628
- 🐛(frontend) fix toolbar not activated when reader #1640
- 🐛(frontend) preserve left panel width on window resize #1588
- 🐛(frontend) prevent duplicate as first character in title #1595
## [3.10.0] - 2025-11-18
### Added
- ✨(export) enable ODT export for documents #1524
- ✨(frontend) improve mobile UX by showing subdocs count #1540
- ✅(e2e) add test to compare generated PDF against reference template #1648
### Changed
- ♻️(frontend) preserve @ character when esc is pressed after typing it #1512
- ♻️(frontend) make summary button fixed to remain visible during scroll #1581
- ♻️(frontend) pdf embed use full width #1526
### Fixed
- ♿(frontend) improve accessibility:
- ♿(frontend) improve ARIA in doc grid and editor for a11y #1519
- ♿(frontend) improve accessibility and styling of summary table #1528
- ♿(frontend) add focus trap and enter key support to remove doc modal #1531
- 🐛(frontend) fix alignment of side menu #1597
- 🐛(frontend) fix fallback translations with Trans #1620
- 🐛(export) fix image overflow by limiting width to 600px during export #1525
- 🐛(export) fix table cell alignment issue in exported documents #1582
- 🐛(export) preserve image aspect ratio in PDF export #1622
- 🐛(export) Export fails when paste with style #1552
### Security
- mitigate role escalation in the ask_for_access viewset #1580
### Removed
- 🔥(backend) remove api managing templates
## [3.9.0] - 2025-11-10
### Added
- ✨(frontend) create skeleton component for DocEditor #1491
- ✨(frontend) add an EmojiPicker in the document tree and title #1381
- ✨(frontend) ajustable left panel #1456
### Changed
- ♻️(frontend) adapt custom blocks to new implementation #1375
- ♻️(backend) increase user short_name field length #1510
- 🚸(frontend) separate viewers from editors #1509
### Fixed
- 🐛(frontend) fix duplicate document entries in grid #1479
- 🐛(backend) fix trashbin list #1520
- ♿(frontend) improve accessibility:
- ♿(frontend) remove empty alt on logo due to Axe a11y error #1516
- 🐛(backend) fix s3 version_id validation #1543
- 🐛(frontend) retry check media status after page reload #1555
- 🐛(frontend) fix Interlinking memory leak #1560
- 🐛(frontend) button new doc UI fix #1557
- 🐛(frontend) interlinking UI fix #1557
## [3.8.2] - 2025-10-17
### Fixed
- 🐛(service-worker) fix sw registration and page reload logic #1500
## [3.8.1] - 2025-10-17
### Fixed
- ⚡️(backend) improve trashbin endpoint performance #1495
- 🐛(backend) manage invitation partial update without email #1494
- ♿(frontend) improve accessibility:
- ♿ add missing aria-label to add sub-doc button for accessibility #1480
- ♿ add missing aria-label to more options button on sub-docs #1481
### Removed
- 🔥(backend) remove treebeard form for the document admin #1470
## [3.8.0] - 2025-10-14
### Added
- ✨(frontend) add pdf block to the editor #1293
- ✨List and restore deleted docs #1450
### Changed
- ♻️(frontend) Refactor Auth component for improved redirection logic #1461
- ♻️(frontend) replace Arial font-family with token font #1411
- ♿(frontend) improve accessibility:
- ♿(frontend) enable enter key to open documentss #1354
- ♿(frontend) improve modal a11y: structure, labels, title #1349
- ♿improve NVDA navigation in DocShareModal #1396
- ♿ improve accessibility by adding landmark roles to layout #1394
- ♿ add document visible in list and openable via enter key #1365
- ♿ add pdf outline property to enable bookmarks display #1368
- ♿ hide decorative icons from assistive tech with aria-hidden #1404
- ♿ fix rgaa 1.9.1: convert to figure/figcaption structure #1426
- ♿ remove redundant aria-label to avoid over-accessibility #1420
- ♿ remove redundant aria-label on hidden icons and update tests #1432
- ♿ improve semantic structure and aria roles of leftpanel #1431
- ♿ add default background to left panel for better accessibility #1423
- ♿ restyle checked checkboxes: removing strikethrough #1439
- ♿ add h1 for SR on 40X pages and remove alt texts #1438
- ♿ update labels and shared document icon accessibility #1442
- 🍱(frontend) Fonts GDPR compliants #1453
- ♻️(service-worker) improve SW registration and update handling #1473
### Fixed
- 🐛(backend) duplicate sub docs as root for reader users #1385
- ⚗️(service-worker) remove index from cache first strategy #1395
- 🐛(frontend) fix 404 page when reload 403 page #1402
- 🐛(frontend) fix legacy role computation #1376
- 🛂(frontend) block editing title when not allowed #1412
- 🐛(frontend) scroll back to top when navigate to a document #1406
- 🐛(frontend) fix export pdf emoji problem #1453
- 🐛(frontend) fix attachment download filename #1447
- 🐛(frontend) exclude h4-h6 headings from table of contents #1441
- 🔒(frontend) prevent readers from changing callout emoji #1449
- 🐛(frontend) fix overlapping placeholders in multi-column layout #1455
- 🐛(backend) filter invitation with case insensitive email #1457
- 🐛(frontend) reduce no access image size from 450 to 300 #1463
- 🐛(frontend) preserve interlink style on drag-and-drop in editor #1460
- ✨(frontend) load docs logo from public folder via url #1462
- 🔧(keycloak) Fix https required issue in dev mode #1286
## Removed
- 🔥(frontend) remove custom DividerBlock ##1375
## [3.7.0] - 2025-09-12
### Added
- ✨(api) add API route to fetch document content #1206
- ✨(frontend) doc emojis improvements #1381
- add an EmojiPicker in the document tree and document title
- remove emoji buttons in menus
### Changed
- 🔒️(backend) configure throttle on every viewsets #1343
- ⬆️ Bump eslint to V9 #1071
- ♿(frontend) improve accessibility:
- ♿fix major accessibility issues reported by wave and axe #1344
- ✨unify tab focus style for better visual consistency #1341
- ✨improve modal a11y: structure, labels, and title #1349
- ✨improve accessibility of cdoc content with correct aria tags #1271
- ✨unify tab focus style for better visual consistency #1341
- ♿hide decorative icons, label menus, avoid accessible name… #1362
- ♻️(tilt) use helm dev-backend chart
- 🩹(frontend) on main pages do not display leading emoji as page icon #1381
- 🩹(frontend) handle properly emojis in interlinking #1381
### Removed
- 🔥(frontend) remove multi column drop cursor #1370
### Fixed
- 🐛(frontend) fix callout emoji list #1366
## [3.6.0] - 2025-09-04
### Added
- 👷(CI) add bundle size check job #1268
- ✨(frontend) use title first emoji as doc icon in tree #1289
### Changed
- ♻️(docs-app) Switch from Jest tests to Vitest #1269
- ♿(frontend) improve accessibility:
- 🌐(frontend) set html lang attribute dynamically #1248
- ♿(frontend) inject language attribute to pdf export #1235
- ♿(frontend) improve accessibility of search modal #1275
- ♿(frontend) add correct attributes to icons #1255
- 🎨(frontend) improve nav structure #1262
- ♿️(frontend) keyboard interaction with menu #1244
- ♿(frontend) improve header accessibility #1270
- ♿(frontend) improve accessibility for decorative images in editor #1282
- #1338
- #1281
- ♻️(backend) fallback to email identifier when no name #1298
- 🐛(backend) allow ASCII characters in user sub field #1295
- ⚡️(frontend) improve fallback width calculation #1333
### Fixed
- 🐛(makefile) Windows compatibility fix for Docker volume mounting #1263
- 🐛(minio) fix user permission error with Minio and Windows #1263
- 🐛(frontend) fix export when quote block and inline code #1319
- 🐛(frontend) fix base64 font #1324
- 🐛(backend) allow creator to delete subpages #1297
- 🐛(frontend) fix dnd conflict with tree and Blocknote #1328
- 🐛(frontend) fix display bug on homepage #1332
- 🐛link role update #1287
## [3.5.0] - 2025-07-31
### Added
- ✨(helm) Service Account support for K8s Resources in Helm Charts #780
- ✨(backend) allow masking documents from the list view #1172
- ✨(frontend) subdocs can manage link reach #1190
- ✨(frontend) add duplicate action to doc tree #1175
- ✨(frontend) Interlinking doc #904
- ✨(frontend) add multi columns support for editor #1219
### Changed
- ♻️(frontend) search on all docs if no children #1184
- ♻️(frontend) redirect to doc after duplicate #1175
- 🔧(project) change env.d system by using local files #1200
- ⚡️(frontend) improve tree stability #1207
- ⚡️(frontend) improve accessibility #1232
- 🛂(frontend) block drag n drop when not desktop #1239
### Fixed
- 🐛(service-worker) Fix useOffline Maximum update depth exceeded #1196
- 🐛(frontend) fix empty left panel after deleting root doc #1197
- 🐛(helm) charts generate invalid YAML for collaboration API / WS #890
- 🐛(frontend) 401 redirection overridden #1214
- 🐛(frontend) include root parent in search #1243
## [3.4.2] - 2025-07-18
### Changed
- ⚡️(docker) Optimize Dockerfile to use apk with --no-cache #743
### Fixed
- 🐛(backend) improve prompt to not use code blocks delimiter #1188
## [3.4.1] - 2025-07-15
### Fixed
- 🌐(frontend) keep simple tag during export #1154
- 🐛(back) manage can-edit endpoint without created room
in the ws #1152
- 🐛(frontend) fix action buttons not clickable #1162
- 🐛(frontend) fix crash share modal on grid options #1174
- 🐛(frontend) fix unfold subdocs not clickable at the bottom #1179
## [3.4.0] - 2025-07-09
### Added
- ✨(frontend) multi-pages #701
- ✨(frontend) Duplicate a doc #1078
- ✨Ask for access #1081
- ✨(frontend) add customization for translations #857
- ✨(backend) add ancestors links definitions to document abilities #846
- ✨(backend) include ancestors accesses on document accesses list view #846
- ✨(backend) add ancestors links reach and role to document API #846
- 📝(project) add troubleshoot doc #1066
- 📝(project) add system-requirement doc #1066
- 🔧(frontend) configure x-frame-options to DENY in nginx conf #1084
- ✨(backend) allow to disable checking unsafe mimetype on
attachment upload #1099
- ✨(doc) add documentation to install with compose #855
- ✨ Give priority to users connected to collaboration server
(aka no websocket feature) #1093
### Changed
- ♻️(backend) stop requiring owner for non-root documents #846
- ♻️(backend) simplify roles by ranking them and return only the max role #846
- 📌(yjs) stop pinning node to minor version on yjs docker image #1005 - 📌(yjs) stop pinning node to minor version on yjs docker image #1005
- 🧑‍💻(docker) add .next to .dockerignore #1055 - 🧑‍💻(docker) add .next to .dockerignore #1055
- 🧑‍💻(docker) handle frontend development images with docker compose #1033 - 🧑‍💻(docker) handle frontend development images with docker compose #1033
@@ -28,19 +309,18 @@ and this project adheres to
### Fixed ### Fixed
- 🐛(backend) fix link definition select options linked to ancestors #846
- 🐛(frontend) table of content disappearing #982 - 🐛(frontend) table of content disappearing #982
- 🐛(frontend) fix multiple EmojiPicker #1012 - 🐛(frontend) fix multiple EmojiPicker #1012
- 🐛(frontend) fix meta title #1017 - 🐛(frontend) fix meta title #1017
- 🔧(git) set LF line endings for all text files #1032 - 🔧(git) set LF line endings for all text files #1032
- 📝(docs) minor fixes to docs/env.md - 📝(docs) minor fixes to docs/env.md
-(backend) support `_FILE` environment variables for secrets #912 - ✨support `_FILE` environment variables for secrets #912
- ✨(frontend) support `_FILE` environment variables for secrets #912
### Removed ### Removed
- 🔥(frontend) remove Beta from logo #1095 - 🔥(frontend) remove Beta from logo #1095
## [3.3.0] - 2025-05-06 ## [3.3.0] - 2025-05-06
### Added ### Added
@@ -66,13 +346,13 @@ and this project adheres to
- ⬆️(docker) upgrade node images to alpine 3.21 #973 - ⬆️(docker) upgrade node images to alpine 3.21 #973
### Fixed ### Fixed
- 🐛(y-provider) increase JSON size limits for transcription conversion #989 - 🐛(y-provider) increase JSON size limits for transcription conversion #989
### Removed ### Removed
- 🔥(back) remove footer endpoint #948 - 🔥(back) remove footer endpoint #948
## [3.2.1] - 2025-05-06 ## [3.2.1] - 2025-05-06
## Fixed ## Fixed
@@ -80,7 +360,6 @@ and this project adheres to
- 🐛(frontend) fix list copy paste #943 - 🐛(frontend) fix list copy paste #943
- 📝(doc) update contributing policy (commit signatures are now mandatory) #895 - 📝(doc) update contributing policy (commit signatures are now mandatory) #895
## [3.2.0] - 2025-05-05 ## [3.2.0] - 2025-05-05
## Added ## Added
@@ -91,7 +370,7 @@ and this project adheres to
- ✨(settings) Allow configuring PKCE for the SSO #886 - ✨(settings) Allow configuring PKCE for the SSO #886
- 🌐(i18n) activate chinese and spanish languages #884 - 🌐(i18n) activate chinese and spanish languages #884
- 🔧(backend) allow overwriting the data directory #893 - 🔧(backend) allow overwriting the data directory #893
- (backend) add `django-lasuite` dependency #839 - (backend) add `django-lasuite` dependency #839
- ✨(frontend) advanced table features #908 - ✨(frontend) advanced table features #908
## Changed ## Changed
@@ -142,7 +421,6 @@ and this project adheres to
- 🐛(backend) compute ancestor_links in get_abilities if needed #725 - 🐛(backend) compute ancestor_links in get_abilities if needed #725
- 🔒️(back) restrict access to document accesses #801 - 🔒️(back) restrict access to document accesses #801
## [2.6.0] - 2025-03-21 ## [2.6.0] - 2025-03-21
## Added ## Added
@@ -161,7 +439,6 @@ and this project adheres to
- 🔒️(back) throttle user list endpoint #636 - 🔒️(back) throttle user list endpoint #636
- 🔒️(back) remove pagination and limit to 5 for user list endpoint #636 - 🔒️(back) remove pagination and limit to 5 for user list endpoint #636
## [2.5.0] - 2025-03-18 ## [2.5.0] - 2025-03-18
## Added ## Added
@@ -184,15 +461,14 @@ and this project adheres to
## Fixed ## Fixed
- 🐛(frontend) SVG export #706 - 🐛(frontend) SVG export #706
- 🐛(frontend) remove scroll listener table content #688 - 🐛(frontend) remove scroll listener table content #688
- 🔒️(back) restrict access to favorite_list endpoint #690 - 🔒️(back) restrict access to favorite_list endpoint #690
- 🐛(backend) refactor to fix filtering on children - 🐛(backend) refactor to fix filtering on children
and descendants views #695 and descendants views #695
- 🐛(action) fix notify-argocd workflow #713 - 🐛(action) fix notify-argocd workflow #713
- 🚨(helm) fix helmfile lint #736 - 🚨(helm) fix helmfile lint #736
- 🚚(frontend) redirect to 401 page when 401 error #759 - 🚚(frontend) redirect to 401 page when 401 error #759
## [2.4.0] - 2025-03-06 ## [2.4.0] - 2025-03-06
## Added ## Added
@@ -207,7 +483,6 @@ and this project adheres to
- 🐛(frontend) fix collaboration error #684 - 🐛(frontend) fix collaboration error #684
## [2.3.0] - 2025-03-03 ## [2.3.0] - 2025-03-03
## Added ## Added
@@ -623,33 +898,44 @@ and this project adheres to
- ✨(frontend) Coming Soon page (#67) - ✨(frontend) Coming Soon page (#67)
- 🚀 Impress, project to manage your documents easily and collaboratively. - 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/numerique-gouv/impress/compare/v3.3.0...main [unreleased]: https://github.com/suitenumerique/docs/compare/v3.10.0...main
[v3.3.0]: https://github.com/numerique-gouv/impress/releases/v3.3.0 [v3.10.0]: https://github.com/suitenumerique/docs/releases/v3.10.0
[v3.2.1]: https://github.com/numerique-gouv/impress/releases/v3.2.1 [v3.9.0]: https://github.com/suitenumerique/docs/releases/v3.9.0
[v3.2.0]: https://github.com/numerique-gouv/impress/releases/v3.2.0 [v3.8.2]: https://github.com/suitenumerique/docs/releases/v3.8.2
[v3.1.0]: https://github.com/numerique-gouv/impress/releases/v3.1.0 [v3.8.1]: https://github.com/suitenumerique/docs/releases/v3.8.1
[v3.0.0]: https://github.com/numerique-gouv/impress/releases/v3.0.0 [v3.8.0]: https://github.com/suitenumerique/docs/releases/v3.8.0
[v2.6.0]: https://github.com/numerique-gouv/impress/releases/v2.6.0 [v3.7.0]: https://github.com/suitenumerique/docs/releases/v3.7.0
[v2.5.0]: https://github.com/numerique-gouv/impress/releases/v2.5.0 [v3.6.0]: https://github.com/suitenumerique/docs/releases/v3.6.0
[v2.4.0]: https://github.com/numerique-gouv/impress/releases/v2.4.0 [v3.5.0]: https://github.com/suitenumerique/docs/releases/v3.5.0
[v2.3.0]: https://github.com/numerique-gouv/impress/releases/v2.3.0 [v3.4.2]: https://github.com/suitenumerique/docs/releases/v3.4.2
[v2.2.0]: https://github.com/numerique-gouv/impress/releases/v2.2.0 [v3.4.1]: https://github.com/suitenumerique/docs/releases/v3.4.1
[v2.1.0]: https://github.com/numerique-gouv/impress/releases/v2.1.0 [v3.4.0]: https://github.com/suitenumerique/docs/releases/v3.4.0
[v2.0.1]: https://github.com/numerique-gouv/impress/releases/v2.0.1 [v3.3.0]: https://github.com/suitenumerique/docs/releases/v3.3.0
[v2.0.0]: https://github.com/numerique-gouv/impress/releases/v2.0.0 [v3.2.1]: https://github.com/suitenumerique/docs/releases/v3.2.1
[v1.10.0]: https://github.com/numerique-gouv/impress/releases/v1.10.0 [v3.2.0]: https://github.com/suitenumerique/docs/releases/v3.2.0
[v1.9.0]: https://github.com/numerique-gouv/impress/releases/v1.9.0 [v3.1.0]: https://github.com/suitenumerique/docs/releases/v3.1.0
[v1.8.2]: https://github.com/numerique-gouv/impress/releases/v1.8.2 [v3.0.0]: https://github.com/suitenumerique/docs/releases/v3.0.0
[v1.8.1]: https://github.com/numerique-gouv/impress/releases/v1.8.1 [v2.6.0]: https://github.com/suitenumerique/docs/releases/v2.6.0
[v1.8.0]: https://github.com/numerique-gouv/impress/releases/v1.8.0 [v2.5.0]: https://github.com/suitenumerique/docs/releases/v2.5.0
[v1.7.0]: https://github.com/numerique-gouv/impress/releases/v1.7.0 [v2.4.0]: https://github.com/suitenumerique/docs/releases/v2.4.0
[v1.6.0]: https://github.com/numerique-gouv/impress/releases/v1.6.0 [v2.3.0]: https://github.com/suitenumerique/docs/releases/v2.3.0
[1.5.1]: https://github.com/numerique-gouv/impress/releases/v1.5.1 [v2.2.0]: https://github.com/suitenumerique/docs/releases/v2.2.0
[1.5.0]: https://github.com/numerique-gouv/impress/releases/v1.5.0 [v2.1.0]: https://github.com/suitenumerique/docs/releases/v2.1.0
[1.4.0]: https://github.com/numerique-gouv/impress/releases/v1.4.0 [v2.0.1]: https://github.com/suitenumerique/docs/releases/v2.0.1
[1.3.0]: https://github.com/numerique-gouv/impress/releases/v1.3.0 [v2.0.0]: https://github.com/suitenumerique/docs/releases/v2.0.0
[1.2.1]: https://github.com/numerique-gouv/impress/releases/v1.2.1 [v1.10.0]: https://github.com/suitenumerique/docs/releases/v1.10.0
[1.2.0]: https://github.com/numerique-gouv/impress/releases/v1.2.0 [v1.9.0]: https://github.com/suitenumerique/docs/releases/v1.9.0
[1.1.0]: https://github.com/numerique-gouv/impress/releases/v1.1.0 [v1.8.2]: https://github.com/suitenumerique/docs/releases/v1.8.2
[1.0.0]: https://github.com/numerique-gouv/impress/releases/v1.0.0 [v1.8.1]: https://github.com/suitenumerique/docs/releases/v1.8.1
[0.1.0]: https://github.com/numerique-gouv/impress/releases/v0.1.0 [v1.8.0]: https://github.com/suitenumerique/docs/releases/v1.8.0
[v1.7.0]: https://github.com/suitenumerique/docs/releases/v1.7.0
[v1.6.0]: https://github.com/suitenumerique/docs/releases/v1.6.0
[1.5.1]: https://github.com/suitenumerique/docs/releases/v1.5.1
[1.5.0]: https://github.com/suitenumerique/docs/releases/v1.5.0
[1.4.0]: https://github.com/suitenumerique/docs/releases/v1.4.0
[1.3.0]: https://github.com/suitenumerique/docs/releases/v1.3.0
[1.2.1]: https://github.com/suitenumerique/docs/releases/v1.2.1
[1.2.0]: https://github.com/suitenumerique/docs/releases/v1.2.0
[1.1.0]: https://github.com/suitenumerique/docs/releases/v1.1.0
[1.0.0]: https://github.com/suitenumerique/docs/releases/v1.0.0
[0.1.0]: https://github.com/suitenumerique/docs/releases/v0.1.0

View File

@@ -7,8 +7,7 @@ FROM python:3.13.3-alpine AS base
RUN python -m pip install --upgrade pip setuptools RUN python -m pip install --upgrade pip setuptools
# Upgrade system packages to install security updates # Upgrade system packages to install security updates
RUN apk update && \ RUN apk update && apk upgrade --no-cache
apk upgrade
# ---- Back-end builder image ---- # ---- Back-end builder image ----
FROM base AS back-builder FROM base AS back-builder
@@ -45,7 +44,7 @@ FROM base AS link-collector
ARG IMPRESS_STATIC_ROOT=/data/static ARG IMPRESS_STATIC_ROOT=/data/static
# Install pango & rdfind # Install pango & rdfind
RUN apk add \ RUN apk add --no-cache \
pango \ pango \
rdfind rdfind
@@ -71,7 +70,7 @@ FROM base AS core
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
# Install required system libs # Install required system libs
RUN apk add \ RUN apk add --no-cache \
cairo \ cairo \
file \ file \
font-noto \ font-noto \
@@ -95,6 +94,14 @@ RUN chmod g=u /etc/passwd
# Copy installed python dependencies # Copy installed python dependencies
COPY --from=back-builder /install /usr/local COPY --from=back-builder /install /usr/local
# Link certifi certificate from a static path /cert/cacert.pem to avoid issues
# when python is upgraded and the path to the certificate changes.
# The space between print and the ( is intended otherwise the git lint is failing
RUN mkdir /cert && \
path=`python -c 'import certifi;print (certifi.where())'` && \
mv $path /cert/ && \
ln -s /cert/cacert.pem $path
# Copy impress application (see .dockerignore) # Copy impress application (see .dockerignore)
COPY ./src/backend /app/ COPY ./src/backend /app/
@@ -117,7 +124,7 @@ FROM core AS backend-development
USER root:root USER root:root
# Install psql # Install psql
RUN apk add postgresql-client RUN apk add --no-cache postgresql-client
# Uninstall impress and re-install it in editable mode along with development # Uninstall impress and re-install it in editable mode along with development
# dependencies # dependencies

116
Makefile
View File

@@ -35,9 +35,13 @@ DB_PORT = 5432
# -- Docker # -- Docker
# Get the current user ID to use for docker run and docker exec commands # Get the current user ID to use for docker run and docker exec commands
DOCKER_UID = $(shell id -u) ifeq ($(OS),Windows_NT)
DOCKER_GID = $(shell id -g) DOCKER_USER := 0:0 # run containers as root on Windows
DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID) else
DOCKER_UID := $(shell id -u)
DOCKER_GID := $(shell id -g)
DOCKER_USER := $(DOCKER_UID):$(DOCKER_GID)
endif
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
COMPOSE_E2E = DOCKER_USER=$(DOCKER_USER) docker compose -f compose.yml -f compose-e2e.yml COMPOSE_E2E = DOCKER_USER=$(DOCKER_USER) docker compose -f compose.yml -f compose-e2e.yml
COMPOSE_EXEC = $(COMPOSE) exec COMPOSE_EXEC = $(COMPOSE) exec
@@ -48,7 +52,7 @@ COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
# -- Backend # -- Backend
MANAGE = $(COMPOSE_RUN_APP) python manage.py MANAGE = $(COMPOSE_RUN_APP) python manage.py
MAIL_YARN = $(COMPOSE_RUN) -w /app/src/mail node yarn MAIL_YARN = $(COMPOSE_RUN) -w //app/src/mail node yarn
# -- Frontend # -- Frontend
PATH_FRONT = ./src/frontend PATH_FRONT = ./src/frontend
@@ -67,18 +71,18 @@ data/static:
# -- Project # -- Project
create-env-files: ## Copy the dist env files to env files create-env-local-files: ## create env.local files in env.d/development
create-env-files: \ create-env-local-files:
env.d/development/common \ @touch env.d/development/crowdin.local
env.d/development/crowdin \ @touch env.d/development/common.local
env.d/development/postgresql \ @touch env.d/development/postgresql.local
env.d/development/kc_postgresql @touch env.d/development/kc_postgresql.local
.PHONY: create-env-files .PHONY: create-env-local-files
pre-bootstrap: \ pre-bootstrap: \
data/media \ data/media \
data/static \ data/static \
create-env-files create-env-local-files
.PHONY: pre-bootstrap .PHONY: pre-bootstrap
post-bootstrap: \ post-bootstrap: \
@@ -89,13 +93,77 @@ post-bootstrap: \
mails-build mails-build
.PHONY: post-bootstrap .PHONY: post-bootstrap
pre-beautiful-bootstrap: ## Display a welcome message before bootstrap
ifeq ($(OS),Windows_NT)
@echo ""
@echo "================================================================================"
@echo ""
@echo " Welcome to Docs - Collaborative Text Editing from La Suite!"
@echo ""
@echo " This will set up your development environment with:"
@echo " - Docker containers for all services"
@echo " - Database migrations and static files"
@echo " - Frontend dependencies and build"
@echo " - Environment configuration files"
@echo ""
@echo " Services will be available at:"
@echo " - Frontend: http://localhost:3000"
@echo " - API: http://localhost:8071"
@echo " - Admin: http://localhost:8071/admin"
@echo ""
@echo "================================================================================"
@echo ""
@echo "Starting bootstrap process..."
else
@echo "$(BOLD)"
@echo "╔══════════════════════════════════════════════════════════════════════════════╗"
@echo "║ ║"
@echo "║ 🚀 Welcome to Docs - Collaborative Text Editing from La Suite ! 🚀 ║"
@echo "║ ║"
@echo "║ This will set up your development environment with : ║"
@echo "║ • Docker containers for all services ║"
@echo "║ • Database migrations and static files ║"
@echo "║ • Frontend dependencies and build ║"
@echo "║ • Environment configuration files ║"
@echo "║ ║"
@echo "║ Services will be available at: ║"
@echo "║ • Frontend: http://localhost:3000 ║"
@echo "║ • API: http://localhost:8071 ║"
@echo "║ • Admin: http://localhost:8071/admin ║"
@echo "║ ║"
@echo "╚══════════════════════════════════════════════════════════════════════════════╝"
@echo "$(RESET)"
@echo "$(GREEN)Starting bootstrap process...$(RESET)"
endif
@echo ""
.PHONY: pre-beautiful-bootstrap
bootstrap: ## Prepare Docker developmentimages for the project post-beautiful-bootstrap: ## Display a success message after bootstrap
@echo ""
ifeq ($(OS),Windows_NT)
@echo "Bootstrap completed successfully!"
@echo ""
@echo "Next steps:"
@echo " - Visit http://localhost:3000 to access the application"
@echo " - Run 'make help' to see all available commands"
else
@echo "$(GREEN)🎉 Bootstrap completed successfully!$(RESET)"
@echo ""
@echo "$(BOLD)Next steps:$(RESET)"
@echo " • Visit http://localhost:3000 to access the application"
@echo " • Run 'make help' to see all available commands"
endif
@echo ""
.PHONY: post-beautiful-bootstrap
bootstrap: ## Prepare the project for local development
bootstrap: \ bootstrap: \
pre-beautiful-bootstrap \
pre-bootstrap \ pre-bootstrap \
build \ build \
post-bootstrap \ post-bootstrap \
run run \
post-beautiful-bootstrap
.PHONY: bootstrap .PHONY: bootstrap
bootstrap-e2e: ## Prepare Docker production images to be used for e2e tests bootstrap-e2e: ## Prepare Docker production images to be used for e2e tests
@@ -258,20 +326,6 @@ resetdb: ## flush database and create a superuser "admin"
@${MAKE} superuser @${MAKE} superuser
.PHONY: resetdb .PHONY: resetdb
env.d/development/common:
cp -n env.d/development/common.dist env.d/development/common
env.d/development/postgresql:
cp -n env.d/development/postgresql.dist env.d/development/postgresql
env.d/development/kc_postgresql:
cp -n env.d/development/kc_postgresql.dist env.d/development/kc_postgresql
# -- Internationalization
env.d/development/crowdin:
cp -n env.d/development/crowdin.dist env.d/development/crowdin
crowdin-download: ## Download translated message from crowdin crowdin-download: ## Download translated message from crowdin
@$(COMPOSE_RUN_CROWDIN) download -c crowdin/config.yml @$(COMPOSE_RUN_CROWDIN) download -c crowdin/config.yml
.PHONY: crowdin-download .PHONY: crowdin-download
@@ -352,6 +406,10 @@ run-frontend-development: ## Run the frontend in development mode
cd $(PATH_FRONT_IMPRESS) && yarn dev cd $(PATH_FRONT_IMPRESS) && yarn dev
.PHONY: run-frontend-development .PHONY: run-frontend-development
frontend-test: ## Run the frontend tests
cd $(PATH_FRONT_IMPRESS) && yarn test
.PHONY: frontend-test
frontend-i18n-extract: ## Extract the frontend translation inside a json to be used for crowdin frontend-i18n-extract: ## Extract the frontend translation inside a json to be used for crowdin
cd $(PATH_FRONT) && yarn i18n:extract cd $(PATH_FRONT) && yarn i18n:extract
.PHONY: frontend-i18n-extract .PHONY: frontend-i18n-extract
@@ -382,6 +440,6 @@ bump-packages-version: ## bump the version of the project - VERSION_TYPE can be
cd ./src/frontend/apps/e2e/ && yarn version --no-git-tag-version --$(VERSION_TYPE) cd ./src/frontend/apps/e2e/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/apps/impress/ && yarn version --no-git-tag-version --$(VERSION_TYPE) cd ./src/frontend/apps/impress/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/servers/y-provider/ && yarn version --no-git-tag-version --$(VERSION_TYPE) cd ./src/frontend/servers/y-provider/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/packages/eslint-config-impress/ && yarn version --no-git-tag-version --$(VERSION_TYPE) cd ./src/frontend/packages/eslint-plugin-docs/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/packages/i18n/ && yarn version --no-git-tag-version --$(VERSION_TYPE) cd ./src/frontend/packages/i18n/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
.PHONY: bump-packages-version .PHONY: bump-packages-version

View File

@@ -34,8 +34,6 @@ Docs, where your notes can become knowledge through live collaboration.
## Why use Docs ❓ ## Why use Docs ❓
Docs is a collaborative text editor designed to address common challenges in knowledge building and sharing. Docs is a collaborative text editor designed to address common challenges in knowledge building and sharing.
It offers a scalable and secure alternative to tools such as Google Docs, Notion (without the dbs), Outline, or Confluence.
### Write ### Write
* 😌 Get simple, accessible online editing for your team. * 😌 Get simple, accessible online editing for your team.
* 💅 Create clean documents with beautiful formatting options. * 💅 Create clean documents with beautiful formatting options.
@@ -51,13 +49,24 @@ It offers a scalable and secure alternative to tools such as Google Docs, Notion
* 📚 Turn your team's collaborative work into organized knowledge with Subpages. * 📚 Turn your team's collaborative work into organized knowledge with Subpages.
### Self-host ### Self-host
🚀 Docs is easy to install on your own servers
Available methods: Helm chart, Nix package #### 🚀 Docs is easy to install on your own servers
We use Kubernetes for our [production instance](https://docs.numerique.gouv.fr/) but also support Docker Compose. The community contributed a couple other methods (Nix, YunoHost etc.) check out the [docs](/docs/installation/README.md) to get detailed instructions and examples.
In the works: Docker Compose, YunoHost #### 🌍 Known instances
We hope to see many more, here is an incomplete list of public Docs instances. Feel free to make a PR to add ones that are not listed below🙏
⚠️ For some advanced features (ex: Export as PDF) Docs relies on XL packages from BlockNote. These are licenced under AGPL-3.0 and are not MIT compatible. You can perfectly use Docs without these packages by setting the environment variable `PUBLISH_AS_MIT` to true. That way you'll build an image of the application without the features that are not MIT compatible. Read the [environment variables documentation](/docs/env.md) for more information. | Url | Org | Public |
| --- | --- | ------- |
| [docs.numerique.gouv.fr](https://docs.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up|
| [docs.suite.anct.gouv.fr](https://docs.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up|
| [notes.demo.opendesk.eu](https://notes.demo.opendesk.eu) | ZenDiS | Demo instance of OpenDesk. Request access to get credentials |
| [notes.liiib.re](https://notes.liiib.re/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
| [docs.federated.nexus](https://docs.federated.nexus/) | federated.nexus | Public instance, but you have to [sign up for a Federated Nexus account](https://federated.nexus/register/). |
| [docs.demo.mosacloud.eu](https://docs.demo.mosacloud.eu/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
#### ⚠️ Advanced features
For some advanced features (ex: Export as PDF) Docs relies on XL packages from BlockNote. These are licenced under GPL and are not MIT compatible. You can perfectly use Docs without these packages by setting the environment variable `PUBLISH_AS_MIT` to true. That way you'll build an image of the application without the features that are not MIT compatible. Read the [environment variables documentation](/docs/env.md) for more information.
## Getting started 🔧 ## Getting started 🔧
@@ -132,6 +141,12 @@ To start all the services, except the frontend container, you can use the follow
$ make run-backend $ make run-backend
``` ```
To execute frontend tests & linting only
```shellscript
$ make frontend-test
$ make frontend-lint
```
**Adding content** **Adding content**
You can create a basic demo site by running this command: You can create a basic demo site by running this command:

View File

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

View File

@@ -38,6 +38,10 @@ function _set_user() {
# options: docker compose command options # options: docker compose command options
# ARGS : docker compose command arguments # ARGS : docker compose command arguments
function _docker_compose() { function _docker_compose() {
# Set DOCKER_USER for Windows compatibility with MinIO
if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" || -n "${WSL_DISTRO_NAME:-}" ]]; then
export DOCKER_USER="0:0"
fi
echo "🐳(compose) file: '${COMPOSE_FILE}'" echo "🐳(compose) file: '${COMPOSE_FILE}'"
docker compose \ docker compose \

View File

@@ -24,5 +24,6 @@ services:
restart: unless-stopped restart: unless-stopped
env_file: env_file:
- env.d/development/common - env.d/development/common
- env.d/development/common.local
ports: ports:
- "4444:4444" - "4444:4444"

View File

@@ -10,6 +10,7 @@ services:
retries: 300 retries: 300
env_file: env_file:
- env.d/development/postgresql - env.d/development/postgresql
- env.d/development/postgresql.local
ports: ports:
- "15432:5432" - "15432:5432"
@@ -66,7 +67,9 @@ services:
- DJANGO_CONFIGURATION=Development - DJANGO_CONFIGURATION=Development
env_file: env_file:
- env.d/development/common - env.d/development/common
- env.d/development/common.local
- env.d/development/postgresql - env.d/development/postgresql
- env.d/development/postgresql.local
ports: ports:
- "8071:8000" - "8071:8000"
volumes: volumes:
@@ -91,7 +94,9 @@ services:
- DJANGO_CONFIGURATION=Development - DJANGO_CONFIGURATION=Development
env_file: env_file:
- env.d/development/common - env.d/development/common
- env.d/development/common.local
- env.d/development/postgresql - env.d/development/postgresql
- env.d/development/postgresql.local
volumes: volumes:
- ./src/backend:/app - ./src/backend:/app
- ./data/static:/data/static - ./data/static:/data/static
@@ -135,6 +140,7 @@ services:
- ".:/app" - ".:/app"
env_file: env_file:
- env.d/development/crowdin - env.d/development/crowdin
- env.d/development/crowdin.local
user: "${DOCKER_USER:-1000}" user: "${DOCKER_USER:-1000}"
working_dir: /app working_dir: /app
@@ -156,6 +162,7 @@ services:
restart: unless-stopped restart: unless-stopped
env_file: env_file:
- env.d/development/common - env.d/development/common
- env.d/development/common.local
ports: ports:
- "4444:4444" - "4444:4444"
volumes: volumes:
@@ -174,24 +181,23 @@ services:
- "5433:5432" - "5433:5432"
env_file: env_file:
- env.d/development/kc_postgresql - env.d/development/kc_postgresql
- env.d/development/kc_postgresql.local
keycloak: keycloak:
image: quay.io/keycloak/keycloak:20.0.1 image: quay.io/keycloak/keycloak:26.3
volumes: volumes:
- ./docker/auth/realm.json:/opt/keycloak/data/import/realm.json - ./docker/auth/realm.json:/opt/keycloak/data/import/realm.json
command: command:
- start-dev - start-dev
- --features=preview - --features=preview
- --import-realm - --import-realm
- --proxy=edge - --hostname=http://localhost:8083
- --hostname-url=http://localhost:8083
- --hostname-admin-url=http://localhost:8083/
- --hostname-strict=false - --hostname-strict=false
- --hostname-strict-https=false
- --health-enabled=true - --health-enabled=true
- --metrics-enabled=true - --metrics-enabled=true
healthcheck: healthcheck:
test: ["CMD", "curl", "--head", "-fsS", "http://localhost:8080/health/ready"] test: ['CMD-SHELL', 'exec 3<>/dev/tcp/localhost/9000; echo -e "GET /health/live HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" >&3; grep "HTTP/1.1 200 OK" <&3']
start_period: 5s
interval: 1s interval: 1s
timeout: 2s timeout: 2s
retries: 300 retries: 300

View File

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

View File

@@ -11,6 +11,9 @@ server {
server_name localhost; server_name localhost;
charset utf-8; charset utf-8;
# increase max upload size
client_max_body_size 10m;
# Disables server version feedback on pages and in headers # Disables server version feedback on pages and in headers
server_tokens off; server_tokens off;
@@ -68,7 +71,7 @@ server {
proxy_set_header Host $host; proxy_set_header Host $host;
} }
location /collaboration/api/ { location /collaboration/api/ {
# Collaboration server # Collaboration server
proxy_pass http://${YPROVIDER_HOST}:4444; proxy_pass http://${YPROVIDER_HOST}:4444;
proxy_set_header Host $host; proxy_set_header Host $host;
@@ -95,7 +98,7 @@ server {
add_header Content-Security-Policy "default-src 'none'" always; add_header Content-Security-Policy "default-src 'none'" always;
} }
location /media-auth { location /media-auth {
proxy_pass http://docs_backend/api/v1.0/documents/media-auth/; proxy_pass http://docs_backend/api/v1.0/documents/media-auth/;
proxy_set_header X-Forwarded-Proto https; proxy_set_header X-Forwarded-Proto https;
@@ -109,4 +112,4 @@ server {
proxy_set_header Content-Length ""; proxy_set_header Content-Length "";
proxy_set_header X-Original-Method $request_method; proxy_set_header X-Original-Method $request_method;
} }
} }

View File

@@ -6,104 +6,104 @@ Here we describe all environment variables that can be set for the docs applicat
These are the environment variables you can set for the `impress-backend` container. These are the environment variables you can set for the `impress-backend` container.
| Option | Description | default | | Option | Description | default |
| ----------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------- | |-------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------|
| AI_ALLOW_REACH_FROM | Users that can use AI must be this level. options are "public", "authenticated", "restricted" | authenticated | | 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_API_KEY | AI key to be used for AI Base url | |
| AI_BASE_URL | OpenAI compatible AI base url | | | AI_BASE_URL | OpenAI compatible AI base url | |
| AI_FEATURE_ENABLED | Enable AI options | false | | AI_FEATURE_ENABLED | Enable AI options | false |
| AI_MODEL | AI Model to use | | | AI_MODEL | AI Model to use | |
| ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true | | ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true |
| API_USERS_LIST_LIMIT | Limit on API users | 5 | | 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_BURST | Throttle rate for api on burst | 30/minute |
| API_USERS_LIST_THROTTLE_RATE_SUSTAINED | throttle rate for api | 180/hour | | API_USERS_LIST_THROTTLE_RATE_SUSTAINED | Throttle rate for api | 180/hour |
| AWS_S3_ACCESS_KEY_ID | access id for s3 endpoint | | | AWS_S3_ACCESS_KEY_ID | Access id for s3 endpoint | |
| AWS_S3_ENDPOINT_URL | S3 endpoint | | | AWS_S3_ENDPOINT_URL | S3 endpoint | |
| AWS_S3_REGION_NAME | region name for s3 endpoint | | | AWS_S3_REGION_NAME | Region name for s3 endpoint | |
| AWS_S3_SECRET_ACCESS_KEY | access key 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 | | AWS_STORAGE_BUCKET_NAME | Bucket name for s3 endpoint | impress-media-storage |
| CACHES_DEFAULT_TIMEOUT | cache default timeout | 30 | | CACHES_DEFAULT_TIMEOUT | Cache default timeout | 30 |
| CACHES_KEY_PREFIX | The prefix used to every cache keys. | docs | | CACHES_KEY_PREFIX | The prefix used to every cache keys. | docs |
| COLLABORATION_API_URL | collaboration api host | | | COLLABORATION_API_URL | Collaboration api host | |
| COLLABORATION_SERVER_SECRET | collaboration api secret | | | COLLABORATION_SERVER_SECRET | Collaboration api secret | |
| COLLABORATION_WS_NOT_CONNECTED_READY_ONLY | Users not connected to the collaboration server cannot edit | false | | COLLABORATION_WS_NOT_CONNECTED_READY_ONLY | Users not connected to the collaboration server cannot edit | false |
| COLLABORATION_WS_URL | collaboration websocket url | | | COLLABORATION_WS_URL | Collaboration websocket url | |
| CONVERSION_API_CONTENT_FIELD | Conversion api content field | content | | CONVERSION_API_CONTENT_FIELD | Conversion api content field | content |
| CONVERSION_API_ENDPOINT | Conversion API endpoint | convert-markdown | | CONVERSION_API_ENDPOINT | Conversion API endpoint | convert |
| CONVERSION_API_SECURE | Require secure conversion api | false | | CONVERSION_API_SECURE | Require secure conversion api | false |
| CONVERSION_API_TIMEOUT | Conversion api timeout | 30 | | 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' | | CRISP_WEBSITE_ID | Crisp website id for support | |
| CONTENT_SECURITY_POLICY_EXCLUDE_URL_PREFIXES | Url with this prefix will not have the header Content-Security-Policy included | | | DB_ENGINE | Engine to use for database connections | django.db.backends.postgresql_psycopg2 |
| CRISP_WEBSITE_ID | crisp website id for support | | | DB_HOST | Host of the database | localhost |
| DB_ENGINE | engine to use for database connections | django.db.backends.postgresql_psycopg2 | | DB_NAME | Name of the database | impress |
| DB_HOST | host of the database | localhost | | DB_PASSWORD | Password to authenticate with | pass |
| DB_NAME | name of the database | impress | | DB_PORT | Port of the database | 5432 |
| DB_PASSWORD | password to authenticate with | pass | | DB_USER | User to authenticate with | dinum |
| DB_PORT | port of the database | 5432 | | DJANGO_ALLOWED_HOSTS | Allowed hosts | [] |
| DB_USER | user to authenticate with | dinum | | DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker transport options | {} |
| DJANGO_ALLOWED_HOSTS | allowed hosts | [] | | DJANGO_CELERY_BROKER_URL | Celery broker url | redis://redis:6379/0 |
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | celery broker transport options | {} | | DJANGO_CORS_ALLOW_ALL_ORIGINS | Allow all CORS origins | false |
| DJANGO_CELERY_BROKER_URL | celery broker url | redis://redis:6379/0 | | DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | List of origins allowed for CORS using regulair expressions | [] |
| DJANGO_CORS_ALLOW_ALL_ORIGINS | allow all CORS origins | false | | DJANGO_CORS_ALLOWED_ORIGINS | List of origins allowed for CORS | [] |
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | list of origins allowed for CORS using regulair expressions | [] | | DJANGO_CSRF_TRUSTED_ORIGINS | CSRF trusted origins | [] |
| DJANGO_CORS_ALLOWED_ORIGINS | list of origins allowed for CORS | [] | | DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
| DJANGO_CSRF_TRUSTED_ORIGINS | CSRF trusted origins | [] | | DJANGO_EMAIL_BRAND_NAME | Brand name for email | |
| DJANGO_EMAIL_BACKEND | email backend library | django.core.mail.backends.smtp.EmailBackend | | DJANGO_EMAIL_FROM | Email address used as sender | from@example.com |
| DJANGO_EMAIL_BRAND_NAME | brand name for email | | | DJANGO_EMAIL_HOST | Hostname of email | |
| DJANGO_EMAIL_FROM | email address used as sender | from@example.com | | DJANGO_EMAIL_HOST_PASSWORD | Password to authenticate with on the email host | |
| DJANGO_EMAIL_HOST | host name of email | | | DJANGO_EMAIL_HOST_USER | User to authenticate with on the email host | |
| DJANGO_EMAIL_HOST_PASSWORD | password to authenticate with on the email host | | | DJANGO_EMAIL_LOGO_IMG | Logo for the email | |
| DJANGO_EMAIL_HOST_USER | user to authenticate with on the email host | | | DJANGO_EMAIL_PORT | Port used to connect to email host | |
| DJANGO_EMAIL_LOGO_IMG | logo for the email | | | DJANGO_EMAIL_USE_SSL | Use ssl for email host connection | false |
| DJANGO_EMAIL_PORT | port used to connect to email host | | | DJANGO_EMAIL_USE_TLS | Use tls for email host connection | false |
| DJANGO_EMAIL_USE_SSL | use sstl for email host connection | false | | DJANGO_SECRET_KEY | Secret key | |
| DJANGO_EMAIL_USE_TLS | use tls for email host connection | false | | DJANGO_SERVER_TO_SERVER_API_TOKENS | | [] |
| DJANGO_SECRET_KEY | secret key | | | DOCUMENT_IMAGE_MAX_SIZE | Maximum size of document in bytes | 10485760 |
| DJANGO_SERVER_TO_SERVER_API_TOKENS | | [] | | FRONTEND_CSS_URL | To add a external css file to the app | |
| DOCUMENT_IMAGE_MAX_SIZE | maximum size of document in bytes | 10485760 | | FRONTEND_HOMEPAGE_FEATURE_ENABLED | Frontend feature flag to display the homepage | false |
| FRONTEND_CSS_URL | To add a external css file to the app | | | FRONTEND_THEME | Frontend theme to use | |
| FRONTEND_HOMEPAGE_FEATURE_ENABLED | frontend feature flag to display the homepage | false | | LANGUAGE_CODE | Default language | en-us |
| FRONTEND_THEME | frontend theme to use | | | LOGGING_LEVEL_LOGGERS_APP | Application logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
| LANGUAGE_CODE | default language | en-us | | LOGGING_LEVEL_LOGGERS_ROOT | Default logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
| LOGGING_LEVEL_LOGGERS_APP | application logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO | | LOGIN_REDIRECT_URL | Login redirect url | |
| LOGGING_LEVEL_LOGGERS_ROOT | default logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO | | LOGIN_REDIRECT_URL_FAILURE | Login redirect url on failure | |
| LOGIN_REDIRECT_URL | login redirect url | | | LOGOUT_REDIRECT_URL | Logout redirect url | |
| LOGIN_REDIRECT_URL_FAILURE | login redirect url on failure | | | MALWARE_DETECTION_BACKEND | The malware detection backend use from the django-lasuite package | lasuite.malware_detection.backends.dummy.DummyBackend |
| LOGOUT_REDIRECT_URL | logout redirect url | | | MALWARE_DETECTION_PARAMETERS | A dict containing all the parameters to initiate the malware detection backend | {"callback_path": "core.malware_detection.malware_detection_callback",} |
| MALWARE_DETECTION_BACKEND | The malware detection backend use from the django-lasuite package | lasuite.malware_detection.backends.dummy.DummyBackend | | MEDIA_BASE_URL | | |
| MALWARE_DETECTION_PARAMETERS | A dict containing all the parameters to initiate the malware detection backend | {"callback_path": "core.malware_detection.malware_detection_callback",} | | NO_WEBSOCKET_CACHE_TIMEOUT | Cache used to store current editor session key when only users without websocket are editing a document | 120 |
| MEDIA_BASE_URL | | | | OIDC_ALLOW_DUPLICATE_EMAILS | Allow duplicate emails | false |
| OIDC_ALLOW_DUPLICATE_EMAILS | Allow duplicate emails | false | | OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth parameters | {} |
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth parameters | {} | | OIDC_CREATE_USER | Create used on OIDC | false |
| OIDC_CREATE_USER | create used on OIDC | false | | OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | true |
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | faillback to email for identification | true | | OIDC_OP_AUTHORIZATION_ENDPOINT | Authorization endpoint for OIDC | |
| OIDC_OP_AUTHORIZATION_ENDPOINT | Authorization endpoint for OIDC | | | OIDC_OP_JWKS_ENDPOINT | JWKS endpoint for OIDC | |
| OIDC_OP_JWKS_ENDPOINT | JWKS endpoint for OIDC | | | OIDC_OP_LOGOUT_ENDPOINT | Logout endpoint for OIDC | |
| OIDC_OP_LOGOUT_ENDPOINT | Logout endpoint for OIDC | | | OIDC_OP_TOKEN_ENDPOINT | Token endpoint for OIDC | |
| OIDC_OP_TOKEN_ENDPOINT | Token endpoint for OIDC | | | OIDC_OP_USER_ENDPOINT | User endpoint for OIDC | |
| OIDC_OP_USER_ENDPOINT | User endpoint for OIDC | | | OIDC_REDIRECT_ALLOWED_HOSTS | Allowed hosts for OIDC redirect url | [] |
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed hosts for OIDC redirect url | [] | | OIDC_REDIRECT_REQUIRE_HTTPS | Require https for OIDC redirect url | false |
| OIDC_REDIRECT_REQUIRE_HTTPS | Require https for OIDC redirect url | false | | OIDC_RP_CLIENT_ID | Client id used for OIDC | impress |
| OIDC_RP_CLIENT_ID | client id used for OIDC | impress | | OIDC_RP_CLIENT_SECRET | Client secret used for OIDC | |
| OIDC_RP_CLIENT_SECRET | client secret used for OIDC | | | OIDC_RP_SCOPES | Scopes requested for OIDC | openid email |
| OIDC_RP_SCOPES | scopes requested for OIDC | openid email | | OIDC_RP_SIGN_ALGO | verification algorithm used OIDC tokens | RS256 |
| OIDC_RP_SIGN_ALGO | verification algorithm used OIDC tokens | RS256 | | OIDC_STORE_ID_TOKEN | Store OIDC token | true |
| OIDC_STORE_ID_TOKEN | Store OIDC token | true | | OIDC_USE_NONCE | Use nonce for OIDC | 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_FULLNAME_FIELDS | OIDC token claims to create full name | ["first_name", "last_name"] | | OIDC_USERINFO_SHORTNAME_FIELD | OIDC token claims to create shortname | first_name |
| OIDC_USERINFO_SHORTNAME_FIELD | OIDC token claims to create shortname | first_name | | POSTHOG_KEY | Posthog key for analytics | |
| POSTHOG_KEY | posthog key for analytics | | | REDIS_URL | Cache url | redis://redis:6379/1 |
| REDIS_URL | cache url | redis://redis:6379/1 | | SENTRY_DSN | Sentry host | |
| SENTRY_DSN | sentry host | | | SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 |
| SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 | | SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | | false |
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | | false | | STORAGES_STATICFILES_BACKEND | | whitenoise.storage.CompressedManifestStaticFilesStorage |
| STORAGES_STATICFILES_BACKEND | | whitenoise.storage.CompressedManifestStaticFilesStorage | | THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 |
| 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 |
| 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 |
| TRASHBIN_CUTOFF_DAYS | trashbin cutoff | 30 | | USER_OIDC_ESSENTIAL_CLAIMS | Essential claims in OIDC token | [] |
| USER_OIDC_ESSENTIAL_CLAIMS | essential claims in OIDC token | [] | | Y_PROVIDER_API_BASE_URL | Y Provider url | |
| Y_PROVIDER_API_BASE_URL | Y Provider url | | | Y_PROVIDER_API_KEY | Y provider API key | |
| Y_PROVIDER_API_KEY | Y provider API key | |
## impress-frontend image ## impress-frontend image
@@ -135,10 +135,11 @@ NODE_ENV=production NEXT_PUBLIC_PUBLISH_AS_MIT=false yarn build
| PUBLISH_AS_MIT | Removes packages whose licences are incompatible with the MIT licence (see below) | true | | PUBLISH_AS_MIT | Removes packages whose licences are incompatible with the MIT licence (see below) | true |
Packages with licences incompatible with the MIT licence: Packages with licences incompatible with the MIT licence:
* `xl-docx-exporter`: [AGPL-3.0](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-docx-exporter/LICENSE), * `xl-docx-exporter`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-docx-exporter/LICENSE),
* `xl-pdf-exporter`: [AGPL-3.0](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE) * `xl-pdf-exporter`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE),
* `xl-multi-column`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-multi-column/LICENSE).
In `.env.development`, `PUBLISH_AS_MIT` is set to `false`, allowing developers to test Docs with all its features. In `.env.development`, `PUBLISH_AS_MIT` is set to `false`, allowing developers to test Docs with all its features.
⚠️ If you run Docs in production with `PUBLISH_AS_MIT` set to `false` make sure you fulfill your [BlockNote licensing](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE) or [subscription](https://www.blocknotejs.org/about#partner-with-us) obligations. ⚠️ If you run Docs in production with `PUBLISH_AS_MIT` set to `false` make sure you fulfill your BlockNote licensing or [subscription](https://www.blocknotejs.org/about#partner-with-us) obligations.

View File

@@ -7,12 +7,12 @@ services:
timeout: 2s timeout: 2s
retries: 300 retries: 300
env_file: env_file:
- env.d/postgresql - env.d/postgresql
- env.d/common - env.d/common
environment: environment:
- PGDATA=/var/lib/postgresql/data/pgdata - PGDATA=/var/lib/postgresql/data/pgdata
volumes: volumes:
- ./data/databases/backend:/var/lib/postgresql/data/pgdata - ./data/databases/backend:/var/lib/postgresql/data/pgdata
redis: redis:
image: redis:8 image: redis:8
@@ -22,12 +22,12 @@ services:
user: ${DOCKER_USER:-1000} user: ${DOCKER_USER:-1000}
restart: always restart: always
environment: environment:
- DJANGO_CONFIGURATION=Production - DJANGO_CONFIGURATION=Production
env_file: env_file:
- env.d/common - env.d/common
- env.d/backend - env.d/backend
- env.d/yprovider - env.d/yprovider
- env.d/postgresql - env.d/postgresql
healthcheck: healthcheck:
test: ["CMD", "python", "manage.py", "check"] test: ["CMD", "python", "manage.py", "check"]
interval: 15s interval: 15s
@@ -45,24 +45,24 @@ services:
image: lasuite/impress-y-provider:latest image: lasuite/impress-y-provider:latest
user: ${DOCKER_USER:-1000} user: ${DOCKER_USER:-1000}
env_file: env_file:
- env.d/common - env.d/common
- env.d/yprovider - env.d/yprovider
frontend: frontend:
image: lasuite/impress-frontend:latest image: lasuite/impress-frontend:latest
user: "101" user: "101"
entrypoint: entrypoint:
- /docker-entrypoint.sh - /docker-entrypoint.sh
command: ["nginx", "-g", "daemon off;"] command: ["nginx", "-g", "daemon off;"]
env_file: env_file:
- env.d/common - env.d/common
# Uncomment and set your values if using our nginx proxy example # Uncomment and set your values if using our nginx proxy example
#environment: #environment:
# - VIRTUAL_HOST=${DOCS_HOST} # used by nginx proxy # - VIRTUAL_HOST=${DOCS_HOST} # used by nginx proxy
# - VIRTUAL_PORT=8083 # used by nginx proxy # - VIRTUAL_PORT=8083 # used by nginx proxy
# - LETSENCRYPT_HOST=${DOCS_HOST} # used by lets encrypt to generate TLS certificate # - LETSENCRYPT_HOST=${DOCS_HOST} # used by lets encrypt to generate TLS certificate
volumes: volumes:
- ./default.conf.template:/etc/nginx/templates/docs.conf.template - ./default.conf.template:/etc/nginx/templates/docs.conf.template
depends_on: depends_on:
backend: backend:
condition: service_healthy condition: service_healthy

View File

@@ -9,9 +9,9 @@
```bash ```bash
mkdir keycloak mkdir keycloak
curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/docs/examples/compose/keycloak/compose.yaml curl -o keycloak/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 keycloak/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 curl -o keycloak/env.d/keycloak https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/keycloak
``` ```
### Step 2:. Update `env.d/` files ### Step 2:. Update `env.d/` files

View File

@@ -7,23 +7,23 @@ services:
timeout: 2s timeout: 2s
retries: 300 retries: 300
env_file: env_file:
- env.d/kc_postgresql - env.d/kc_postgresql
volumes: volumes:
- ./data/keycloak:/var/lib/postgresql/data/pgdata - ./data/keycloak:/var/lib/postgresql/data/pgdata
keycloak: keycloak:
image: quay.io/keycloak/keycloak:26.1.3 image: quay.io/keycloak/keycloak:26.1.3
command: ["start"] command: ["start"]
env_file: env_file:
- env.d/kc_postgresql - env.d/kc_postgresql
- env.d/keycloak - env.d/keycloak
# Uncomment and set your values if using our nginx proxy example # Uncomment and set your values if using our nginx proxy example
# environment: # environment:
# - VIRTUAL_HOST=id.yourdomain.tld # used by nginx proxy # - VIRTUAL_HOST=id.yourdomain.tld # used by nginx proxy
# - VIRTUAL_PORT=8080 # used by nginx proxy # - VIRTUAL_PORT=8080 # used by nginx proxy
# - LETSENCRYPT_HOST=id.yourdomain.tld # used by lets encrypt to generate TLS certificate # - LETSENCRYPT_HOST=id.yourdomain.tld # used by lets encrypt to generate TLS certificate
depends_on: depends_on:
kc_postgresql:: kc_postgresql:
condition: service_healthy condition: service_healthy
restart: true restart: true
# Uncomment if using our nginx proxy example # Uncomment if using our nginx proxy example
@@ -33,4 +33,4 @@ services:
# #
#networks: #networks:
# proxy-tier: # proxy-tier:
# external: true # external: true

View File

@@ -9,7 +9,7 @@
```bash ```bash
mkdir minio mkdir minio
curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/docs/examples/compose/minio/compose.yaml curl -o minio/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 ### Step 2:. Update compose file with your own values

View File

@@ -2,8 +2,8 @@ services:
minio: minio:
image: minio/minio image: minio/minio
environment: environment:
- MINIO_ROOT_USER=<set minio root username> - MINIO_ROOT_USER=<set minio root username>
- MINIO_ROOT_PASSWORD=<set minio root password> - MINIO_ROOT_PASSWORD=<set minio root password>
# Uncomment and set your values if using our nginx proxy example # Uncomment and set your values if using our nginx proxy example
# - VIRTUAL_HOST=storage.yourdomain.tld # used by nginx proxy # - VIRTUAL_HOST=storage.yourdomain.tld # used by nginx proxy
# - VIRTUAL_PORT=9000 # used by nginx proxy # - VIRTUAL_PORT=9000 # used by nginx proxy
@@ -16,12 +16,12 @@ services:
entrypoint: "" entrypoint: ""
command: minio server /data command: minio server /data
volumes: volumes:
- ./data/minio:/data - ./data/minio:/data
# Uncomment if using our nginx proxy example # Uncomment if using our nginx proxy example
# networks: # networks:
# - proxy-tier # - proxy-tier
# Uncomment if using our nginx proxy example # Uncomment if using our nginx proxy example
#networks: #networks:
# proxy-tier: # proxy-tier:
# external: true # external: true

View File

@@ -13,7 +13,7 @@ Acme-companion is a lightweight companion container for nginx-proxy. It handles
```bash ```bash
mkdir nginx-proxy mkdir nginx-proxy
curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/docs/examples/compose/nginx-proxy/compose.yaml curl -o nginx-proxy/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. ### Step 2: Edit `DEFAULT_EMAIL` in the compose file.

View File

@@ -3,28 +3,28 @@ services:
image: nginxproxy/nginx-proxy image: nginxproxy/nginx-proxy
container_name: nginx-proxy container_name: nginx-proxy
ports: ports:
- "80:80" - "80:80"
- "443:443" - "443:443"
volumes: volumes:
- html:/usr/share/nginx/html - html:/usr/share/nginx/html
- certs:/etc/nginx/certs:ro - certs:/etc/nginx/certs:ro
- /var/run/docker.sock:/tmp/docker.sock:ro - /var/run/docker.sock:/tmp/docker.sock:ro
networks: networks:
- proxy-tier - proxy-tier
acme-companion: acme-companion:
image: nginxproxy/acme-companion image: nginxproxy/acme-companion
container_name: nginx-proxy-acme container_name: nginx-proxy-acme
environment: environment:
- DEFAULT_EMAIL=mail@yourdomain.tld - DEFAULT_EMAIL=mail@yourdomain.tld
volumes_from: volumes_from:
- nginx-proxy - nginx-proxy
volumes: volumes:
- certs:/etc/nginx/certs:rw - certs:/etc/nginx/certs:rw
- acme:/etc/acme.sh - acme:/etc/acme.sh
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
networks: networks:
- proxy-tier - proxy-tier
networks: networks:
proxy-tier: proxy-tier:

View File

@@ -27,7 +27,7 @@ backend:
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/userinfo OIDC_OP_USER_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/userinfo
OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/session/end OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/logout
OIDC_RP_CLIENT_ID: impress OIDC_RP_CLIENT_ID: impress
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
OIDC_RP_SIGN_ALGO: RS256 OIDC_RP_SIGN_ALGO: RS256
@@ -46,9 +46,6 @@ backend:
DB_USER: dinum DB_USER: dinum
DB_PASSWORD: pass DB_PASSWORD: pass
DB_PORT: 5432 DB_PORT: 5432
POSTGRES_DB: impress
POSTGRES_USER: dinum
POSTGRES_PASSWORD: pass
REDIS_URL: redis://default:pass@redis-master:6379/1 REDIS_URL: redis://default:pass@redis-master:6379/1
AWS_S3_ENDPOINT_URL: http://minio.impress.svc.cluster.local:9000 AWS_S3_ENDPOINT_URL: http://minio.impress.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: root AWS_S3_ACCESS_KEY_ID: root
@@ -85,7 +82,7 @@ backend:
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false # Extra volume to manage our local custom CA and avoid to set ssl_verify: false
extraVolumeMounts: extraVolumeMounts:
- name: certs - name: certs
mountPath: /usr/local/lib/python3.13/site-packages/certifi/cacert.pem mountPath: /cert/cacert.pem
subPath: cacert.pem subPath: cacert.pem
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false # Extra volume to manage our local custom CA and avoid to set ssl_verify: false

View File

@@ -0,0 +1,32 @@
# Installation
If you want to install Docs you've come to the right place.
Here are a bunch of resources to help you install the project.
## Kubernetes
We (Docs maintainers) are only using the Kubernetes deployment method in production. We can only provide advanced support for this method.
Please follow the instructions laid out [here](/docs/installation/kubernetes.md).
## Docker Compose
We are aware that not everyone has Kubernetes Cluster laying around 😆.
We also provide [Docker images](https://hub.docker.com/u/lasuite?page=1&search=impress) that you can deploy using Compose.
Please follow the instructions [here](/docs/installation/compose.md).
⚠️ Please keep in mind that we do not use it ourselves in production. Let us know in the issues if you run into troubles, we'll try to help.
## Other ways to install Docs
Community members have contributed several other ways to install Docs. While we owe them a big thanks 🙏, please keep in mind we (Docs maintainers) can't provide support on these installation methods as we don't use them ourselves and there are two many options out there for us to keep track of. Of course you can contact the contributors and the broader community for assistance.
Here is the list of other methods in alphabetical order:
- Coop-Cloud: [code](https://git.coopcloud.tech/coop-cloud/lasuite-docs)
- Nix: [Packages](https://search.nixos.org/packages?channel=unstable&query=lasuite-docs), ⚠️ unstable
- Podman: [code][https://codeberg.org/philo/lasuite-docs-podman], ⚠️ experimental
- YunoHost: [code](https://github.com/YunoHost-Apps/lasuite-docs_ynh), [app store](https://apps.yunohost.org/app/lasuite-docs)
Feel free to make a PR to add ones that are not listed above 🙏
## Cloud providers
Some cloud providers are making it easy to deploy Docs on their infrastructure.
Here is the list in alphabetical order:
- Clever Cloud 🇫🇷 : [market place][https://www.clever-cloud.com/product/docs/], [technical doc](https://www.clever.cloud/developers/guides/docs/#deploy-docs)
Feel free to make a PR to add ones that are not listed above 🙏

View File

@@ -1,6 +1,6 @@
# Installation with docker compose # Installation with docker compose
We provide a sample configuration for running Docs using Docker Compose. Please note that this configuration is experimental, and the official way to deploy Docs in production is to use [k8s](../installation/k8s.md) We provide a sample configuration for running Docs using Docker Compose. Please note that this configuration is experimental, and the official way to deploy Docs in production is to use [k8s](../installation/kubernetes.md)
## Requirements ## Requirements
@@ -31,11 +31,17 @@ For older versions of Docker Engine that do not include Docker Compose:
```bash ```bash
mkdir -p docs/env.d mkdir -p docs/env.d
cd docs
curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/docs/examples/compose/compose.yaml 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/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/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/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 curl -o env.d/postgresql https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/postgresql
```
If you are using the sample nginx-proxy configuration:
```bash
curl -o default.conf.template https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/docker/files/production/etc/nginx/conf.d/default.conf.template
``` ```
## Step 2: Configuration ## Step 2: Configuration

View File

@@ -124,7 +124,7 @@ OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/userinfo OIDC_OP_USER_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/userinfo
OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/session/end OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/logout
OIDC_RP_CLIENT_ID: impress OIDC_RP_CLIENT_ID: impress
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
OIDC_RP_SIGN_ALGO: RS256 OIDC_RP_SIGN_ALGO: RS256
@@ -168,9 +168,6 @@ DB_NAME: impress
DB_USER: dinum DB_USER: dinum
DB_PASSWORD: pass DB_PASSWORD: pass
DB_PORT: 5432 DB_PORT: 5432
POSTGRES_DB: impress
POSTGRES_USER: dinum
POSTGRES_PASSWORD: pass
``` ```
### Find s3 bucket connection values ### Find s3 bucket connection values

View File

@@ -32,6 +32,24 @@ Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom
---- ----
# **Your Docs icon** 📝
You can add your own Docs icon in the header from the theme customization file.
### Settings 🔧
```shellscript
THEME_CUSTOMIZATION_FILE_PATH=<path>
```
### Example of JSON
You can activate it with the `header.icon` configuration: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
This configuration is optional. If not set, the default icon will be used.
----
# **Footer Configuration** 📝 # **Footer Configuration** 📝
The footer is configurable from the theme customization file. The footer is configurable from the theme customization file.

View File

@@ -83,55 +83,6 @@ If you already have CRLF line endings in your local repository, the **best appro
git commit -m "✏️(project) Fix line endings to LF" git commit -m "✏️(project) Fix line endings to LF"
``` ```
## Minio Permission Issues on Windows
### Problem Description
On Windows, you may encounter permission-related errors when running Minio in development mode with Docker Compose. This typically happens because:
- **Windows file permissions** don't map well to Unix-style user IDs used in Docker containers
- **Docker Desktop** may have issues with user mapping when using the `DOCKER_USER` environment variable
- **Minio container** fails to start or access volumes due to permission conflicts
### Common Symptoms
- Minio container fails to start with permission denied errors
- Error messages related to file system permissions in Minio logs
- Unable to create or access buckets in the development environment
- Docker Compose showing Minio service as unhealthy or exited
### Solution for Windows Users
If you encounter Minio permission issues on Windows, you can temporarily disable user mapping for the Minio service:
1. **Open the `compose.yml` file**
2. **Comment out the user directive** in the `minio` service section:
```yaml
minio:
# user: ${DOCKER_USER:-1000} # Comment this line on Windows if permission issues occur
image: minio/minio
environment:
- MINIO_ROOT_USER=impress
- MINIO_ROOT_PASSWORD=password
# ... rest of the configuration
```
3. **Restart the services**:
```bash
make run
```
### Why This Works
- Commenting out the `user` directive allows the Minio container to run with its default user
- This bypasses Windows-specific permission mapping issues
- The container will have the necessary permissions to access and manage the mounted volumes
### Note
This is a **development-only workaround**. In production environments, proper user mapping and security considerations should be maintained according to your deployment requirements.
## Frontend File Watching Issues on Windows ## Frontend File Watching Issues on Windows
### Problem Description ### Problem Description

View File

@@ -60,8 +60,12 @@ COLLABORATION_API_URL=http://y-provider-development:4444/collaboration/api/
COLLABORATION_BACKEND_BASE_URL=http://app-dev:8000 COLLABORATION_BACKEND_BASE_URL=http://app-dev:8000
COLLABORATION_SERVER_ORIGIN=http://localhost:3000 COLLABORATION_SERVER_ORIGIN=http://localhost:3000
COLLABORATION_SERVER_SECRET=my-secret COLLABORATION_SERVER_SECRET=my-secret
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY=true
COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/ COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/
DJANGO_SERVER_TO_SERVER_API_TOKENS=server-api-token DJANGO_SERVER_TO_SERVER_API_TOKENS=server-api-token
Y_PROVIDER_API_BASE_URL=http://y-provider-development:4444/api/ Y_PROVIDER_API_BASE_URL=http://y-provider-development:4444/api/
Y_PROVIDER_API_KEY=yprovider-api-key Y_PROVIDER_API_KEY=yprovider-api-key
# Theme customization
THEME_CUSTOMIZATION_CACHE_TIMEOUT=15

View File

@@ -2,4 +2,8 @@
BURST_THROTTLE_RATES="200/minute" BURST_THROTTLE_RATES="200/minute"
COLLABORATION_API_URL=http://y-provider:4444/collaboration/api/ COLLABORATION_API_URL=http://y-provider:4444/collaboration/api/
SUSTAINED_THROTTLE_RATES="200/hour" SUSTAINED_THROTTLE_RATES="200/hour"
Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/ Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/
# Throttle
API_DOCUMENT_THROTTLE_RATE=1000/min
API_CONFIG_THROTTLE_RATE=1000/min

View File

@@ -43,8 +43,8 @@ OIDC_RP_CLIENT_ID=<client_id>
OIDC_RP_CLIENT_SECRET=<client secret> OIDC_RP_CLIENT_SECRET=<client secret>
OIDC_RP_SIGN_ALGO=RS256 OIDC_RP_SIGN_ALGO=RS256
OIDC_RP_SCOPES="openid email" OIDC_RP_SCOPES="openid email"
#USER_OIDC_FIELD_TO_SHORTNAME #OIDC_USERINFO_SHORTNAME_FIELD
#USER_OIDC_FIELDS_TO_FULLNAME #OIDC_USERINFO_FULLNAME_FIELDS
LOGIN_REDIRECT_URL=https://${DOCS_HOST} LOGIN_REDIRECT_URL=https://${DOCS_HOST}
LOGIN_REDIRECT_URL_FAILURE=https://${DOCS_HOST} LOGIN_REDIRECT_URL_FAILURE=https://${DOCS_HOST}

View File

@@ -1,4 +1,4 @@
Y_PROVIDER_API_BASE_URL=http://${YPROVIDER_HOST}:4444/api Y_PROVIDER_API_BASE_URL=http://${YPROVIDER_HOST}:4444/api/
Y_PROVIDER_API_KEY=<generate a random key> Y_PROVIDER_API_KEY=<generate a random key>
COLLABORATION_SERVER_SECRET=<generate a random key> COLLABORATION_SERVER_SECRET=<generate a random key>
COLLABORATION_SERVER_ORIGIN=https://${DOCS_HOST} COLLABORATION_SERVER_ORIGIN=https://${DOCS_HOST}

View File

@@ -2,6 +2,10 @@
"extends": ["github>numerique-gouv/renovate-configuration"], "extends": ["github>numerique-gouv/renovate-configuration"],
"dependencyDashboard": true, "dependencyDashboard": true,
"labels": ["dependencies", "noChangeLog", "automated"], "labels": ["dependencies", "noChangeLog", "automated"],
"schedule": ["before 7am on monday"],
"prCreation": "not-pending",
"rebaseWhen": "conflicted",
"updateNotScheduled": false,
"packageRules": [ "packageRules": [
{ {
"enabled": false, "enabled": false,
@@ -15,15 +19,18 @@
"matchPackageNames": ["redis"], "matchPackageNames": ["redis"],
"allowedVersions": "<6.0.0" "allowedVersions": "<6.0.0"
}, },
{
"groupName": "allowed pylint versions",
"matchManagers": ["pep621"],
"matchPackageNames": ["pylint"],
"allowedVersions": "<4.0.0"
},
{ {
"enabled": false, "enabled": false,
"groupName": "ignored js dependencies", "groupName": "ignored js dependencies",
"matchManagers": ["npm"], "matchManagers": ["npm"],
"matchPackageNames": [ "matchPackageNames": [
"@hocuspocus/provider",
"@hocuspocus/server",
"docx", "docx",
"eslint",
"fetch-mock", "fetch-mock",
"node", "node",
"node-fetch", "node-fetch",

View File

@@ -5,7 +5,6 @@ from django.contrib.auth import admin as auth_admin
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from treebeard.admin import TreeAdmin from treebeard.admin import TreeAdmin
from treebeard.forms import movenodeform_factory
from . import models from . import models
@@ -157,7 +156,6 @@ class DocumentAdmin(TreeAdmin):
}, },
), ),
) )
form = movenodeform_factory(models.Document)
inlines = (DocumentAccessInline,) inlines = (DocumentAccessInline,)
list_display = ( list_display = (
"id", "id",

View File

@@ -60,6 +60,9 @@ class ListDocumentFilter(DocumentFilter):
is_creator_me = django_filters.BooleanFilter( is_creator_me = django_filters.BooleanFilter(
method="filter_is_creator_me", label=_("Creator is me") method="filter_is_creator_me", label=_("Creator is me")
) )
is_masked = django_filters.BooleanFilter(
method="filter_is_masked", label=_("Masked")
)
is_favorite = django_filters.BooleanFilter( is_favorite = django_filters.BooleanFilter(
method="filter_is_favorite", label=_("Favorite") method="filter_is_favorite", label=_("Favorite")
) )
@@ -106,3 +109,30 @@ class ListDocumentFilter(DocumentFilter):
return queryset return queryset
return queryset.filter(is_favorite=bool(value)) return queryset.filter(is_favorite=bool(value))
# pylint: disable=unused-argument
def filter_is_masked(self, queryset, name, value):
"""
Filter documents based on whether they are masked by the current user.
Example:
- /api/v1.0/documents/?is_masked=true
→ Filters documents marked as masked by the logged-in user
- /api/v1.0/documents/?is_masked=false
→ Filters documents not marked as masked by the logged-in user
"""
user = self.request.user
if not user.is_authenticated:
return queryset
queryset_method = queryset.filter if bool(value) else queryset.exclude
return queryset_method(link_traces__user=user, link_traces__is_masked=True)
class UserSearchFilter(django_filters.FilterSet):
"""
Custom filter for searching users.
"""
q = django_filters.CharFilter(min_length=5, max_length=254)

View File

@@ -6,6 +6,7 @@ from django.http import Http404
from rest_framework import permissions from rest_framework import permissions
from core import choices
from core.models import DocumentAccess, RoleChoices, get_trashbin_cutoff from core.models import DocumentAccess, RoleChoices, get_trashbin_cutoff
ACTION_FOR_METHOD_TO_PERMISSION = { ACTION_FOR_METHOD_TO_PERMISSION = {
@@ -96,26 +97,27 @@ class CanCreateInvitationPermission(permissions.BasePermission):
).exists() ).exists()
class AccessPermission(permissions.BasePermission): class ResourceWithAccessPermission(permissions.BasePermission):
"""Permission class for access objects.""" """A permission class for templates and invitations."""
def has_permission(self, request, view): def has_permission(self, request, view):
"""check create permission for templates."""
return request.user.is_authenticated or view.action != "create" return request.user.is_authenticated or view.action != "create"
def has_object_permission(self, request, view, obj): def has_object_permission(self, request, view, obj):
"""Check permission for a given object.""" """Check permission for a given object."""
abilities = obj.get_abilities(request.user) abilities = obj.get_abilities(request.user)
action = view.action action = view.action
try:
action = ACTION_FOR_METHOD_TO_PERMISSION[view.action][request.method]
except KeyError:
pass
return abilities.get(action, False) return abilities.get(action, False)
class DocumentAccessPermission(AccessPermission): class DocumentPermission(permissions.BasePermission):
"""Subclass to handle soft deletion specificities.""" """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): def has_object_permission(self, request, view, obj):
""" """
Return a 404 on deleted documents Return a 404 on deleted documents
@@ -127,10 +129,61 @@ class DocumentAccessPermission(AccessPermission):
) and deleted_at < get_trashbin_cutoff(): ) and deleted_at < get_trashbin_cutoff():
raise Http404 raise Http404
# Compute permission first to ensure the "user_roles" attribute is set abilities = obj.get_abilities(request.user)
has_permission = super().has_object_permission(request, view, obj) 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: if obj.ancestors_deleted_at and not RoleChoices.OWNER in obj.user_roles:
raise Http404 raise Http404
return has_permission 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)
class CommentPermission(permissions.BasePermission):
"""Permission class for comments."""
def has_permission(self, request, view):
"""Check permission for a given object."""
if view.action in ["create", "list"]:
document_abilities = view.get_document_or_404().get_abilities(request.user)
return document_abilities["comment"]
return True
def has_object_permission(self, request, view, obj):
"""Check permission for a given object."""
return obj.get_abilities(request.user).get(view.action, False)

View File

@@ -1,4 +1,5 @@
"""Client serializers for the impress core app.""" """Client serializers for the impress core app."""
# pylint: disable=too-many-lines
import binascii import binascii
import mimetypes import mimetypes
@@ -7,12 +8,13 @@ from base64 import b64decode
from django.conf import settings from django.conf import settings
from django.db.models import Q from django.db.models import Q
from django.utils.functional import lazy from django.utils.functional import lazy
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import magic 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, validators
from core.services.ai_services import AI_ACTIONS from core.services.ai_services import AI_ACTIONS
from core.services.converter_services import ( from core.services.converter_services import (
ConversionError, ConversionError,
@@ -23,143 +25,63 @@ from core.services.converter_services import (
class UserSerializer(serializers.ModelSerializer): class UserSerializer(serializers.ModelSerializer):
"""Serialize users.""" """Serialize users."""
full_name = serializers.SerializerMethodField(read_only=True)
short_name = serializers.SerializerMethodField(read_only=True)
class Meta: class Meta:
model = models.User model = models.User
fields = ["id", "email", "full_name", "short_name", "language"] fields = ["id", "email", "full_name", "short_name", "language"]
read_only_fields = ["id", "email", "full_name", "short_name"] read_only_fields = ["id", "email", "full_name", "short_name"]
def get_full_name(self, instance):
"""Return the full name of the user."""
if not instance.full_name:
email = instance.email.split("@")[0]
return slugify(email)
return instance.full_name
def get_short_name(self, instance):
"""Return the short name of the user."""
if not instance.short_name:
email = instance.email.split("@")[0]
return slugify(email)
return instance.short_name
class UserLightSerializer(UserSerializer): class UserLightSerializer(UserSerializer):
"""Serialize users with limited fields.""" """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: class Meta:
model = models.User model = models.User
fields = ["id", "email", "full_name", "short_name"] fields = ["full_name", "short_name"]
read_only_fields = ["id", "email", "full_name", "short_name"] read_only_fields = ["full_name", "short_name"]
class BaseAccessSerializer(serializers.ModelSerializer): class TemplateAccessSerializer(serializers.ModelSerializer):
"""Serialize template accesses.""" """Serialize template accesses."""
abilities = serializers.SerializerMethodField(read_only=True) 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: class Meta:
model = models.TemplateAccess model = models.TemplateAccess
resource_field_name = "template" resource_field_name = "template"
fields = ["id", "user", "team", "role", "abilities"] fields = ["id", "user", "team", "role", "abilities"]
read_only_fields = ["id", "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): class ListDocumentSerializer(serializers.ModelSerializer):
"""Serialize documents with limited fields for display in lists.""" """Serialize documents with limited fields for display in lists."""
@@ -167,16 +89,22 @@ class ListDocumentSerializer(serializers.ModelSerializer):
is_favorite = serializers.BooleanField(read_only=True) is_favorite = serializers.BooleanField(read_only=True)
nb_accesses_ancestors = serializers.IntegerField(read_only=True) nb_accesses_ancestors = serializers.IntegerField(read_only=True)
nb_accesses_direct = 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) abilities = serializers.SerializerMethodField(read_only=True)
deleted_at = serializers.SerializerMethodField(read_only=True)
class Meta: class Meta:
model = models.Document model = models.Document
fields = [ fields = [
"id", "id",
"abilities", "abilities",
"ancestors_link_reach",
"ancestors_link_role",
"computed_link_reach",
"computed_link_role",
"created_at", "created_at",
"creator", "creator",
"deleted_at",
"depth", "depth",
"excerpt", "excerpt",
"is_favorite", "is_favorite",
@@ -188,13 +116,18 @@ class ListDocumentSerializer(serializers.ModelSerializer):
"path", "path",
"title", "title",
"updated_at", "updated_at",
"user_roles", "user_role",
] ]
read_only_fields = [ read_only_fields = [
"id", "id",
"abilities", "abilities",
"ancestors_link_reach",
"ancestors_link_role",
"computed_link_reach",
"computed_link_role",
"created_at", "created_at",
"creator", "creator",
"deleted_at",
"depth", "depth",
"excerpt", "excerpt",
"is_favorite", "is_favorite",
@@ -205,49 +138,70 @@ class ListDocumentSerializer(serializers.ModelSerializer):
"numchild", "numchild",
"path", "path",
"updated_at", "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.""" """Return abilities of the logged-in user on the instance."""
request = self.context.get("request") request = self.context.get("request")
if not request:
return {}
if request: return instance.get_abilities(request.user)
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 {} def get_user_role(self, instance):
def get_user_roles(self, document):
""" """
Return roles of the logged-in user for the current document, Return roles of the logged-in user for the current document,
taking into account ancestors. taking into account ancestors.
""" """
request = self.context.get("request") request = self.context.get("request")
if request: return instance.get_role(request.user) if request else None
return document.get_roles(request.user)
return [] def get_deleted_at(self, instance):
"""Return the deleted_at of the current document."""
return instance.ancestors_deleted_at
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): class DocumentSerializer(ListDocumentSerializer):
"""Serialize documents with all fields for display in detail views.""" """Serialize documents with all fields for display in detail views."""
content = serializers.CharField(required=False) content = serializers.CharField(required=False)
websocket = serializers.BooleanField(required=False, write_only=True)
class Meta: class Meta:
model = models.Document model = models.Document
fields = [ fields = [
"id", "id",
"abilities", "abilities",
"ancestors_link_reach",
"ancestors_link_role",
"computed_link_reach",
"computed_link_role",
"content", "content",
"created_at", "created_at",
"creator", "creator",
"deleted_at",
"depth", "depth",
"excerpt", "excerpt",
"is_favorite", "is_favorite",
@@ -259,13 +213,19 @@ class DocumentSerializer(ListDocumentSerializer):
"path", "path",
"title", "title",
"updated_at", "updated_at",
"user_roles", "user_role",
"websocket",
] ]
read_only_fields = [ read_only_fields = [
"id", "id",
"abilities", "abilities",
"ancestors_link_reach",
"ancestors_link_role",
"computed_link_reach",
"computed_link_role",
"created_at", "created_at",
"creator", "creator",
"deleted_at",
"depth", "depth",
"is_favorite", "is_favorite",
"link_role", "link_role",
@@ -275,7 +235,7 @@ class DocumentSerializer(ListDocumentSerializer):
"numchild", "numchild",
"path", "path",
"updated_at", "updated_at",
"user_roles", "user_role",
] ]
def get_fields(self): def get_fields(self):
@@ -361,6 +321,99 @@ class DocumentSerializer(ListDocumentSerializer):
return super().save(**kwargs) 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): class ServerCreateDocumentSerializer(serializers.Serializer):
""" """
Serializer for creating a document from a server-to-server request. Serializer for creating a document from a server-to-server request.
@@ -379,7 +432,7 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
content = serializers.CharField(required=True) content = serializers.CharField(required=True)
# User # User
sub = serializers.CharField( sub = serializers.CharField(
required=True, validators=[models.User.sub_validator], max_length=255 required=True, validators=[validators.sub_validator], max_length=255
) )
email = serializers.EmailField(required=True) email = serializers.EmailField(required=True)
language = serializers.ChoiceField( language = serializers.ChoiceField(
@@ -408,9 +461,7 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
language = user.language or language language = user.language or language
try: try:
document_content = YdocConverter().convert_markdown( document_content = YdocConverter().convert(validated_data["content"])
validated_data["content"]
)
except ConversionError as err: except ConversionError as err:
raise serializers.ValidationError( raise serializers.ValidationError(
{"content": ["Could not convert content"]} {"content": ["Could not convert content"]}
@@ -465,6 +516,10 @@ class LinkDocumentSerializer(serializers.ModelSerializer):
We expose it separately from document in order to simplify and secure access control. We expose it separately from document in order to simplify and secure access control.
""" """
link_reach = serializers.ChoiceField(
choices=models.LinkReachChoices.choices, required=True
)
class Meta: class Meta:
model = models.Document model = models.Document
fields = [ fields = [
@@ -472,6 +527,58 @@ class LinkDocumentSerializer(serializers.ModelSerializer):
"link_reach", "link_reach",
] ]
def validate(self, attrs):
"""Validate that link_role and link_reach are compatible using get_select_options."""
link_reach = attrs.get("link_reach")
link_role = attrs.get("link_role")
if not link_reach:
raise serializers.ValidationError(
{"link_reach": _("This field is required.")}
)
# Get available options based on ancestors' link definition
available_options = models.LinkReachChoices.get_select_options(
**self.instance.ancestors_link_definition
)
# Validate link_reach is allowed
if link_reach not in available_options:
msg = _(
"Link reach '%(link_reach)s' is not allowed based on parent document configuration."
)
raise serializers.ValidationError(
{"link_reach": msg % {"link_reach": link_reach}}
)
# Validate link_role is compatible with link_reach
allowed_roles = available_options[link_reach]
# Restricted reach: link_role must be None
if link_reach == models.LinkReachChoices.RESTRICTED:
if link_role is not None:
raise serializers.ValidationError(
{
"link_role": (
"Cannot set link_role when link_reach is 'restricted'. "
"Link role must be null for restricted reach."
)
}
)
return attrs
# Non-restricted: link_role must be in allowed roles
if link_role not in allowed_roles:
allowed_roles_str = ", ".join(allowed_roles) if allowed_roles else "none"
raise serializers.ValidationError(
{
"link_role": (
f"Link role '{link_role}' is not allowed for link reach '{link_reach}'. "
f"Allowed roles: {allowed_roles_str}"
)
}
)
return attrs
class DocumentDuplicationSerializer(serializers.Serializer): class DocumentDuplicationSerializer(serializers.Serializer):
""" """
@@ -643,6 +750,9 @@ class InvitationSerializer(serializers.ModelSerializer):
if self.instance is None: if self.instance is None:
attrs["issuer"] = user attrs["issuer"] = user
if attrs.get("email"):
attrs["email"] = attrs["email"].lower()
return attrs return attrs
def validate_role(self, role): def validate_role(self, role):
@@ -677,7 +787,9 @@ class DocumentAskForAccessCreateSerializer(serializers.Serializer):
"""Serializer for creating a document ask for access.""" """Serializer for creating a document ask for access."""
role = serializers.ChoiceField( role = serializers.ChoiceField(
choices=models.RoleChoices.choices, choices=[
role for role in choices.RoleChoices if role != models.RoleChoices.OWNER
],
required=False, required=False,
default=models.RoleChoices.READER, default=models.RoleChoices.READER,
) )
@@ -701,11 +813,11 @@ class DocumentAskForAccessSerializer(serializers.ModelSerializer):
] ]
read_only_fields = ["id", "document", "user", "role", "created_at", "abilities"] read_only_fields = ["id", "document", "user", "role", "created_at", "abilities"]
def get_abilities(self, invitation) -> dict: def get_abilities(self, instance) -> dict:
"""Return abilities of the logged-in user on the instance.""" """Return abilities of the logged-in user on the instance."""
request = self.context.get("request") request = self.context.get("request")
if request: if request:
return invitation.get_abilities(request.user) return instance.get_abilities(request.user)
return {} return {}
@@ -780,3 +892,124 @@ class MoveDocumentSerializer(serializers.Serializer):
choices=enums.MoveNodePositionChoices.choices, choices=enums.MoveNodePositionChoices.choices,
default=enums.MoveNodePositionChoices.LAST_CHILD, default=enums.MoveNodePositionChoices.LAST_CHILD,
) )
class ReactionSerializer(serializers.ModelSerializer):
"""Serialize reactions."""
users = UserLightSerializer(many=True, read_only=True)
class Meta:
model = models.Reaction
fields = [
"id",
"emoji",
"created_at",
"users",
]
read_only_fields = ["id", "created_at", "users"]
class CommentSerializer(serializers.ModelSerializer):
"""Serialize comments (nested under a thread) with reactions and abilities."""
user = UserLightSerializer(read_only=True)
abilities = serializers.SerializerMethodField()
reactions = ReactionSerializer(many=True, read_only=True)
class Meta:
model = models.Comment
fields = [
"id",
"user",
"body",
"created_at",
"updated_at",
"reactions",
"abilities",
]
read_only_fields = [
"id",
"user",
"created_at",
"updated_at",
"reactions",
"abilities",
]
def validate(self, attrs):
"""Validate comment data."""
request = self.context.get("request")
user = getattr(request, "user", None)
attrs["thread_id"] = self.context["thread_id"]
attrs["user_id"] = user.id if user else None
return attrs
def get_abilities(self, obj):
"""Return comment's abilities."""
request = self.context.get("request")
if request:
return obj.get_abilities(request.user)
return {}
class ThreadSerializer(serializers.ModelSerializer):
"""Serialize threads in a backward compatible shape for current frontend.
We expose a flatten representation where ``content`` maps to the first
comment's body. Creating a thread requires a ``content`` field which is
stored as the first comment.
"""
creator = UserLightSerializer(read_only=True)
abilities = serializers.SerializerMethodField(read_only=True)
body = serializers.JSONField(write_only=True, required=True)
comments = serializers.SerializerMethodField(read_only=True)
comments = CommentSerializer(many=True, read_only=True)
class Meta:
model = models.Thread
fields = [
"id",
"body",
"created_at",
"updated_at",
"creator",
"abilities",
"comments",
"resolved",
"resolved_at",
"resolved_by",
"metadata",
]
read_only_fields = [
"id",
"created_at",
"updated_at",
"creator",
"abilities",
"comments",
"resolved",
"resolved_at",
"resolved_by",
"metadata",
]
def validate(self, attrs):
"""Validate thread data."""
request = self.context.get("request")
user = getattr(request, "user", None)
attrs["document_id"] = self.context["resource_id"]
attrs["creator_id"] = user.id if user else None
return attrs
def get_abilities(self, thread):
"""Return thread's abilities."""
request = self.context.get("request")
if request:
return thread.get_abilities(request.user)
return {}

View File

@@ -0,0 +1,21 @@
"""Throttling modules for the API."""
from rest_framework.throttling import UserRateThrottle
from sentry_sdk import capture_message
def sentry_monitoring_throttle_failure(message):
"""Log when a failure occurs to detect rate limiting issues."""
capture_message(message, "warning")
class UserListThrottleBurst(UserRateThrottle):
"""Throttle for the user list endpoint."""
scope = "user_list_burst"
class UserListThrottleSustained(UserRateThrottle):
"""Throttle for the user list endpoint."""
scope = "user_list_sustained"

File diff suppressed because it is too large Load Diff

117
src/backend/core/choices.py Normal file
View File

@@ -0,0 +1,117 @@
"""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
COMMENTER = "commenter", _("Commenter") # Can read and comment
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
COMMENTER = "commenter", _("Commenter") # Can read and comment
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}

View File

@@ -150,7 +150,7 @@ class DocumentFactory(factory.django.DjangoModelFactory):
"""Add link traces to document from a given list of users.""" """Add link traces to document from a given list of users."""
if create and extracted: if create and extracted:
for item in extracted: for item in extracted:
models.LinkTrace.objects.create(document=self, user=item) models.LinkTrace.objects.update_or_create(document=self, user=item)
@factory.post_generation @factory.post_generation
def favorited_by(self, create, extracted, **kwargs): def favorited_by(self, create, extracted, **kwargs):
@@ -159,6 +159,15 @@ class DocumentFactory(factory.django.DjangoModelFactory):
for item in extracted: for item in extracted:
models.DocumentFavorite.objects.create(document=self, user=item) models.DocumentFavorite.objects.create(document=self, user=item)
@factory.post_generation
def masked_by(self, create, extracted, **kwargs):
"""Mark document as masked by a list of users."""
if create and extracted:
for item in extracted:
models.LinkTrace.objects.update_or_create(
document=self, user=item, defaults={"is_masked": True}
)
class UserDocumentAccessFactory(factory.django.DjangoModelFactory): class UserDocumentAccessFactory(factory.django.DjangoModelFactory):
"""Create fake document user accesses for testing.""" """Create fake document user accesses for testing."""
@@ -247,3 +256,49 @@ class InvitationFactory(factory.django.DjangoModelFactory):
document = factory.SubFactory(DocumentFactory) document = factory.SubFactory(DocumentFactory)
role = factory.fuzzy.FuzzyChoice([role[0] for role in models.RoleChoices.choices]) role = factory.fuzzy.FuzzyChoice([role[0] for role in models.RoleChoices.choices])
issuer = factory.SubFactory(UserFactory) issuer = factory.SubFactory(UserFactory)
class ThreadFactory(factory.django.DjangoModelFactory):
"""A factory to create threads for a document"""
class Meta:
model = models.Thread
document = factory.SubFactory(DocumentFactory)
creator = factory.SubFactory(UserFactory)
class CommentFactory(factory.django.DjangoModelFactory):
"""A factory to create comments for a thread"""
class Meta:
model = models.Comment
thread = factory.SubFactory(ThreadFactory)
user = factory.SubFactory(UserFactory)
body = factory.Faker("text")
class ReactionFactory(factory.django.DjangoModelFactory):
"""A factory to create reactions for a comment"""
class Meta:
model = models.Reaction
comment = factory.SubFactory(CommentFactory)
emoji = "test"
@factory.post_generation
def users(self, create, extracted, **kwargs):
"""Add users to reaction from a given list of users or create one if not provided."""
if not create:
return
if not extracted:
# the factory is being created, but no users were provided
user = UserFactory()
self.users.add(user)
return
# Add the iterable of groups using bulk addition
self.users.add(*extracted)

View File

@@ -0,0 +1,21 @@
"""Force session creation for all requests."""
class ForceSessionMiddleware:
"""
Force session creation for unauthenticated users.
Must be used after Authentication middleware.
"""
def __init__(self, get_response):
"""Initialize the middleware."""
self.get_response = get_response
def __call__(self, request):
"""Force session creation for unauthenticated users."""
if not request.user.is_authenticated and request.session.session_key is None:
request.session.create()
response = self.get_response(request)
return response

View File

@@ -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),
),
]

View File

@@ -0,0 +1,51 @@
# Generated by Django 5.2.3 on 2025-07-13 08:22
from django.db import migrations, models
import core.validators
class Migration(migrations.Migration):
dependencies = [
("core", "0023_remove_document_is_public_and_more"),
]
operations = [
migrations.AddField(
model_name="linktrace",
name="is_masked",
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name="user",
name="language",
field=models.CharField(
blank=True,
choices=[
("en-us", "English"),
("fr-fr", "Français"),
("de-de", "Deutsch"),
("nl-nl", "Nederlands"),
("es-es", "Español"),
],
default=None,
help_text="The language in which the user wants to see the interface.",
max_length=10,
null=True,
verbose_name="language",
),
),
migrations.AlterField(
model_name="user",
name="sub",
field=models.CharField(
blank=True,
help_text="Required. 255 characters or fewer. ASCII characters only.",
max_length=255,
null=True,
unique=True,
validators=[core.validators.sub_validator],
verbose_name="sub",
),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.2.7 on 2025-10-22 06:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0024_add_is_masked_field_to_link_trace"),
]
operations = [
migrations.AlterField(
model_name="user",
name="short_name",
field=models.CharField(
blank=True, max_length=100, null=True, verbose_name="short name"
),
),
]

View File

@@ -0,0 +1,275 @@
# Generated by Django 5.2.6 on 2025-09-16 08:59
import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0025_alter_user_short_name"),
]
operations = [
migrations.AlterField(
model_name="document",
name="link_role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commenter", "Commenter"),
("editor", "Editor"),
],
default="reader",
max_length=20,
),
),
migrations.AlterField(
model_name="documentaccess",
name="role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commenter", "Commenter"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
migrations.AlterField(
model_name="documentaskforaccess",
name="role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commenter", "Commenter"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
migrations.AlterField(
model_name="invitation",
name="role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commenter", "Commenter"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
migrations.AlterField(
model_name="templateaccess",
name="role",
field=models.CharField(
choices=[
("reader", "Reader"),
("commenter", "Commenter"),
("editor", "Editor"),
("administrator", "Administrator"),
("owner", "Owner"),
],
default="reader",
max_length=20,
),
),
migrations.CreateModel(
name="Thread",
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",
),
),
("resolved", models.BooleanField(default=False)),
("resolved_at", models.DateTimeField(blank=True, null=True)),
("metadata", models.JSONField(blank=True, default=dict)),
(
"creator",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="threads",
to=settings.AUTH_USER_MODEL,
),
),
(
"document",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="threads",
to="core.document",
),
),
(
"resolved_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="resolved_threads",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Thread",
"verbose_name_plural": "Threads",
"db_table": "impress_thread",
"ordering": ("-created_at",),
},
),
migrations.CreateModel(
name="Comment",
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",
),
),
("body", models.JSONField()),
("metadata", models.JSONField(blank=True, default=dict)),
(
"user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="thread_comment",
to=settings.AUTH_USER_MODEL,
),
),
(
"thread",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="comments",
to="core.thread",
),
),
],
options={
"verbose_name": "Comment",
"verbose_name_plural": "Comments",
"db_table": "impress_comment",
"ordering": ("created_at",),
},
),
migrations.CreateModel(
name="Reaction",
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",
),
),
("emoji", models.CharField(max_length=32)),
(
"comment",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="reactions",
to="core.comment",
),
),
(
"users",
models.ManyToManyField(
related_name="reactions", to=settings.AUTH_USER_MODEL
),
),
],
options={
"verbose_name": "Reaction",
"verbose_name_plural": "Reactions",
"db_table": "impress_comment_reaction",
"constraints": [
models.UniqueConstraint(
fields=("comment", "emoji"),
name="unique_comment_emoji",
violation_error_message="This emoji has already been reacted to this comment.",
)
],
},
),
]

View File

@@ -6,7 +6,6 @@ Declare and configure the models for the impress core application
import hashlib import hashlib
import smtplib import smtplib
import uuid import uuid
from collections import defaultdict
from datetime import timedelta from datetime import timedelta
from logging import getLogger from logging import getLogger
@@ -15,7 +14,7 @@ from django.contrib.auth import models as auth_models
from django.contrib.auth.base_user import AbstractBaseUser from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.core import mail, validators from django.core import mail
from django.core.cache import cache from django.core.cache import cache
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.files.storage import default_storage from django.core.files.storage import default_storage
@@ -33,6 +32,15 @@ from rest_framework.exceptions import ValidationError
from timezone_field import TimeZoneField from timezone_field import TimeZoneField
from treebeard.mp_tree import MP_Node, MP_NodeManager, MP_NodeQuerySet from treebeard.mp_tree import MP_Node, MP_NodeManager, MP_NodeQuerySet
from .choices import (
PRIVILEGED_ROLES,
LinkReachChoices,
LinkRoleChoices,
RoleChoices,
get_equivalent_link_definition,
)
from .validators import sub_validator
logger = getLogger(__name__) logger = getLogger(__name__)
@@ -50,88 +58,6 @@ def get_trashbin_cutoff():
return timezone.now() - timedelta(days=settings.TRASHBIN_CUTOFF_DAYS) 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): class DuplicateEmailError(Exception):
"""Raised when an email is already associated with a pre-existing user.""" """Raised when an email is already associated with a pre-existing user."""
@@ -211,28 +137,20 @@ class UserManager(auth_models.UserManager):
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin): class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"""User model to work with OIDC only authentication.""" """User model to work with OIDC only authentication."""
sub_validator = validators.RegexValidator(
regex=r"^[\w.@+-:]+\Z",
message=_(
"Enter a valid sub. This value may contain only letters, "
"numbers, and @/./+/-/_/: characters."
),
)
sub = models.CharField( sub = models.CharField(
_("sub"), _("sub"),
help_text=_( help_text=_("Required. 255 characters or fewer. ASCII characters only."),
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
),
max_length=255, max_length=255,
unique=True,
validators=[sub_validator], validators=[sub_validator],
unique=True,
blank=True, blank=True,
null=True, null=True,
) )
full_name = models.CharField(_("full name"), max_length=100, null=True, blank=True) full_name = models.CharField(_("full name"), max_length=100, null=True, blank=True)
short_name = models.CharField(_("short name"), max_length=20, null=True, blank=True) short_name = models.CharField(
_("short name"), max_length=100, null=True, blank=True
)
email = models.EmailField(_("identity email address"), blank=True, null=True) email = models.EmailField(_("identity email address"), blank=True, null=True)
@@ -305,7 +223,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
Expired invitations are ignored. Expired invitations are ignored.
""" """
valid_invitations = Invitation.objects.filter( valid_invitations = Invitation.objects.filter(
email=self.email, email__iexact=self.email,
created_at__gte=( created_at__gte=(
timezone.now() timezone.now()
- timedelta(seconds=settings.INVITATION_VALIDITY_DURATION) - timedelta(seconds=settings.INVITATION_VALIDITY_DURATION)
@@ -364,69 +282,6 @@ class BaseAccess(BaseModel):
class Meta: class Meta:
abstract = True 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): class DocumentQuerySet(MP_NodeQuerySet):
""" """
@@ -452,6 +307,41 @@ class DocumentQuerySet(MP_NodeQuerySet):
return self.filter(link_reach=LinkReachChoices.PUBLIC) 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)): class DocumentManager(MP_NodeManager.from_queryset(DocumentQuerySet)):
""" """
@@ -464,6 +354,7 @@ class DocumentManager(MP_NodeManager.from_queryset(DocumentQuerySet)):
return self._queryset_class(self.model).order_by("path") return self._queryset_class(self.model).order_by("path")
# pylint: disable=too-many-public-methods
class Document(MP_Node, BaseModel): class Document(MP_Node, BaseModel):
"""Pad document carrying the content.""" """Pad document carrying the content."""
@@ -486,6 +377,7 @@ class Document(MP_Node, BaseModel):
) )
deleted_at = models.DateTimeField(null=True, blank=True) deleted_at = models.DateTimeField(null=True, blank=True)
ancestors_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( duplicated_from = models.ForeignKey(
"self", "self",
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
@@ -531,6 +423,12 @@ class Document(MP_Node, BaseModel):
def __str__(self): def __str__(self):
return str(self.title) if self.title else str(_("Untitled Document")) 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): def save(self, *args, **kwargs):
"""Write content to object storage only if _content has changed.""" """Write content to object storage only if _content has changed."""
super().save(*args, **kwargs) super().save(*args, **kwargs)
@@ -561,6 +459,12 @@ class Document(MP_Node, BaseModel):
content_file = ContentFile(bytes_content) content_file = ContentFile(bytes_content)
default_storage.save(file_key, content_file) 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 @property
def key_base(self): def key_base(self):
"""Key base of the location where the document is stored in object storage.""" """Key base of the location where the document is stored in object storage."""
@@ -718,38 +622,22 @@ class Document(MP_Node, BaseModel):
cache_key = document.get_nb_accesses_cache_key() cache_key = document.get_nb_accesses_cache_key()
cache.delete(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.""" """Return the roles a user has on a document."""
if not user.is_authenticated: if not user.is_authenticated:
return [] return None
try: try:
roles = self.user_roles or [] roles = self.user_roles or []
except AttributeError: except AttributeError:
try: roles = DocumentAccess.objects.filter(
roles = DocumentAccess.objects.filter( models.Q(user=user) | models.Q(team__in=user.teams),
models.Q(user=user) | models.Q(team__in=user.teams), document__path=Left(models.Value(self.path), Length("document__path")),
document__path=Left( ).values_list("role", flat=True)
models.Value(self.path), Length("document__path")
),
).values_list("role", flat=True)
except (models.ObjectDoesNotExist, IndexError):
roles = []
return roles
def get_links_definitions(self, ancestors_links): return RoleChoices.max(*roles)
"""Get links reach/role definitions for the current document and its ancestors."""
links_definitions = defaultdict(set) def compute_ancestors_links_paths_mapping(self):
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):
""" """
Compute the ancestors links for the current document up to the highest readable ancestor. Compute the ancestors links for the current document up to the highest readable ancestor.
""" """
@@ -758,63 +646,122 @@ class Document(MP_Node, BaseModel):
.filter(ancestors_deleted_at__isnull=True) .filter(ancestors_deleted_at__isnull=True)
.order_by("path") .order_by("path")
) )
highest_readable = ancestors.readable_per_se(user).only("depth").first()
if highest_readable is None:
return []
ancestors_links = [] ancestors_links = []
paths_links_mapping = {} paths_links_mapping = {}
for ancestor in ancestors.filter(depth__gte=highest_readable.depth):
for ancestor in ancestors:
ancestors_links.append( ancestors_links.append(
{"link_reach": ancestor.link_reach, "link_role": ancestor.link_role} {"link_reach": ancestor.link_reach, "link_role": ancestor.link_role}
) )
paths_links_mapping[ancestor.path] = ancestors_links.copy() paths_links_mapping[ancestor.path] = ancestors_links.copy()
ancestors_links = paths_links_mapping.get(self.path[: -self.steplen], []) return paths_links_mapping
return ancestors_links @property
def link_definition(self):
"""Returns link reach/role as a definition in dictionary format."""
return {"link_reach": self.link_reach, "link_role": self.link_role}
def get_abilities(self, user, ancestors_links=None): @property
def ancestors_link_definition(self):
"""Link definition equivalent to all document's ancestors."""
if getattr(self, "_ancestors_link_definition", None) is None:
if self.depth <= 1:
ancestors_links = []
else:
mapping = self.compute_ancestors_links_paths_mapping()
ancestors_links = mapping.get(self.path[: -self.steplen], [])
self._ancestors_link_definition = get_equivalent_link_definition(
ancestors_links
)
return self._ancestors_link_definition
@ancestors_link_definition.setter
def ancestors_link_definition(self, definition):
"""Cache the ancestors_link_definition."""
self._ancestors_link_definition = definition
@property
def ancestors_link_reach(self):
"""Link reach equivalent to all document's ancestors."""
return self.ancestors_link_definition["link_reach"]
@property
def ancestors_link_role(self):
"""Link role equivalent to all document's ancestors."""
return self.ancestors_link_definition["link_role"]
@property
def computed_link_definition(self):
"""
Link reach/role on the document, combining inherited ancestors' link
definitions and the document's own link definition.
"""
if getattr(self, "_computed_link_definition", None) is None:
self._computed_link_definition = get_equivalent_link_definition(
[self.ancestors_link_definition, self.link_definition]
)
return self._computed_link_definition
@property
def computed_link_reach(self):
"""Actual link reach on the document."""
return self.computed_link_definition["link_reach"]
@property
def computed_link_role(self):
"""Actual link role on the document."""
return self.computed_link_definition["link_role"]
def get_abilities(self, user):
""" """
Compute and return abilities for a given user on the document. Compute and return abilities for a given user on the document.
""" """
if self.depth <= 1 or getattr(self, "is_highest_ancestor_for_user", False): # First get the role based on specific access
ancestors_links = [] role = self.get_role(user)
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
# Characteristics that are based only on specific access # 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_deleted = self.ancestors_deleted_at
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 # Compute access roles before adding link roles because we don't
# want anonymous users to access versions (we wouldn't know from # want anonymous users to access versions (we wouldn't know from
# which date to allow them anyway) # which date to allow them anyway)
# Anonymous users should also not see document accesses # 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 = ( 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 ) and not is_deleted
# Add roles provided by the document link, taking into account its ancestors link_select_options = LinkReachChoices.get_select_options(
links_definitions = self.get_links_definitions(ancestors_links) **self.ancestors_link_definition
public_roles = links_definitions.get(LinkReachChoices.PUBLIC, set()) )
authenticated_roles = ( link_definition = get_equivalent_link_definition(
links_definitions.get(LinkReachChoices.AUTHENTICATED, set()) [
if user.is_authenticated self.ancestors_link_definition,
else set() {"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
retrieve = can_get or is_owner
can_update = ( can_update = (
is_owner_or_admin or RoleChoices.EDITOR in roles is_owner_or_admin or role == RoleChoices.EDITOR
) and not is_deleted
can_comment = (can_update or role == RoleChoices.COMMENTER) and not is_deleted
can_create_children = can_update and user.is_authenticated
can_destroy = (
is_owner
if self.is_root()
else (is_owner_or_admin or (user.is_authenticated and self.creator == user))
) and not is_deleted ) and not is_deleted
ai_allow_reach_from = settings.AI_ALLOW_REACH_FROM ai_allow_reach_from = settings.AI_ALLOW_REACH_FROM
@@ -836,23 +783,27 @@ class Document(MP_Node, BaseModel):
"ai_translate": ai_access, "ai_translate": ai_access,
"attachment_upload": can_update, "attachment_upload": can_update,
"media_check": can_get, "media_check": can_get,
"can_edit": can_update,
"children_list": can_get, "children_list": can_get,
"children_create": can_update and user.is_authenticated, "children_create": can_create_children,
"collaboration_auth": can_get, "collaboration_auth": can_get,
"comment": can_comment,
"content": can_get,
"cors_proxy": can_get, "cors_proxy": can_get,
"descendants": can_get, "descendants": can_get,
"destroy": is_owner, "destroy": can_destroy,
"duplicate": can_get, "duplicate": can_get and user.is_authenticated,
"favorite": can_get and user.is_authenticated, "favorite": can_get and user.is_authenticated,
"link_configuration": is_owner_or_admin, "link_configuration": is_owner_or_admin,
"invite_owner": is_owner, "invite_owner": is_owner and not is_deleted,
"move": is_owner_or_admin and not self.ancestors_deleted_at, "mask": can_get and user.is_authenticated,
"move": is_owner_or_admin and not is_deleted,
"partial_update": can_update, "partial_update": can_update,
"restore": is_owner, "restore": is_owner,
"retrieve": can_get, "retrieve": retrieve,
"media_auth": can_get, "media_auth": can_get,
"link_select_options": LinkReachChoices.get_select_options(ancestors_links), "link_select_options": link_select_options,
"tree": can_get, "tree": retrieve,
"update": can_update, "update": can_update,
"versions_destroy": is_owner_or_admin, "versions_destroy": is_owner_or_admin,
"versions_list": has_access_role, "versions_list": has_access_role,
@@ -946,7 +897,8 @@ class Document(MP_Node, BaseModel):
if self.depth > 1: if self.depth > 1:
self._meta.model.objects.filter(pk=self.get_parent().pk).update( 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 # Mark all descendants as soft deleted
@@ -1010,6 +962,7 @@ class LinkTrace(BaseModel):
related_name="link_traces", related_name="link_traces",
) )
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="link_traces") user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="link_traces")
is_masked = models.BooleanField(default=False)
class Meta: class Meta:
db_table = "impress_link_trace" db_table = "impress_link_trace"
@@ -1103,48 +1056,125 @@ class DocumentAccess(BaseAccess):
super().save(*args, **kwargs) super().save(*args, **kwargs)
self.document.invalidate_nb_accesses_cache() 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): def delete(self, *args, **kwargs):
"""Override delete to clear the document's cache for number of accesses.""" """Override delete to clear the document's cache for number of accesses."""
super().delete(*args, **kwargs) super().delete(*args, **kwargs)
self.document.invalidate_nb_accesses_cache() 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): def get_abilities(self, user):
""" """
Compute and return abilities for a given user on the document access. Compute and return abilities for a given user on the document access.
""" """
roles = self._get_roles(self.document, user) ancestors_role, current_role = self.get_user_roles_tuple(user)
is_owner_or_admin = bool(set(roles).intersection(set(PRIVILEGED_ROLES))) role = RoleChoices.max(ancestors_role, current_role)
is_owner_or_admin = role in PRIVILEGED_ROLES
if self.role == RoleChoices.OWNER: if self.role == RoleChoices.OWNER:
can_delete = ( can_delete = role == RoleChoices.OWNER and (
RoleChoices.OWNER in roles # check if document is not root trying to avoid an extra query
and self.document.accesses.filter(role=RoleChoices.OWNER).count() > 1 self.document.depth > 1
) or DocumentAccess.objects.filter(
set_role_to = ( document_id=self.document_id, role=RoleChoices.OWNER
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER] ).count()
if can_delete > 1
else []
) )
set_role_to = RoleChoices.values if can_delete else []
else: else:
can_delete = is_owner_or_admin can_delete = is_owner_or_admin
set_role_to = [] set_role_to = []
if RoleChoices.OWNER in roles:
set_role_to.append(RoleChoices.OWNER)
if is_owner_or_admin: if is_owner_or_admin:
set_role_to.extend( set_role_to.extend(
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER] [
RoleChoices.READER,
RoleChoices.COMMENTER,
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 # Filter out roles that would be lower than the one the user already has
try: ancestors_role_priority = RoleChoices.get_priority(
set_role_to.remove(self.role) getattr(self, "max_ancestors_role", None)
except ValueError: )
pass 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 { return {
"destroy": can_delete, "destroy": can_delete,
"update": bool(set_role_to) and is_owner_or_admin, "update": bool(set_role_to) and is_owner_or_admin,
"partial_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, "set_role_to": set_role_to,
} }
@@ -1182,23 +1212,14 @@ class DocumentAskForAccess(BaseModel):
def get_abilities(self, user): def get_abilities(self, user):
"""Compute and return abilities for a given user.""" """Compute and return abilities for a given user."""
roles = [] user_role = self.document.get_role(user)
is_admin_or_owner = user_role in PRIVILEGED_ROLES
if user.is_authenticated: set_role_to = [
teams = user.teams role
try: for role in RoleChoices.values
roles = self.user_roles or [] if RoleChoices.get_priority(role) <= RoleChoices.get_priority(user_role)
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 { return {
"destroy": is_admin_or_owner, "destroy": is_admin_or_owner,
@@ -1206,6 +1227,7 @@ class DocumentAskForAccess(BaseModel):
"partial_update": is_admin_or_owner, "partial_update": is_admin_or_owner,
"retrieve": is_admin_or_owner, "retrieve": is_admin_or_owner,
"accept": is_admin_or_owner, "accept": is_admin_or_owner,
"set_role_to": set_role_to,
} }
def accept(self, role=None): def accept(self, role=None):
@@ -1255,6 +1277,153 @@ class DocumentAskForAccess(BaseModel):
self.document.send_email(subject, [email], context, language) self.document.send_email(subject, [email], context, language)
class Thread(BaseModel):
"""Discussion thread attached to a document.
A thread groups one or many comments. For backward compatibility with the
existing frontend (useComments hook) we still expose a flattened serializer
that returns a "content" field representing the first comment's body.
"""
document = models.ForeignKey(
Document,
on_delete=models.CASCADE,
related_name="threads",
)
creator = models.ForeignKey(
User,
on_delete=models.SET_NULL,
related_name="threads",
null=True,
blank=True,
)
resolved = models.BooleanField(default=False)
resolved_at = models.DateTimeField(null=True, blank=True)
resolved_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
related_name="resolved_threads",
null=True,
blank=True,
)
metadata = models.JSONField(default=dict, blank=True)
class Meta:
db_table = "impress_thread"
ordering = ("-created_at",)
verbose_name = _("Thread")
verbose_name_plural = _("Threads")
def __str__(self):
author = self.creator or _("Anonymous")
return f"Thread by {author!s} on {self.document!s}"
def get_abilities(self, user):
"""Compute and return abilities for a given user (mirrors comment logic)."""
role = self.document.get_role(user)
doc_abilities = self.document.get_abilities(user)
read_access = doc_abilities.get("comment", False)
write_access = self.creator == user or role in [
RoleChoices.OWNER,
RoleChoices.ADMIN,
]
return {
"destroy": write_access,
"update": write_access,
"partial_update": write_access,
"resolve": write_access,
"retrieve": read_access,
}
@property
def first_comment(self):
"""Return the first createdcomment of the thread."""
return self.comments.order_by("created_at").first()
class Comment(BaseModel):
"""A comment belonging to a thread."""
thread = models.ForeignKey(
Thread,
on_delete=models.CASCADE,
related_name="comments",
)
user = models.ForeignKey(
User,
on_delete=models.SET_NULL,
related_name="thread_comment",
null=True,
blank=True,
)
body = models.JSONField()
metadata = models.JSONField(default=dict, blank=True)
class Meta:
db_table = "impress_comment"
ordering = ("created_at",)
verbose_name = _("Comment")
verbose_name_plural = _("Comments")
def __str__(self):
"""Return the string representation of the comment."""
author = self.user or _("Anonymous")
return f"Comment by {author!s} on thread {self.thread_id}"
def get_abilities(self, user):
"""Return the abilities of the comment."""
role = self.thread.document.get_role(user)
doc_abilities = self.thread.document.get_abilities(user)
read_access = doc_abilities.get("comment", False)
can_react = read_access and user.is_authenticated
write_access = self.user == user or role in [
RoleChoices.OWNER,
RoleChoices.ADMIN,
]
return {
"destroy": write_access,
"update": write_access,
"partial_update": write_access,
"reactions": can_react,
"retrieve": read_access,
}
class Reaction(BaseModel):
"""Aggregated reactions for a given emoji on a comment.
We store one row per (comment, emoji) and maintain the list of user IDs who
reacted with that emoji. This matches the frontend interface where a
reaction exposes: emoji, createdAt (first reaction date) and userIds.
"""
comment = models.ForeignKey(
Comment,
on_delete=models.CASCADE,
related_name="reactions",
)
emoji = models.CharField(max_length=32)
users = models.ManyToManyField(User, related_name="reactions")
class Meta:
db_table = "impress_comment_reaction"
constraints = [
models.UniqueConstraint(
fields=["comment", "emoji"],
name="unique_comment_emoji",
violation_error_message=_(
"This emoji has already been reacted to this comment."
),
),
]
verbose_name = _("Reaction")
verbose_name_plural = _("Reactions")
def __str__(self):
"""Return the string representation of the reaction."""
return f"Reaction {self.emoji} on comment {self.comment.id}"
class Template(BaseModel): class Template(BaseModel):
"""HTML and CSS code used for formatting the print around the MarkDown body.""" """HTML and CSS code used for formatting the print around the MarkDown body."""
@@ -1277,10 +1446,10 @@ class Template(BaseModel):
def __str__(self): def __str__(self):
return self.title 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.""" """Return the roles a user has on a resource as an iterable."""
if not user.is_authenticated: if not user.is_authenticated:
return [] return None
try: try:
roles = self.user_roles or [] roles = self.user_roles or []
@@ -1291,21 +1460,20 @@ class Template(BaseModel):
).values_list("role", flat=True) ).values_list("role", flat=True)
except (models.ObjectDoesNotExist, IndexError): except (models.ObjectDoesNotExist, IndexError):
roles = [] roles = []
return roles
return RoleChoices.max(*roles)
def get_abilities(self, user): def get_abilities(self, user):
""" """
Compute and return abilities for a given user on the template. Compute and return abilities for a given user on the template.
""" """
roles = self.get_roles(user) role = self.get_role(user)
is_owner_or_admin = bool( is_owner_or_admin = role in PRIVILEGED_ROLES
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN}) can_get = self.is_public or bool(role)
) can_update = is_owner_or_admin or role == RoleChoices.EDITOR
can_get = self.is_public or bool(roles)
can_update = is_owner_or_admin or RoleChoices.EDITOR in roles
return { return {
"destroy": RoleChoices.OWNER in roles, "destroy": role == RoleChoices.OWNER,
"generate_document": can_get, "generate_document": can_get,
"accesses_manage": is_owner_or_admin, "accesses_manage": is_owner_or_admin,
"update": can_update, "update": can_update,
@@ -1352,11 +1520,65 @@ class TemplateAccess(BaseAccess):
def __str__(self): def __str__(self):
return f"{self.user!s} is {self.role:s} in template {self.template!s}" 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): def get_abilities(self, user):
""" """
Compute and return abilities for a given user on the template access. 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): class Invitation(BaseModel):

View File

@@ -9,7 +9,8 @@ from core import enums
AI_ACTIONS = { AI_ACTIONS = {
"prompt": ( "prompt": (
"Answer the prompt in markdown format. " "Answer the prompt using markdown formatting for structure and emphasis. "
"Return the content directly without wrapping it in code blocks or markdown delimiters. "
"Preserve the language and markdown formatting. " "Preserve the language and markdown formatting. "
"Do not provide any other information. " "Do not provide any other information. "
"Preserve the language." "Preserve the language."

View File

@@ -41,3 +41,35 @@ class CollaborationService:
f"Failed to notify WebSocket server. Status code: {response.status_code}, " f"Failed to notify WebSocket server. Status code: {response.status_code}, "
f"Response: {response.text}" f"Response: {response.text}"
) )
def get_document_connection_info(self, room, session_key):
"""
Get the connection info for a document.
"""
endpoint = "get-connections"
querystring = {
"room": room,
"sessionKey": session_key,
}
endpoint_url = f"{settings.COLLABORATION_API_URL}{endpoint}/"
headers = {"Authorization": settings.COLLABORATION_SERVER_SECRET}
try:
response = requests.get(
endpoint_url, headers=headers, params=querystring, timeout=10
)
except requests.RequestException as e:
raise requests.HTTPError("Failed to get document connection info.") from e
if response.status_code == 200:
result = response.json()
return result.get("count", 0), result.get("exists", False)
if response.status_code == 404:
return 0, False
raise requests.HTTPError(
f"Failed to get document connection info. Status code: {response.status_code}, "
f"Response: {response.text}"
)

View File

@@ -1,4 +1,6 @@
"""Converter services.""" """Y-Provider API services."""
from base64 import b64encode
from django.conf import settings from django.conf import settings
@@ -17,14 +19,6 @@ class ServiceUnavailableError(ConversionError):
"""Raised when the conversion service is unavailable.""" """Raised when the conversion service is unavailable."""
class InvalidResponseError(ConversionError):
"""Raised when the conversion service returns an invalid response."""
class MissingContentError(ConversionError):
"""Raised when the response is missing required content."""
class YdocConverter: class YdocConverter:
"""Service class for conversion-related operations.""" """Service class for conversion-related operations."""
@@ -32,47 +26,47 @@ class YdocConverter:
def auth_header(self): def auth_header(self):
"""Build microservice authentication header.""" """Build microservice authentication header."""
# Note: Yprovider microservice accepts only raw token, which is not recommended # Note: Yprovider microservice accepts only raw token, which is not recommended
return settings.Y_PROVIDER_API_KEY return f"Bearer {settings.Y_PROVIDER_API_KEY}"
def convert_markdown(self, text): def _request(self, url, data, content_type, accept):
"""Make a request to the Y-Provider API."""
response = requests.post(
url,
data=data,
headers={
"Authorization": self.auth_header,
"Content-Type": content_type,
"Accept": accept,
},
timeout=settings.CONVERSION_API_TIMEOUT,
verify=settings.CONVERSION_API_SECURE,
)
response.raise_for_status()
return response
def convert(
self, text, content_type="text/markdown", accept="application/vnd.yjs.doc"
):
"""Convert a Markdown text into our internal format using an external microservice.""" """Convert a Markdown text into our internal format using an external microservice."""
if not text: if not text:
raise ValidationError("Input text cannot be empty") raise ValidationError("Input text cannot be empty")
try: try:
response = requests.post( response = self._request(
f"{settings.Y_PROVIDER_API_BASE_URL}{settings.CONVERSION_API_ENDPOINT}/", f"{settings.Y_PROVIDER_API_BASE_URL}{settings.CONVERSION_API_ENDPOINT}/",
json={ text,
"content": text, content_type,
}, accept,
headers={
"Authorization": self.auth_header,
"Content-Type": "application/json",
},
timeout=settings.CONVERSION_API_TIMEOUT,
verify=settings.CONVERSION_API_SECURE,
) )
response.raise_for_status() if accept == "application/vnd.yjs.doc":
conversion_response = response.json() return b64encode(response.content).decode("utf-8")
if accept in {"text/markdown", "text/html"}:
return response.text
if accept == "application/json":
return response.json()
raise ValidationError("Unsupported format")
except requests.RequestException as err: except requests.RequestException as err:
raise ServiceUnavailableError( raise ServiceUnavailableError(
"Failed to connect to conversion service", "Failed to connect to conversion service",
) from err ) from err
except ValueError as err:
raise InvalidResponseError(
"Could not parse conversion service response"
) from err
try:
document_content = conversion_response[
settings.CONVERSION_API_CONTENT_FIELD
]
except KeyError as err:
raise MissingContentError(
f"Response missing required field: {settings.CONVERSION_API_CONTENT_FIELD}"
) from err
return document_content

View File

@@ -57,7 +57,7 @@ def test_authentication_getter_existing_user_via_email(
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked) monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(3): # user by sub, user by mail, update sub with django_assert_num_queries(4): # user by sub, user by mail, update sub
user = klass.get_or_create_user( user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None access_token="test-token", id_token=None, payload=None
) )
@@ -214,7 +214,7 @@ def test_authentication_getter_existing_user_change_fields_sub(
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked) monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed # One and only one additional update query when a field has changed
with django_assert_num_queries(2): with django_assert_num_queries(3):
authenticated_user = klass.get_or_create_user( authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None access_token="test-token", id_token=None, payload=None
) )
@@ -256,7 +256,7 @@ def test_authentication_getter_existing_user_change_fields_email(
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked) monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed # One and only one additional update query when a field has changed
with django_assert_num_queries(3): with django_assert_num_queries(4):
authenticated_user = klass.get_or_create_user( authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None access_token="test-token", id_token=None, payload=None
) )

View File

@@ -1,14 +1,16 @@
""" """
Test document accesses API endpoints for users in impress's core app. Test document accesses API endpoints for users in impress's core app.
""" """
# pylint: disable=too-many-lines
import random import random
from unittest import mock
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
from rest_framework.test import APIClient from rest_framework.test import APIClient
from core import factories, models from core import choices, factories, models
from core.api import serializers from core.api import serializers
from core.tests.conftest import TEAM, USER, VIA from core.tests.conftest import TEAM, USER, VIA
from core.tests.test_services_collaboration_services import ( # pylint: disable=unused-import from core.tests.test_services_collaboration_services import ( # pylint: disable=unused-import
@@ -51,12 +53,7 @@ def test_api_document_accesses_list_authenticated_unrelated():
f"/api/v1.0/documents/{document.id!s}/accesses/", f"/api/v1.0/documents/{document.id!s}/accesses/",
) )
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == { assert response.json() == []
"count": 0,
"next": None,
"previous": None,
"results": [],
}
def test_api_document_accesses_list_unexisting_document(): def test_api_document_accesses_list_unexisting_document():
@@ -69,39 +66,46 @@ def test_api_document_accesses_list_unexisting_document():
client.force_login(user) client.force_login(user)
response = client.get(f"/api/v1.0/documents/{uuid4()!s}/accesses/") response = client.get(f"/api/v1.0/documents/{uuid4()!s}/accesses/")
assert response.status_code == 200 assert response.status_code == 404
assert response.json() == { assert response.json() == {"detail": "Not found."}
"count": 0,
"next": None,
"previous": None,
"results": [],
}
@pytest.mark.parametrize("via", VIA) @pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize( @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( 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 Authenticated users with no privileged role should only be able to list document
to which they are directly related, whatever their role in the document. accesses associated with privileged roles for a document, including from ancestors.
""" """
user = factories.UserFactory() user = factories.UserFactory()
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
owner = factories.UserFactory() # Create documents structured as a tree
accesses = [] unreadable_ancestor = factories.DocumentFactory(link_reach="restricted")
# make all documents below the grand parent readable without a specific access for the user
document_access = factories.UserDocumentAccessFactory( grand_parent = factories.DocumentFactory(
user=owner, role=models.RoleChoices.OWNER parent=unreadable_ancestor, link_reach="authenticated"
) )
accesses.append(document_access) parent = factories.DocumentFactory(parent=grand_parent)
document = document_access.document 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: if via == USER:
models.DocumentAccess.objects.create( models.DocumentAccess.objects.create(
document=document, document=document,
@@ -116,33 +120,32 @@ def test_api_document_accesses_list_authenticated_related_non_privileged(
role=role, 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 # Accesses for other documents to which the user is related should not be listed either
other_access = factories.UserDocumentAccessFactory(user=user) other_access = factories.UserDocumentAccessFactory(user=user)
factories.UserDocumentAccessFactory(document=other_access.document) factories.UserDocumentAccessFactory(document=other_access.document)
response = client.get( with django_assert_num_queries(3):
f"/api/v1.0/documents/{document.id!s}/accesses/", 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 assert response.status_code == 200
content = response.json() 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), "id": str(access.id),
"document": {
"id": str(access.document_id),
"path": access.document.path,
"depth": access.document.depth,
},
"user": { "user": {
"id": None,
"email": None,
"full_name": access.user.full_name, "full_name": access.user.full_name,
"short_name": access.user.short_name, "short_name": access.user.short_name,
} }
@@ -150,40 +153,47 @@ def test_api_document_accesses_list_authenticated_related_non_privileged(
else None, else None,
"team": access.team, "team": access.team,
"role": access.role, "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"], 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("via", VIA)
@pytest.mark.parametrize("role", models.PRIVILEGED_ROLES) @pytest.mark.parametrize(
def test_api_document_accesses_list_authenticated_related_privileged_roles( "role", [role for role in choices.RoleChoices if role in choices.PRIVILEGED_ROLES]
via, role, mock_user_teams )
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 Authenticated users with a privileged role should be able to list all
to which they are directly related, whatever their role in the document. document accesses whatever the role, including from ancestors.
""" """
user = factories.UserFactory() user = factories.UserFactory()
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
owner = factories.UserFactory() # Create documents structured as a tree
accesses = [] unreadable_ancestor = factories.DocumentFactory(link_reach="restricted")
# make all documents below the grand parent readable without a specific access for the user
document_access = factories.UserDocumentAccessFactory( grand_parent = factories.DocumentFactory(
user=owner, role=models.RoleChoices.OWNER parent=unreadable_ancestor, link_reach="authenticated"
) )
accesses.append(document_access) parent = factories.DocumentFactory(parent=grand_parent)
document = document_access.document document = factories.DocumentFactory(parent=parent)
user_access = None child = factories.DocumentFactory(parent=document)
if via == USER: if via == USER:
user_access = models.DocumentAccess.objects.create( user_access = models.DocumentAccess.objects.create(
document=document, document=document,
@@ -197,61 +207,322 @@ def test_api_document_accesses_list_authenticated_related_privileged_roles(
team="lasuite", team="lasuite",
role=role, role=role,
) )
else:
raise RuntimeError()
access1 = factories.TeamDocumentAccessFactory(document=document) # Create accesses related to each document
access2 = factories.UserDocumentAccessFactory(document=document) ancestors_accesses = [
accesses.append(access1) # Access on unreadable ancestor should still be listed
accesses.append(access2) # 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 # Accesses for other documents to which the user is related should not be listed either
other_access = factories.UserDocumentAccessFactory(user=user) other_access = factories.UserDocumentAccessFactory(user=user)
factories.UserDocumentAccessFactory(document=other_access.document) factories.UserDocumentAccessFactory(document=other_access.document)
response = client.get( with django_assert_num_queries(3):
f"/api/v1.0/documents/{document.id!s}/accesses/", 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
assert response.status_code == 200 assert response.status_code == 200
content = response.json() content = response.json()
assert len(content["results"]) == 4 assert len(content) == 7
assert sorted(content["results"], key=lambda x: x["id"]) == sorted( assert sorted(content, key=lambda x: x["id"]) == sorted(
[ [
{ {
"id": str(user_access.id), "id": str(access.id),
"user": base_user if via == "user" else None, "document": {
"team": "lasuite" if via == "team" else "", "id": str(access.document_id),
"role": user_access.role, "path": access.document.path,
"abilities": user_access.get_abilities(user), "depth": access.document.depth,
}, },
{ "user": {
"id": str(access1.id), "id": str(access.user.id),
"user": None, "email": access.user.email,
"team": access1.team, "language": access.user.language,
"role": access1.role, "full_name": access.user.full_name,
"abilities": access1.get_abilities(user), "short_name": access.user.short_name,
}, }
{ if access.user
"id": str(access2.id), else None,
"user": access2_user, "max_ancestors_role": None,
"team": "", "max_role": access.role,
"role": access2.role, "team": access.team,
"abilities": access2.get_abilities(user), "role": access.role,
}, "abilities": access.get_abilities(user),
{ }
"id": str(document_access.id), for access in ancestors_accesses + document_accesses
"user": serializers.UserSerializer(instance=owner).data,
"team": "",
"role": models.RoleChoices.OWNER,
"abilities": document_access.get_abilities(user),
},
], ],
key=lambda x: x["id"], 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",
"commenter",
"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="commenter"
)
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)] == [
"commenter",
"editor",
"administrator",
"owner",
]
assert result_dict[str(parent_access.id)] == []
assert result_dict[str(parent_access_other_user.id)] == [
"reader",
"commenter",
"editor",
"administrator",
"owner",
]
@pytest.mark.parametrize(
"roles,results",
[
[
["administrator", "reader", "reader", "reader"],
[
["reader", "commenter", "editor", "administrator"],
[],
[],
["reader", "commenter", "editor", "administrator"],
],
],
[
["owner", "reader", "reader", "reader"],
[
["reader", "commenter", "editor", "administrator", "owner"],
[],
[],
["reader", "commenter", "editor", "administrator", "owner"],
],
],
[
["owner", "reader", "reader", "owner"],
[
["reader", "commenter", "editor", "administrator", "owner"],
[],
[],
["reader", "commenter", "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", "commenter", "editor", "administrator"],
[],
[],
["reader", "commenter", "editor", "administrator"],
],
],
[
["owner", "reader", "reader", "reader"],
[
["reader", "commenter", "editor", "administrator", "owner"],
[],
[],
["reader", "commenter", "editor", "administrator", "owner"],
],
],
[
["owner", "reader", "reader", "owner"],
[
["reader", "commenter", "editor", "administrator", "owner"],
[],
[],
["reader", "commenter", "editor", "administrator", "owner"],
],
],
[
["reader", "reader", "reader", "owner"],
[
["reader", "commenter", "editor", "administrator", "owner"],
[],
[],
["reader", "commenter", "editor", "administrator", "owner"],
],
],
[
["reader", "administrator", "reader", "editor"],
[
["reader", "commenter", "editor", "administrator"],
["reader", "commenter", "editor", "administrator"],
[],
[],
],
],
[
["editor", "editor", "administrator", "editor"],
[
["reader", "commenter", "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(): def test_api_document_accesses_retrieve_anonymous():
""" """
Anonymous users should not be allowed to retrieve a document access. Anonymous users should not be allowed to retrieve a document access.
@@ -307,7 +578,9 @@ def test_api_document_accesses_retrieve_authenticated_unrelated():
@pytest.mark.parametrize("via", VIA) @pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize("role", models.RoleChoices) @pytest.mark.parametrize("role", models.RoleChoices)
def test_api_document_accesses_retrieve_authenticated_related( 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 A user who is related to a document should be allowed to retrieve the
@@ -333,7 +606,7 @@ def test_api_document_accesses_retrieve_authenticated_related(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", 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 assert response.status_code == 403
else: else:
access_user = serializers.UserSerializer(instance=access.user).data access_user = serializers.UserSerializer(instance=access.user).data
@@ -341,9 +614,16 @@ def test_api_document_accesses_retrieve_authenticated_related(
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == { assert response.json() == {
"id": str(access.id), "id": str(access.id),
"document": {
"id": str(access.document_id),
"path": access.document.path,
"depth": access.document.depth,
},
"user": access_user, "user": access_user,
"team": "", "team": "",
"role": access.role, "role": access.role,
"max_ancestors_role": None,
"max_role": access.role,
"abilities": access.get_abilities(user), "abilities": access.get_abilities(user),
} }
@@ -448,7 +728,9 @@ def test_api_document_accesses_update_authenticated_reader_or_editor(
@pytest.mark.parametrize("via", VIA) @pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize("create_for", VIA)
def test_api_document_accesses_update_administrator_except_owner( def test_api_document_accesses_update_administrator_except_owner(
create_for,
via, via,
mock_user_teams, mock_user_teams,
mock_reset_connections, # pylint: disable=redefined-outer-name mock_reset_connections, # pylint: disable=redefined-outer-name
@@ -481,32 +763,31 @@ def test_api_document_accesses_update_administrator_except_owner(
new_values = { new_values = {
"id": uuid4(), "id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(["administrator", "editor", "reader"]), "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(): for field, value in new_values.items():
new_data = {**old_values, field: value} 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( response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data, data=new_data,
format="json", format="json",
) )
assert response.status_code == 403 assert response.status_code == 200
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
access.refresh_from_db() access.refresh_from_db()
updated_values = serializers.DocumentAccessSerializer(instance=access).data updated_values = serializers.DocumentAccessSerializer(instance=access).data
if field == "role": if field in ["role", "max_role"]:
assert updated_values == {**old_values, "role": new_values["role"]} assert updated_values == {
**old_values,
"role": new_values["role"],
"max_role": new_values["role"],
}
else: else:
assert updated_values == old_values assert updated_values == old_values
@@ -601,7 +882,7 @@ def test_api_document_accesses_update_administrator_to_owner(
for field, value in new_values.items(): for field, value in new_values.items():
new_data = {**old_values, field: value} new_data = {**old_values, field: value}
# We are not allowed or not really updating the role # 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( response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data, data=new_data,
@@ -624,7 +905,9 @@ def test_api_document_accesses_update_administrator_to_owner(
@pytest.mark.parametrize("via", VIA) @pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize("create_for", VIA)
def test_api_document_accesses_update_owner( def test_api_document_accesses_update_owner(
create_for,
via, via,
mock_user_teams, mock_user_teams,
mock_reset_connections, # pylint: disable=redefined-outer-name mock_reset_connections, # pylint: disable=redefined-outer-name
@@ -655,42 +938,39 @@ def test_api_document_accesses_update_owner(
new_values = { new_values = {
"id": uuid4(), "id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.values), "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(): for field, value in new_values.items():
new_data = {**old_values, field: value} new_data = {**old_values, field: value}
if ( with mock_reset_connections(document.id, str(access.user_id)):
new_data["role"] == old_values["role"]
): # we are not really updating the role
response = client.put( response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data, data=new_data,
format="json", 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() access.refresh_from_db()
updated_values = serializers.DocumentAccessSerializer(instance=access).data updated_values = serializers.DocumentAccessSerializer(instance=access).data
if field == "role": if field in ["role", "max_role"]:
assert updated_values == {**old_values, "role": new_values["role"]} assert updated_values == {
**old_values,
"role": new_values["role"],
"max_role": new_values["role"],
}
else: else:
assert updated_values == old_values assert updated_values == old_values
@pytest.mark.parametrize("via", VIA) @pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_update_owner_self( def test_api_document_accesses_update_owner_self_root(
via, via,
mock_user_teams, mock_user_teams,
mock_reset_connections, # pylint: disable=redefined-outer-name mock_reset_connections, # pylint: disable=redefined-outer-name
@@ -751,6 +1031,51 @@ def test_api_document_accesses_update_owner_self(
assert access.role == new_role 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 # Delete
@@ -931,17 +1256,16 @@ def test_api_document_accesses_delete_owners(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
) )
assert response.status_code == 204 assert response.status_code == 204
assert models.DocumentAccess.objects.count() == 1 assert models.DocumentAccess.objects.count() == 1
@pytest.mark.parametrize("via", VIA) @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) user = factories.UserFactory(with_owned_document=True)
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
@@ -964,3 +1288,84 @@ def test_api_document_accesses_delete_owners_last_owner(via, mock_user_teams):
assert response.status_code == 403 assert response.status_code == 403
assert models.DocumentAccess.objects.count() == 2 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
def test_api_document_accesses_throttling(settings):
"""Test api document accesses throttling."""
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document_access"] = "2/minute"
user = factories.UserFactory()
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(
document=document, user=user, role="administrator"
)
client = APIClient()
client.force_login(user)
for _i in range(2):
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
assert response.status_code == 200
with mock.patch("core.api.throttling.capture_message") as mock_capture_message:
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
assert response.status_code == 429
mock_capture_message.assert_called_once_with(
"Rate limit exceeded for scope document_access", "warning"
)

View File

@@ -103,32 +103,37 @@ def test_api_document_accesses_create_authenticated_reader_or_editor(
assert not models.DocumentAccess.objects.filter(user=other_user).exists() assert not models.DocumentAccess.objects.filter(user=other_user).exists()
@pytest.mark.parametrize("depth", [1, 2, 3])
@pytest.mark.parametrize("via", VIA) @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 Administrators of a document (direct or by heritage) should be able to create
except for the "owner" role. document accesses except for the "owner" role.
An email should be sent to the accesses to notify them of the adding. An email should be sent to the accesses to notify them of the adding.
""" """
user = factories.UserFactory(with_owned_document=True) user = factories.UserFactory(with_owned_document=True)
client = APIClient() client = APIClient()
client.force_login(user) 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: if via == USER:
factories.UserDocumentAccessFactory( factories.UserDocumentAccessFactory(
document=document, user=user, role="administrator" document=documents[0], user=user, role="administrator"
) )
elif via == TEAM: elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"] mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory( factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="administrator" document=documents[0], team="lasuite", role="administrator"
) )
other_user = factories.UserFactory(language="en-us") other_user = factories.UserFactory(language="en-us")
document = documents[-1]
# It should not be allowed to create an owner access
response = client.post( response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/", 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.status_code == 403
assert response.json() == { 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 # 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 other_user = serializers.UserSerializer(instance=other_user).data
assert response.json() == { assert response.json() == {
"abilities": new_document_access.get_abilities(user), "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), "id": str(new_document_access.id),
"team": "", "max_ancestors_role": None,
"max_role": role,
"role": role, "role": role,
"team": "",
"user": other_user, "user": other_user,
} }
assert len(mail.outbox) == 1 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 assert "docs/" + str(document.id) + "/" in email_content
@pytest.mark.parametrize("depth", [1, 2, 3])
@pytest.mark.parametrize("via", VIA) @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. 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() user = factories.UserFactory()
client = APIClient() client = APIClient()
client.force_login(user) 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: if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="owner") factories.UserDocumentAccessFactory(
document=documents[0], user=user, role="owner"
)
elif via == TEAM: elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"] mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory( factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="owner" document=documents[0], team="lasuite", role="owner"
) )
other_user = factories.UserFactory(language="en-us") other_user = factories.UserFactory(language="en-us")
document = documents[-1]
role = random.choice([role[0] for role in models.RoleChoices.choices]) role = random.choice([role[0] for role in models.RoleChoices.choices])
assert len(mail.outbox) == 0 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() new_document_access = models.DocumentAccess.objects.filter(user=other_user).get()
other_user = serializers.UserSerializer(instance=other_user).data other_user = serializers.UserSerializer(instance=other_user).data
assert response.json() == { assert response.json() == {
"id": str(new_document_access.id),
"user": other_user,
"team": "",
"role": role,
"abilities": new_document_access.get_abilities(user), "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 assert len(mail.outbox) == 1
email = mail.outbox[0] 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 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) @pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_create_email_in_receivers_language(via, mock_user_teams): 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() ).get()
other_user_data = serializers.UserSerializer(instance=other_user).data other_user_data = serializers.UserSerializer(instance=other_user).data
assert response.json() == { assert response.json() == {
"id": str(new_document_access.id),
"user": other_user_data,
"team": "",
"role": role,
"abilities": new_document_access.get_abilities(user), "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 assert len(mail.outbox) == index + 1
email = mail.outbox[index] email = mail.outbox[index]

View File

@@ -596,6 +596,32 @@ def test_api_document_invitations_create_cannot_invite_existing_users():
} }
def test_api_document_invitations_create_lower_email():
"""
No matter the case, the email should be converted to lowercase.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, "owner")])
# Build an invitation to the email of an existing identity in the db
invitation_values = {
"email": "GuEst@example.com",
"role": random.choice(models.RoleChoices.values),
}
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == 201
assert response.json()["email"] == "guest@example.com"
# Update # Update
@@ -743,6 +769,37 @@ def test_api_document_invitations_update_authenticated_unprivileged(
assert value == old_invitation_values[key] assert value == old_invitation_values[key]
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize("role", ["administrator", "owner"])
def test_api_document_invitations_patch(via, role, mock_user_teams):
"""Partially updating an invitation should be allowed."""
user = factories.UserFactory()
invitation = factories.InvitationFactory(role="editor")
if via == USER:
factories.UserDocumentAccessFactory(
document=invitation.document, user=user, role=role
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=invitation.document, team="lasuite", role=role
)
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/documents/{invitation.document.id!s}/invitations/{invitation.id!s}/",
{"role": "reader"},
format="json",
)
assert response.status_code == 200
invitation.refresh_from_db()
assert invitation.role == "reader"
# Delete # Delete
@@ -824,3 +881,29 @@ def test_api_document_invitations_delete_readers_or_editors(via, role, mock_user
response.json()["detail"] response.json()["detail"]
== "You do not have permission to perform this action." == "You do not have permission to perform this action."
) )
def test_api_document_invitations_throttling(settings):
"""Test api document ask for access throttling."""
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["invitation"]
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["invitation"] = "2/minute"
user = factories.UserFactory()
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
factories.InvitationFactory(document=document, issuer=user)
client = APIClient()
client.force_login(user)
for _i in range(2):
response = client.get(f"/api/v1.0/documents/{document.id}/invitations/")
assert response.status_code == 200
with mock.patch("core.api.throttling.capture_message") as mock_capture_message:
response = client.get(f"/api/v1.0/documents/{document.id}/invitations/")
assert response.status_code == 429
mock_capture_message.assert_called_once_with(
"Rate limit exceeded for scope invitation", "warning"
)
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["invitation"] = current_rate

View File

@@ -175,8 +175,11 @@ def test_api_documents_ai_transform_authenticated_success(mock_create, reach, ro
{ {
"role": "system", "role": "system",
"content": ( "content": (
"Answer the prompt in markdown format. Preserve the language and markdown " "Answer the prompt using markdown formatting for structure and emphasis. "
"formatting. Do not provide any other information. Preserve the language." "Return the content directly without wrapping it in code blocks or markdown delimiters. "
"Preserve the language and markdown formatting. "
"Do not provide any other information. "
"Preserve the language."
), ),
}, },
{"role": "user", "content": "Hello"}, {"role": "user", "content": "Hello"},
@@ -249,8 +252,11 @@ def test_api_documents_ai_transform_success(mock_create, via, role, mock_user_te
{ {
"role": "system", "role": "system",
"content": ( "content": (
"Answer the prompt in markdown format. Preserve the language and markdown " "Answer the prompt using markdown formatting for structure and emphasis. "
"formatting. Do not provide any other information. Preserve the language." "Return the content directly without wrapping it in code blocks or markdown delimiters. "
"Preserve the language and markdown formatting. "
"Do not provide any other information. "
"Preserve the language."
), ),
}, },
{"role": "user", "content": "Hello"}, {"role": "user", "content": "Hello"},

View File

@@ -1,6 +1,7 @@
"""Test API for document ask for access.""" """Test API for document ask for access."""
import uuid import uuid
from unittest import mock
from django.core import mail from django.core import mail
@@ -48,7 +49,7 @@ def test_api_documents_ask_for_access_create_authenticated():
An email should be sent to document owners and admins to notify them. An email should be sent to document owners and admins to notify them.
""" """
owner_user = UserFactory(language="en-us") owner_user = UserFactory(language="en-us")
admin_user = UserFactory(language="fr-fr") admin_user = UserFactory(language="en-us")
document = DocumentFactory( document = DocumentFactory(
users=[ users=[
(owner_user, RoleChoices.OWNER), (owner_user, RoleChoices.OWNER),
@@ -97,7 +98,27 @@ def test_api_documents_ask_for_access_create_authenticated():
assert document.title.lower() in email_subject.lower() assert document.title.lower() in email_subject.lower()
def test_api_documents_ask_for_access_create_authenticated_specific_role(): def test_api_documents_ask_for_access_create_authenticated_non_root_document():
"""
Authenticated users should not be able to create a document ask for access on a non-root
document.
"""
parent = DocumentFactory()
child = DocumentFactory(parent=parent)
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(f"/api/v1.0/documents/{child.id}/ask-for-access/")
assert response.status_code == 404
@pytest.mark.parametrize(
"role", [role for role in RoleChoices if role != RoleChoices.OWNER]
)
def test_api_documents_ask_for_access_create_authenticated_specific_role(role):
""" """
Authenticated users should be able to create a document ask for access with a specific role. Authenticated users should be able to create a document ask for access with a specific role.
""" """
@@ -109,17 +130,35 @@ def test_api_documents_ask_for_access_create_authenticated_specific_role():
response = client.post( response = client.post(
f"/api/v1.0/documents/{document.id}/ask-for-access/", f"/api/v1.0/documents/{document.id}/ask-for-access/",
data={"role": RoleChoices.EDITOR}, data={"role": role},
) )
assert response.status_code == 201 assert response.status_code == 201
assert DocumentAskForAccess.objects.filter( assert DocumentAskForAccess.objects.filter(
document=document, document=document,
user=user, user=user,
role=RoleChoices.EDITOR, role=role,
).exists() ).exists()
def test_api_documents_ask_for_access_create_authenticated_owner_role():
"""
Authenticated users should not be able to create a document ask for access with the owner 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.OWNER},
)
assert response.status_code == 400
assert response.json() == {"role": ['"owner" is not a valid choice.']}
def test_api_documents_ask_for_access_create_authenticated_already_has_access(): 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.""" """Authenticated users with existing access can ask for access with a different role."""
user = UserFactory() user = UserFactory()
@@ -196,6 +235,20 @@ def test_api_documents_ask_for_access_list_authenticated():
} }
def test_api_documents_ask_for_access_list_authenticated_non_root_document():
"""
Authenticated users should not be able to list document ask for access on a non-root document.
"""
parent = DocumentFactory()
child = DocumentFactory(parent=parent)
client = APIClient()
client.force_login(UserFactory())
response = client.get(f"/api/v1.0/documents/{child.id}/ask-for-access/")
assert response.status_code == 404
def test_api_documents_ask_for_access_list_authenticated_own_request(): def test_api_documents_ask_for_access_list_authenticated_own_request():
"""Authenticated users should be able to list their own document ask for access.""" """Authenticated users should be able to list their own document ask for access."""
document = DocumentFactory() document = DocumentFactory()
@@ -234,6 +287,7 @@ def test_api_documents_ask_for_access_list_authenticated_own_request():
"update": False, "update": False,
"partial_update": False, "partial_update": False,
"retrieve": False, "retrieve": False,
"set_role_to": [],
}, },
} }
], ],
@@ -289,7 +343,7 @@ def test_api_documents_ask_for_access_list_non_owner_or_admin(role):
} }
@pytest.mark.parametrize("role", [RoleChoices.OWNER]) @pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
def test_api_documents_ask_for_access_list_owner_or_admin(role): 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.""" """Owner or admin users should be able to list document ask for access."""
user = UserFactory() user = UserFactory()
@@ -303,6 +357,16 @@ def test_api_documents_ask_for_access_list_owner_or_admin(role):
response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/") response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/")
assert response.status_code == 200 assert response.status_code == 200
expected_set_role_to = [
RoleChoices.READER,
RoleChoices.COMMENTER,
RoleChoices.EDITOR,
RoleChoices.ADMIN,
]
if role == RoleChoices.OWNER:
expected_set_role_to.append(RoleChoices.OWNER)
assert response.json() == { assert response.json() == {
"count": 3, "count": 3,
"next": None, "next": None,
@@ -322,6 +386,7 @@ def test_api_documents_ask_for_access_list_owner_or_admin(role):
"update": True, "update": True,
"partial_update": True, "partial_update": True,
"retrieve": True, "retrieve": True,
"set_role_to": expected_set_role_to,
}, },
} }
for document_ask_for_access in document_ask_for_accesses for document_ask_for_access in document_ask_for_accesses
@@ -329,6 +394,23 @@ def test_api_documents_ask_for_access_list_owner_or_admin(role):
} }
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
def test_api_documents_ask_for_access_list_admin_non_root_document(role):
"""
Authenticated users should not be able to list document ask for access on a non-root document.
"""
user = UserFactory()
parent = DocumentFactory(users=[(user, role)])
child = DocumentFactory(parent=parent, users=[(user, role)])
DocumentAskForAccessFactory.create_batch(3, document=child, role=RoleChoices.READER)
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/documents/{child.id}/ask-for-access/")
assert response.status_code == 404
## Retrieve ## Retrieve
@@ -397,6 +479,14 @@ def test_api_documents_ask_for_access_retrieve_owner_or_admin(role):
f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/" f"/api/v1.0/documents/{document.id}/ask-for-access/{document_ask_for_access.id}/"
) )
assert response.status_code == 200 assert response.status_code == 200
expected_set_role_to = [
RoleChoices.READER,
RoleChoices.COMMENTER,
RoleChoices.EDITOR,
RoleChoices.ADMIN,
]
if role == RoleChoices.OWNER:
expected_set_role_to.append(RoleChoices.OWNER)
assert response.json() == { assert response.json() == {
"id": str(document_ask_for_access.id), "id": str(document_ask_for_access.id),
"document": str(document.id), "document": str(document.id),
@@ -411,10 +501,33 @@ def test_api_documents_ask_for_access_retrieve_owner_or_admin(role):
"update": True, "update": True,
"partial_update": True, "partial_update": True,
"retrieve": True, "retrieve": True,
"set_role_to": expected_set_role_to,
}, },
} }
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
def test_api_documents_ask_for_access_retrieve_authenticated_non_root_document(role):
"""
Authenticated users should not be able to retrieve document ask for access on a non-root
document.
"""
user = UserFactory()
parent = DocumentFactory(users=[(user, role)])
child = DocumentFactory(parent=parent, users=[(user, role)])
document_ask_for_access = DocumentAskForAccessFactory(
document=child, role=RoleChoices.READER
)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{child.id}/ask-for-access/{document_ask_for_access.id}/"
)
assert response.status_code == 404
## Delete ## Delete
@@ -487,6 +600,28 @@ def test_api_documents_ask_for_access_delete_owner_or_admin(role):
).exists() ).exists()
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
def test_api_documents_ask_for_access_delete_authenticated_non_root_document(role):
"""
Authenticated users should not be able to delete document ask for access on a non-root
document.
"""
user = UserFactory()
parent = DocumentFactory(users=[(user, role)])
child = DocumentFactory(parent=parent, users=[(user, role)])
document_ask_for_access = DocumentAskForAccessFactory(
document=child, role=RoleChoices.READER
)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{child.id}/ask-for-access/{document_ask_for_access.id}/"
)
assert response.status_code == 404
## Accept ## Accept
@@ -654,3 +789,104 @@ def test_api_documents_ask_for_access_accept_authenticated_owner_or_admin_update
).exists() ).exists()
document_access.refresh_from_db() document_access.refresh_from_db()
assert document_access.role == RoleChoices.ADMIN assert document_access.role == RoleChoices.ADMIN
def test_api_documents_ask_for_access_accept_admin_cannot_accept_owner_role():
"""
Admin users should not be able to accept document ask for access with the owner role.
"""
user = UserFactory()
document = DocumentFactory(users=[(user, RoleChoices.ADMIN)])
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.OWNER},
)
assert response.status_code == 400
assert response.json() == {
"detail": "You cannot accept a role higher than your own."
}
def test_api_documents_ask_for_access_accept_owner_can_accept_owner_role():
"""
Owner users should be able to accept document ask for access with the owner role.
"""
user = UserFactory()
document = DocumentFactory(users=[(user, RoleChoices.OWNER)])
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.OWNER},
)
assert response.status_code == 204
assert not DocumentAskForAccess.objects.filter(
id=document_ask_for_access.id
).exists()
@pytest.mark.parametrize("role", [RoleChoices.OWNER, RoleChoices.ADMIN])
def test_api_documents_ask_for_access_accept_authenticated_non_root_document(role):
"""
Authenticated users should not be able to accept document ask for access on a non-root
document.
"""
user = UserFactory()
parent = DocumentFactory(users=[(user, role)])
child = DocumentFactory(parent=parent, users=[(user, role)])
document_ask_for_access = DocumentAskForAccessFactory(
document=child, role=RoleChoices.READER
)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{child.id}/ask-for-access/{document_ask_for_access.id}/accept/"
)
assert response.status_code == 404
def test_api_document_ask_for_access_throttling(settings):
"""Test api document ask for access throttling."""
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"][
"document_ask_for_access"
]
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document_ask_for_access"] = (
"2/minute"
)
document = DocumentFactory()
DocumentAskForAccessFactory.create_batch(
3, document=document, role=RoleChoices.READER
)
user = UserFactory()
client = APIClient()
client.force_login(user)
for _i in range(2):
response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/")
assert response.status_code == 200
with mock.patch("core.api.throttling.capture_message") as mock_capture_message:
response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/")
assert response.status_code == 429
mock_capture_message.assert_called_once_with(
"Rate limit exceeded for scope document_ask_for_access", "warning"
)
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document_ask_for_access"] = (
current_rate
)

View File

@@ -0,0 +1,318 @@
"""Test the can_edit endpoint in the viewset DocumentViewSet."""
from django.core.cache import cache
import pytest
import responses
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
@responses.activate
@pytest.mark.parametrize("ws_not_connected_ready_only", [True, False])
@pytest.mark.parametrize("role", ["editor", "reader"])
def test_api_documents_can_edit_anonymous(settings, ws_not_connected_ready_only, role):
"""Anonymous users can edit documents when link_role is editor."""
document = factories.DocumentFactory(link_reach="public", link_role=role)
client = APIClient()
session_key = client.session.session_key
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = ws_not_connected_ready_only
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
response = client.get(f"/api/v1.0/documents/{document.id!s}/can-edit/")
if role == "reader":
assert response.status_code == 401
else:
assert response.status_code == 200
assert response.json() == {"can_edit": True}
assert ws_resp.call_count == (1 if ws_not_connected_ready_only else 0)
@responses.activate
@pytest.mark.parametrize("ws_not_connected_ready_only", [True, False])
def test_api_documents_can_edit_authenticated_no_websocket(
settings, ws_not_connected_ready_only
):
"""
A user not connected to the websocket and no other user have already updated the document,
the document can be updated.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = ws_not_connected_ready_only
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
assert cache.get(f"docs:no-websocket:{document.id}") is None
response = client.get(
f"/api/v1.0/documents/{document.id!s}/can-edit/",
)
assert response.status_code == 200
assert response.json() == {"can_edit": True}
assert ws_resp.call_count == (1 if ws_not_connected_ready_only else 0)
@responses.activate
def test_api_documents_can_edit_authenticated_no_websocket_user_already_editing(
settings,
):
"""
A user not connected to the websocket and another user have already updated the document,
the document can not be updated.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
response = client.get(
f"/api/v1.0/documents/{document.id!s}/can-edit/",
)
assert response.status_code == 200
assert response.json() == {"can_edit": False}
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_can_edit_no_websocket_other_user_connected_to_websocket(
settings,
):
"""
A user not connected to the websocket and another user is connected to the websocket,
the document can not be updated.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": False})
assert cache.get(f"docs:no-websocket:{document.id}") is None
response = client.get(
f"/api/v1.0/documents/{document.id!s}/can-edit/",
)
assert response.status_code == 200
assert response.json() == {"can_edit": False}
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_can_edit_user_connected_to_websocket(settings):
"""
A user connected to the websocket, the document can be updated.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": True})
assert cache.get(f"docs:no-websocket:{document.id}") is None
response = client.get(
f"/api/v1.0/documents/{document.id!s}/can-edit/",
)
assert response.status_code == 200
assert response.json() == {"can_edit": True}
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_can_edit_websocket_server_unreachable_fallback_to_no_websocket(
settings,
):
"""
When the websocket server is unreachable, the document can be updated like if the user was
not connected to the websocket.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
assert cache.get(f"docs:no-websocket:{document.id}") is None
response = client.get(
f"/api/v1.0/documents/{document.id!s}/can-edit/",
)
assert response.status_code == 200
assert response.json() == {"can_edit": True}
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_can_edit_websocket_server_unreachable_fallback_to_no_websocket_other_users(
settings,
):
"""
When the websocket server is unreachable, the behavior fallback to the no websocket one.
If an other user is already editing, the document can not be updated.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
response = client.get(
f"/api/v1.0/documents/{document.id!s}/can-edit/",
)
assert response.status_code == 200
assert response.json() == {"can_edit": False}
assert cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_can_edit_websocket_server_room_not_found(
settings,
):
"""
When the websocket server returns a 404, the document can be updated like if the user was
not connected to the websocket.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=404)
assert cache.get(f"docs:no-websocket:{document.id}") is None
response = client.get(
f"/api/v1.0/documents/{document.id!s}/can-edit/",
)
assert response.status_code == 200
assert response.json() == {"can_edit": True}
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_can_edit_websocket_server_room_not_found_other_already_editing(
settings,
):
"""
When the websocket server returns a 404 and another user is editing the document,
the response should be can-edit=False.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=404)
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
response = client.get(
f"/api/v1.0/documents/{document.id!s}/can-edit/",
)
assert response.status_code == 200
assert response.json() == {"can_edit": False}
assert ws_resp.call_count == 1

View File

@@ -98,7 +98,9 @@ def test_api_documents_children_create_authenticated_success(reach, role, depth)
if i == 0: if i == 0:
document = factories.DocumentFactory(link_reach=reach, link_role=role) document = factories.DocumentFactory(link_reach=reach, link_role=role)
else: else:
document = factories.DocumentFactory(parent=document, link_role="reader") document = factories.DocumentFactory(
parent=document, link_reach="restricted"
)
response = client.post( response = client.post(
f"/api/v1.0/documents/{document.id!s}/children/", 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"]) child = Document.objects.get(id=response.json()["id"])
assert child.title == "my child" assert child.title == "my child"
assert child.link_reach == "restricted" 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]) @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"]) child = Document.objects.get(id=response.json()["id"])
assert child.title == "my child" assert child.title == "my child"
assert child.link_reach == "restricted" 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(): def test_api_documents_children_create_authenticated_title_null():

View File

@@ -14,13 +14,18 @@ from core import factories
pytestmark = pytest.mark.django_db 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.""" """Anonymous users should be allowed to retrieve the children of a public document."""
document = factories.DocumentFactory(link_reach="public") document = factories.DocumentFactory(link_reach="public")
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1) 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.status_code == 200
assert response.json() == { assert response.json() == {
@@ -30,8 +35,13 @@ def test_api_documents_children_list_anonymous_public_standalone():
"results": [ "results": [
{ {
"abilities": child1.get_abilities(AnonymousUser()), "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"), "created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id), "creator": str(child1.creator.id),
"deleted_at": None,
"depth": 2, "depth": 2,
"excerpt": child1.excerpt, "excerpt": child1.excerpt,
"id": str(child1.id), "id": str(child1.id),
@@ -44,12 +54,17 @@ def test_api_documents_children_list_anonymous_public_standalone():
"path": child1.path, "path": child1.path,
"title": child1.title, "title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [], "user_role": None,
}, },
{ {
"abilities": child2.get_abilities(AnonymousUser()), "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"), "created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id), "creator": str(child2.creator.id),
"deleted_at": None,
"depth": 2, "depth": 2,
"excerpt": child2.excerpt, "excerpt": child2.excerpt,
"id": str(child2.id), "id": str(child2.id),
@@ -62,13 +77,13 @@ def test_api_documents_children_list_anonymous_public_standalone():
"path": child2.path, "path": child2.path,
"title": child2.title, "title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), "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 Anonymous users should be allowed to retrieve the children of a document who
has a public ancestor. has a public ancestor.
@@ -83,7 +98,10 @@ def test_api_documents_children_list_anonymous_public_parent():
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1) 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.status_code == 200
assert response.json() == { assert response.json() == {
@@ -93,8 +111,13 @@ def test_api_documents_children_list_anonymous_public_parent():
"results": [ "results": [
{ {
"abilities": child1.get_abilities(AnonymousUser()), "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"), "created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id), "creator": str(child1.creator.id),
"deleted_at": None,
"depth": 4, "depth": 4,
"excerpt": child1.excerpt, "excerpt": child1.excerpt,
"id": str(child1.id), "id": str(child1.id),
@@ -107,12 +130,17 @@ def test_api_documents_children_list_anonymous_public_parent():
"path": child1.path, "path": child1.path,
"title": child1.title, "title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [], "user_role": None,
}, },
{ {
"abilities": child2.get_abilities(AnonymousUser()), "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"), "created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id), "creator": str(child2.creator.id),
"deleted_at": None,
"depth": 4, "depth": 4,
"excerpt": child2.excerpt, "excerpt": child2.excerpt,
"id": str(child2.id), "id": str(child2.id),
@@ -125,7 +153,7 @@ def test_api_documents_children_list_anonymous_public_parent():
"path": child2.path, "path": child2.path,
"title": child2.title, "title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [], "user_role": None,
}, },
], ],
} }
@@ -149,7 +177,7 @@ def test_api_documents_children_list_anonymous_restricted_or_authenticated(reach
@pytest.mark.parametrize("reach", ["public", "authenticated"]) @pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_children_list_authenticated_unrelated_public_or_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 Authenticated users should be able to retrieve the children of a public/authenticated
@@ -163,9 +191,13 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1) factories.UserDocumentAccessFactory(document=child1)
response = client.get( with django_assert_num_queries(9):
f"/api/v1.0/documents/{document.id!s}/children/", 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.status_code == 200
assert response.json() == { assert response.json() == {
"count": 2, "count": 2,
@@ -174,8 +206,13 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
"results": [ "results": [
{ {
"abilities": child1.get_abilities(user), "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"), "created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id), "creator": str(child1.creator.id),
"deleted_at": None,
"depth": 2, "depth": 2,
"excerpt": child1.excerpt, "excerpt": child1.excerpt,
"id": str(child1.id), "id": str(child1.id),
@@ -188,12 +225,17 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
"path": child1.path, "path": child1.path,
"title": child1.title, "title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [], "user_role": None,
}, },
{ {
"abilities": child2.get_abilities(user), "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"), "created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id), "creator": str(child2.creator.id),
"deleted_at": None,
"depth": 2, "depth": 2,
"excerpt": child2.excerpt, "excerpt": child2.excerpt,
"id": str(child2.id), "id": str(child2.id),
@@ -206,7 +248,7 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
"path": child2.path, "path": child2.path,
"title": child2.title, "title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [], "user_role": None,
}, },
], ],
} }
@@ -214,7 +256,7 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
@pytest.mark.parametrize("reach", ["public", "authenticated"]) @pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_children_list_authenticated_public_or_authenticated_parent( 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 Authenticated users should be allowed to retrieve the children of a document who
@@ -231,7 +273,11 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1) 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.status_code == 200
assert response.json() == { assert response.json() == {
@@ -241,8 +287,13 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
"results": [ "results": [
{ {
"abilities": child1.get_abilities(user), "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"), "created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id), "creator": str(child1.creator.id),
"deleted_at": None,
"depth": 4, "depth": 4,
"excerpt": child1.excerpt, "excerpt": child1.excerpt,
"id": str(child1.id), "id": str(child1.id),
@@ -255,12 +306,17 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
"path": child1.path, "path": child1.path,
"title": child1.title, "title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [], "user_role": None,
}, },
{ {
"abilities": child2.get_abilities(user), "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"), "created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id), "creator": str(child2.creator.id),
"deleted_at": None,
"depth": 4, "depth": 4,
"excerpt": child2.excerpt, "excerpt": child2.excerpt,
"id": str(child2.id), "id": str(child2.id),
@@ -273,13 +329,15 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
"path": child2.path, "path": child2.path,
"title": child2.title, "title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), "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 Authenticated users should not be allowed to retrieve the children of a document that is
restricted and to which they are not related. restricted and to which they are not related.
@@ -293,16 +351,20 @@ def test_api_documents_children_list_authenticated_unrelated_restricted():
child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document) child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1) factories.UserDocumentAccessFactory(document=child1)
response = client.get( with django_assert_num_queries(2):
f"/api/v1.0/documents/{document.id!s}/children/", response = client.get(
) f"/api/v1.0/documents/{document.id!s}/children/",
)
assert response.status_code == 403 assert response.status_code == 403
assert response.json() == { assert response.json() == {
"detail": "You do not have permission to perform this action." "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 Authenticated users should be allowed to retrieve the children of a document
to which they are directly related whatever the role. to which they are directly related whatever the role.
@@ -319,10 +381,13 @@ def test_api_documents_children_list_authenticated_related_direct():
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1) factories.UserDocumentAccessFactory(document=child1)
response = client.get( with django_assert_num_queries(9):
f"/api/v1.0/documents/{document.id!s}/children/", response = client.get(
) f"/api/v1.0/documents/{document.id!s}/children/",
)
assert response.status_code == 200 assert response.status_code == 200
link_role = None if document.link_reach == "restricted" else document.link_role
assert response.json() == { assert response.json() == {
"count": 2, "count": 2,
"next": None, "next": None,
@@ -330,8 +395,13 @@ def test_api_documents_children_list_authenticated_related_direct():
"results": [ "results": [
{ {
"abilities": child1.get_abilities(user), "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"), "created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id), "creator": str(child1.creator.id),
"deleted_at": None,
"depth": 2, "depth": 2,
"excerpt": child1.excerpt, "excerpt": child1.excerpt,
"id": str(child1.id), "id": str(child1.id),
@@ -344,12 +414,17 @@ def test_api_documents_children_list_authenticated_related_direct():
"path": child1.path, "path": child1.path,
"title": child1.title, "title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role], "user_role": access.role,
}, },
{ {
"abilities": child2.get_abilities(user), "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"), "created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id), "creator": str(child2.creator.id),
"deleted_at": None,
"depth": 2, "depth": 2,
"excerpt": child2.excerpt, "excerpt": child2.excerpt,
"id": str(child2.id), "id": str(child2.id),
@@ -362,13 +437,15 @@ def test_api_documents_children_list_authenticated_related_direct():
"path": child2.path, "path": child2.path,
"title": child2.title, "title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), "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 Authenticated users should be allowed to retrieve the children of a document if they
are related to one of its ancestors whatever the role. are related to one of its ancestors whatever the role.
@@ -389,9 +466,11 @@ def test_api_documents_children_list_authenticated_related_parent():
document=grand_parent, user=user document=grand_parent, user=user
) )
response = client.get( with django_assert_num_queries(10):
f"/api/v1.0/documents/{document.id!s}/children/", response = client.get(
) f"/api/v1.0/documents/{document.id!s}/children/",
)
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == { assert response.json() == {
"count": 2, "count": 2,
@@ -400,8 +479,13 @@ def test_api_documents_children_list_authenticated_related_parent():
"results": [ "results": [
{ {
"abilities": child1.get_abilities(user), "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"), "created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id), "creator": str(child1.creator.id),
"deleted_at": None,
"depth": 4, "depth": 4,
"excerpt": child1.excerpt, "excerpt": child1.excerpt,
"id": str(child1.id), "id": str(child1.id),
@@ -414,12 +498,17 @@ def test_api_documents_children_list_authenticated_related_parent():
"path": child1.path, "path": child1.path,
"title": child1.title, "title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), "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), "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"), "created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id), "creator": str(child2.creator.id),
"deleted_at": None,
"depth": 4, "depth": 4,
"excerpt": child2.excerpt, "excerpt": child2.excerpt,
"id": str(child2.id), "id": str(child2.id),
@@ -432,13 +521,15 @@ def test_api_documents_children_list_authenticated_related_parent():
"path": child2.path, "path": child2.path,
"title": child2.title, "title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), "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 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. as a result of being related to one of its children.
@@ -454,16 +545,20 @@ def test_api_documents_children_list_authenticated_related_child():
factories.UserDocumentAccessFactory(document=child1, user=user) factories.UserDocumentAccessFactory(document=child1, user=user)
factories.UserDocumentAccessFactory(document=document) factories.UserDocumentAccessFactory(document=document)
response = client.get( with django_assert_num_queries(2):
f"/api/v1.0/documents/{document.id!s}/children/", response = client.get(
) f"/api/v1.0/documents/{document.id!s}/children/",
)
assert response.status_code == 403 assert response.status_code == 403
assert response.json() == { assert response.json() == {
"detail": "You do not have permission to perform this action." "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 Authenticated users should not be able to retrieve the children of a restricted document
related to teams in which the user is not. related to teams in which the user is not.
@@ -480,7 +575,9 @@ def test_api_documents_children_list_authenticated_related_team_none(mock_user_t
factories.TeamDocumentAccessFactory(document=document, team="myteam") 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.status_code == 403
assert response.json() == { assert response.json() == {
"detail": "You do not have permission to perform this action." "detail": "You do not have permission to perform this action."
@@ -488,7 +585,7 @@ def test_api_documents_children_list_authenticated_related_team_none(mock_user_t
def test_api_documents_children_list_authenticated_related_team_members( 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 Authenticated users should be allowed to retrieve the children of a document to which they
@@ -506,7 +603,8 @@ def test_api_documents_children_list_authenticated_related_team_members(
access = factories.TeamDocumentAccessFactory(document=document, team="myteam") 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 # pylint: disable=R0801
assert response.status_code == 200 assert response.status_code == 200
@@ -517,8 +615,13 @@ def test_api_documents_children_list_authenticated_related_team_members(
"results": [ "results": [
{ {
"abilities": child1.get_abilities(user), "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"), "created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id), "creator": str(child1.creator.id),
"deleted_at": None,
"depth": 2, "depth": 2,
"excerpt": child1.excerpt, "excerpt": child1.excerpt,
"id": str(child1.id), "id": str(child1.id),
@@ -531,12 +634,17 @@ def test_api_documents_children_list_authenticated_related_team_members(
"path": child1.path, "path": child1.path,
"title": child1.title, "title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role], "user_role": access.role,
}, },
{ {
"abilities": child2.get_abilities(user), "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"), "created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id), "creator": str(child2.creator.id),
"deleted_at": None,
"depth": 2, "depth": 2,
"excerpt": child2.excerpt, "excerpt": child2.excerpt,
"id": str(child2.id), "id": str(child2.id),
@@ -549,7 +657,7 @@ def test_api_documents_children_list_authenticated_related_team_members(
"path": child2.path, "path": child2.path,
"title": child2.title, "title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role], "user_role": access.role,
}, },
], ],
} }

View File

@@ -0,0 +1,878 @@
"""Test API for comments on documents."""
import random
from django.contrib.auth.models import AnonymousUser
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
# List comments
def test_list_comments_anonymous_user_public_document():
"""Anonymous users should be allowed to list comments on a public document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.COMMENTER
)
thread = factories.ThreadFactory(document=document)
comment1, comment2 = factories.CommentFactory.create_batch(2, thread=thread)
# other comments not linked to the document
factories.CommentFactory.create_batch(2)
response = APIClient().get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/"
)
assert response.status_code == 200
assert response.json() == {
"count": 2,
"next": None,
"previous": None,
"results": [
{
"id": str(comment1.id),
"body": comment1.body,
"created_at": comment1.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": comment1.updated_at.isoformat().replace("+00:00", "Z"),
"user": {
"full_name": comment1.user.full_name,
"short_name": comment1.user.short_name,
},
"abilities": comment1.get_abilities(AnonymousUser()),
"reactions": [],
},
{
"id": str(comment2.id),
"body": comment2.body,
"created_at": comment2.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": comment2.updated_at.isoformat().replace("+00:00", "Z"),
"user": {
"full_name": comment2.user.full_name,
"short_name": comment2.user.short_name,
},
"abilities": comment2.get_abilities(AnonymousUser()),
"reactions": [],
},
],
}
@pytest.mark.parametrize("link_reach", ["restricted", "authenticated"])
def test_list_comments_anonymous_user_non_public_document(link_reach):
"""Anonymous users should not be allowed to list comments on a non-public document."""
document = factories.DocumentFactory(
link_reach=link_reach, link_role=models.LinkRoleChoices.COMMENTER
)
thread = factories.ThreadFactory(document=document)
factories.CommentFactory(thread=thread)
# other comments not linked to the document
factories.CommentFactory.create_batch(2)
response = APIClient().get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/"
)
assert response.status_code == 401
def test_list_comments_authenticated_user_accessible_document():
"""Authenticated users should be allowed to list comments on an accessible document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)]
)
thread = factories.ThreadFactory(document=document)
comment1 = factories.CommentFactory(thread=thread)
comment2 = factories.CommentFactory(thread=thread, user=user)
# other comments not linked to the document
factories.CommentFactory.create_batch(2)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/"
)
assert response.status_code == 200
assert response.json() == {
"count": 2,
"next": None,
"previous": None,
"results": [
{
"id": str(comment1.id),
"body": comment1.body,
"created_at": comment1.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": comment1.updated_at.isoformat().replace("+00:00", "Z"),
"user": {
"full_name": comment1.user.full_name,
"short_name": comment1.user.short_name,
},
"abilities": comment1.get_abilities(user),
"reactions": [],
},
{
"id": str(comment2.id),
"body": comment2.body,
"created_at": comment2.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": comment2.updated_at.isoformat().replace("+00:00", "Z"),
"user": {
"full_name": comment2.user.full_name,
"short_name": comment2.user.short_name,
},
"abilities": comment2.get_abilities(user),
"reactions": [],
},
],
}
def test_list_comments_authenticated_user_non_accessible_document():
"""Authenticated users should not be allowed to list comments on a non-accessible document."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach="restricted")
thread = factories.ThreadFactory(document=document)
factories.CommentFactory(thread=thread)
# other comments not linked to the document
factories.CommentFactory.create_batch(2)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/"
)
assert response.status_code == 403
def test_list_comments_authenticated_user_not_enough_access():
"""
Authenticated users should not be allowed to list comments on a document they don't have
comment access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)]
)
thread = factories.ThreadFactory(document=document)
factories.CommentFactory(thread=thread)
# other comments not linked to the document
factories.CommentFactory.create_batch(2)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/"
)
assert response.status_code == 403
# Create comment
def test_create_comment_anonymous_user_public_document():
"""
Anonymous users should be allowed to create comments on a public document
with commenter link_role.
"""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.COMMENTER
)
thread = factories.ThreadFactory(document=document)
client = APIClient()
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/",
{"body": "test"},
)
assert response.status_code == 201
assert response.json() == {
"id": str(response.json()["id"]),
"body": "test",
"created_at": response.json()["created_at"],
"updated_at": response.json()["updated_at"],
"user": None,
"abilities": {
"destroy": False,
"update": False,
"partial_update": False,
"reactions": False,
"retrieve": True,
},
"reactions": [],
}
def test_create_comment_anonymous_user_non_accessible_document():
"""Anonymous users should not be allowed to create comments on a non-accessible document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.READER
)
thread = factories.ThreadFactory(document=document)
client = APIClient()
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/",
{"body": "test"},
)
assert response.status_code == 401
def test_create_comment_authenticated_user_accessible_document():
"""Authenticated users should be allowed to create comments on an accessible document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)]
)
thread = factories.ThreadFactory(document=document)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/",
{"body": "test"},
)
assert response.status_code == 201
assert response.json() == {
"id": str(response.json()["id"]),
"body": "test",
"created_at": response.json()["created_at"],
"updated_at": response.json()["updated_at"],
"user": {
"full_name": user.full_name,
"short_name": user.short_name,
},
"abilities": {
"destroy": True,
"update": True,
"partial_update": True,
"reactions": True,
"retrieve": True,
},
"reactions": [],
}
def test_create_comment_authenticated_user_not_enough_access():
"""
Authenticated users should not be allowed to create comments on a document they don't have
comment access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)]
)
thread = factories.ThreadFactory(document=document)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/",
{"body": "test"},
)
assert response.status_code == 403
# Retrieve comment
def test_retrieve_comment_anonymous_user_public_document():
"""Anonymous users should be allowed to retrieve comments on a public document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.COMMENTER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
response = client.get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 200
assert response.json() == {
"id": str(comment.id),
"body": comment.body,
"created_at": comment.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": comment.updated_at.isoformat().replace("+00:00", "Z"),
"user": {
"full_name": comment.user.full_name,
"short_name": comment.user.short_name,
},
"reactions": [],
"abilities": comment.get_abilities(AnonymousUser()),
}
def test_retrieve_comment_anonymous_user_non_accessible_document():
"""Anonymous users should not be allowed to retrieve comments on a non-accessible document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.READER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
response = client.get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 401
def test_retrieve_comment_authenticated_user_accessible_document():
"""Authenticated users should be allowed to retrieve comments on an accessible document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)]
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 200
def test_retrieve_comment_authenticated_user_not_enough_access():
"""
Authenticated users should not be allowed to retrieve comments on a document they don't have
comment access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)]
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 403
# Update comment
def test_update_comment_anonymous_user_public_document():
"""Anonymous users should not be allowed to update comments on a public document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.COMMENTER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, body="test")
client = APIClient()
response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
{"body": "other content"},
)
assert response.status_code == 401
def test_update_comment_anonymous_user_non_accessible_document():
"""Anonymous users should not be allowed to update comments on a non-accessible document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.READER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, body="test")
client = APIClient()
response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
{"body": "other content"},
)
assert response.status_code == 401
def test_update_comment_authenticated_user_accessible_document():
"""Authenticated users should not be able to update comments not their own."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted",
users=[
(
user,
random.choice(
[models.LinkRoleChoices.COMMENTER, models.LinkRoleChoices.EDITOR]
),
)
],
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, body="test")
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
{"body": "other content"},
)
assert response.status_code == 403
def test_update_comment_authenticated_user_own_comment():
"""Authenticated users should be able to update comments not their own."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted",
users=[
(
user,
random.choice(
[models.LinkRoleChoices.COMMENTER, models.LinkRoleChoices.EDITOR]
),
)
],
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, body="test", user=user)
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
{"body": "other content"},
)
assert response.status_code == 200
comment.refresh_from_db()
assert comment.body == "other content"
def test_update_comment_authenticated_user_not_enough_access():
"""
Authenticated users should not be allowed to update comments on a document they don't
have comment access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)]
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, body="test")
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
{"body": "other content"},
)
assert response.status_code == 403
def test_update_comment_authenticated_no_access():
"""
Authenticated users should not be allowed to update comments on a document they don't
have access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach="restricted")
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, body="test")
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
{"body": "other content"},
)
assert response.status_code == 403
@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER])
def test_update_comment_authenticated_admin_or_owner_can_update_any_comment(role):
"""
Authenticated users should be able to update comments on a document they don't have access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, role)])
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, body="test")
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
{"body": "other content"},
)
assert response.status_code == 200
comment.refresh_from_db()
assert comment.body == "other content"
@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER])
def test_update_comment_authenticated_admin_or_owner_can_update_own_comment(role):
"""
Authenticated users should be able to update comments on a document they don't have access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, role)])
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, body="test", user=user)
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/",
{"body": "other content"},
)
assert response.status_code == 200
comment.refresh_from_db()
assert comment.body == "other content"
# Delete comment
def test_delete_comment_anonymous_user_public_document():
"""Anonymous users should not be allowed to delete comments on a public document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.COMMENTER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 401
def test_delete_comment_anonymous_user_non_accessible_document():
"""Anonymous users should not be allowed to delete comments on a non-accessible document."""
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.READER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 401
def test_delete_comment_authenticated_user_accessible_document_own_comment():
"""Authenticated users should be able to delete comments on an accessible document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)]
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, user=user)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 204
def test_delete_comment_authenticated_user_accessible_document_not_own_comment():
"""Authenticated users should not be able to delete comments on an accessible document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)]
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 403
@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER])
def test_delete_comment_authenticated_user_admin_or_owner_can_delete_any_comment(role):
"""Authenticated users should be able to delete comments on a document they have access to."""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, role)])
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 204
@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER])
def test_delete_comment_authenticated_user_admin_or_owner_can_delete_own_comment(role):
"""Authenticated users should be able to delete comments on a document they have access to."""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, role)])
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread, user=user)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 204
def test_delete_comment_authenticated_user_not_enough_access():
"""
Authenticated users should not be able to delete comments on a document they don't
have access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)]
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/"
)
assert response.status_code == 403
# Create reaction
@pytest.mark.parametrize("link_role", models.LinkRoleChoices.values)
def test_create_reaction_anonymous_user_public_document(link_role):
"""No matter the link_role, an anonymous user can not react to a comment."""
document = factories.DocumentFactory(link_reach="public", link_role=link_role)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 401
def test_create_reaction_authenticated_user_public_document():
"""
Authenticated users should not be able to reaction to a comment on a public document with
link_role reader.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.READER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 403
def test_create_reaction_authenticated_user_accessible_public_document():
"""
Authenticated users should be able to react to a comment on a public document.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="public", link_role=models.LinkRoleChoices.COMMENTER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 201
assert models.Reaction.objects.filter(
comment=comment, emoji="test", users__in=[user]
).exists()
def test_create_reaction_authenticated_user_connected_document_link_role_reader():
"""
Authenticated users should not be able to react to a comment on a connected document
with link_role reader.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="authenticated", link_role=models.LinkRoleChoices.READER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 403
@pytest.mark.parametrize(
"link_role",
[
role
for role in models.LinkRoleChoices.values
if role != models.LinkRoleChoices.READER
],
)
def test_create_reaction_authenticated_user_connected_document(link_role):
"""
Authenticated users should be able to react to a comment on a connected document.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="authenticated", link_role=link_role
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 201
assert models.Reaction.objects.filter(
comment=comment, emoji="test", users__in=[user]
).exists()
def test_create_reaction_authenticated_user_restricted_accessible_document():
"""
Authenticated users should not be able to react to a comment on a restricted accessible document
they don't have access to.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach="restricted")
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 403
def test_create_reaction_authenticated_user_restricted_accessible_document_role_reader():
"""
Authenticated users should not be able to react to a comment on a restricted accessible
document with role reader.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", link_role=models.LinkRoleChoices.READER
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 403
@pytest.mark.parametrize(
"role",
[role for role in models.RoleChoices.values if role != models.RoleChoices.READER],
)
def test_create_reaction_authenticated_user_restricted_accessible_document_role_commenter(
role,
):
"""
Authenticated users should be able to react to a comment on a restricted accessible document
with role commenter.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach="restricted", users=[(user, role)])
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 201
assert models.Reaction.objects.filter(
comment=comment, emoji="test", users__in=[user]
).exists()
response = client.post(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": "test"},
)
assert response.status_code == 400
assert response.json() == {"user_already_reacted": True}
# Delete reaction
def test_delete_reaction_not_owned_by_the_current_user():
"""
Users should not be able to delete reactions not owned by the current user.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.RoleChoices.ADMIN)]
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
reaction = factories.ReactionFactory(comment=comment)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": reaction.emoji},
)
assert response.status_code == 404
def test_delete_reaction_owned_by_the_current_user():
"""
Users should not be able to delete reactions not owned by the current user.
"""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_reach="restricted", users=[(user, models.RoleChoices.ADMIN)]
)
thread = factories.ThreadFactory(document=document)
comment = factories.CommentFactory(thread=thread)
reaction = factories.ReactionFactory(comment=comment)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/"
f"comments/{comment.id!s}/reactions/",
{"emoji": reaction.emoji},
)
assert response.status_code == 404
reaction.refresh_from_db()
assert reaction.users.exists()

View File

@@ -0,0 +1,176 @@
"""
Tests for Documents API endpoint in impress's core app: content
"""
import base64
from unittest.mock import patch
import pytest
import requests
from rest_framework import status
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize(
"reach, role",
[
("public", "reader"),
("public", "editor"),
],
)
@patch("core.services.converter_services.YdocConverter.convert")
def test_api_documents_content_public(mock_content, reach, role):
"""Anonymous users should be allowed to access content of public documents."""
document = factories.DocumentFactory(link_reach=reach, link_role=role)
mock_content.return_value = {"some": "data"}
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/content/")
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["id"] == str(document.id)
assert data["title"] == document.title
assert data["content"] == {"some": "data"}
mock_content.assert_called_once_with(
base64.b64decode(document.content),
"application/vnd.yjs.doc",
"application/json",
)
@pytest.mark.parametrize(
"reach, doc_role, user_role",
[
("restricted", "reader", "reader"),
("restricted", "reader", "editor"),
("restricted", "reader", "administrator"),
("restricted", "reader", "owner"),
("restricted", "editor", "reader"),
("restricted", "editor", "editor"),
("restricted", "editor", "administrator"),
("restricted", "editor", "owner"),
("authenticated", "reader", None),
("authenticated", "editor", None),
],
)
@patch("core.services.converter_services.YdocConverter.convert")
def test_api_documents_content_not_public(mock_content, reach, doc_role, user_role):
"""Authenticated users need access to get non-public document content."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach=reach, link_role=doc_role)
mock_content.return_value = {"some": "data"}
# First anonymous request should fail
client = APIClient()
response = client.get(f"/api/v1.0/documents/{document.id!s}/content/")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
mock_content.assert_not_called()
# Login and try again
client.force_login(user)
response = client.get(f"/api/v1.0/documents/{document.id!s}/content/")
# If restricted, we still should not have access
if user_role is not None:
assert response.status_code == status.HTTP_403_FORBIDDEN
mock_content.assert_not_called()
# Create an access as a reader. This should unlock the access.
factories.UserDocumentAccessFactory(
document=document, user=user, role=user_role
)
response = client.get(f"/api/v1.0/documents/{document.id!s}/content/")
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["id"] == str(document.id)
assert data["title"] == document.title
assert data["content"] == {"some": "data"}
mock_content.assert_called_once_with(
base64.b64decode(document.content),
"application/vnd.yjs.doc",
"application/json",
)
@pytest.mark.parametrize(
"content_format, accept",
[
("markdown", "text/markdown"),
("html", "text/html"),
("json", "application/json"),
],
)
@patch("core.services.converter_services.YdocConverter.convert")
def test_api_documents_content_format(mock_content, content_format, accept):
"""Test that the content endpoint returns a specific format."""
document = factories.DocumentFactory(link_reach="public")
mock_content.return_value = {"some": "data"}
response = APIClient().get(
f"/api/v1.0/documents/{document.id!s}/content/?content_format={content_format}"
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["id"] == str(document.id)
assert data["title"] == document.title
assert data["content"] == {"some": "data"}
mock_content.assert_called_once_with(
base64.b64decode(document.content), "application/vnd.yjs.doc", accept
)
@patch("core.services.converter_services.YdocConverter._request")
def test_api_documents_content_invalid_format(mock_request):
"""Test that the content endpoint rejects invalid formats."""
document = factories.DocumentFactory(link_reach="public")
response = APIClient().get(
f"/api/v1.0/documents/{document.id!s}/content/?content_format=invalid"
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
mock_request.assert_not_called()
@patch("core.services.converter_services.YdocConverter._request")
def test_api_documents_content_yservice_error(mock_request):
"""Test that service errors are handled properly."""
document = factories.DocumentFactory(link_reach="public")
mock_request.side_effect = requests.RequestException()
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/content/")
mock_request.assert_called_once()
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
@patch("core.services.converter_services.YdocConverter._request")
def test_api_documents_content_nonexistent_document(mock_request):
"""Test that accessing a nonexistent document returns 404."""
client = APIClient()
response = client.get(
"/api/v1.0/documents/00000000-0000-0000-0000-000000000000/content/"
)
assert response.status_code == status.HTTP_404_NOT_FOUND
mock_request.assert_not_called()
@patch("core.services.converter_services.YdocConverter._request")
def test_api_documents_content_empty_document(mock_request):
"""Test that accessing an empty document returns empty content."""
document = factories.DocumentFactory(link_reach="public", content="")
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/content/")
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["id"] == str(document.id)
assert data["title"] == document.title
assert data["content"] is None
mock_request.assert_not_called()

View File

@@ -2,6 +2,7 @@
import pytest import pytest
import responses import responses
from requests.exceptions import RequestException
from rest_framework.test import APIClient from rest_framework.test import APIClient
from core import factories from core import factories
@@ -149,3 +150,41 @@ def test_api_docs_cors_proxy_unsupported_media_type():
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}" f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
) )
assert response.status_code == 415 assert response.status_code == 415
@pytest.mark.parametrize(
"url_to_fetch",
[
"ftp://external-url.com/assets/index.html",
"ftps://external-url.com/assets/index.html",
"invalid-url.com",
"ssh://external-url.com/assets/index.html",
],
)
def test_api_docs_cors_proxy_invalid_url(url_to_fetch):
"""Test the CORS proxy API for documents with an invalid URL."""
document = factories.DocumentFactory(link_reach="public")
client = APIClient()
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json() == ["Enter a valid URL."]
@responses.activate
def test_api_docs_cors_proxy_request_failed():
"""Test the CORS proxy API for documents with a request failed."""
document = factories.DocumentFactory(link_reach="public")
client = APIClient()
url_to_fetch = "https://external-url.com/assets/index.html"
responses.get(url_to_fetch, body=RequestException("Connection refused"))
response = client.get(
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
)
assert response.status_code == 400
assert response.json() == {
"error": "Failed to fetch resource from https://external-url.com/assets/index.html"
}

View File

@@ -23,10 +23,10 @@ pytestmark = pytest.mark.django_db
@pytest.fixture @pytest.fixture
def mock_convert_md(): def mock_convert_md():
"""Mock YdocConverter.convert_markdown to return a converted content.""" """Mock YdocConverter.convert to return a converted content."""
with patch.object( with patch.object(
YdocConverter, YdocConverter,
"convert_markdown", "convert",
return_value="Converted document content", return_value="Converted document content",
) as mock: ) as mock:
yield mock yield mock
@@ -148,7 +148,7 @@ def test_api_documents_create_for_owner_invalid_sub():
data = { data = {
"title": "My Document", "title": "My Document",
"content": "Document content", "content": "Document content",
"sub": "123!!", "sub": "invalid süb",
"email": "john.doe@example.com", "email": "john.doe@example.com",
} }
@@ -163,10 +163,7 @@ def test_api_documents_create_for_owner_invalid_sub():
assert not Document.objects.exists() assert not Document.objects.exists()
assert response.json() == { assert response.json() == {
"sub": [ "sub": ["Enter a valid sub. This value should be ASCII only."]
"Enter a valid sub. This value may contain only letters, "
"numbers, and @/./+/-/_/: characters."
]
} }

View File

@@ -32,8 +32,13 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
"results": [ "results": [
{ {
"abilities": child1.get_abilities(AnonymousUser()), "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"), "created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id), "creator": str(child1.creator.id),
"deleted_at": None,
"depth": 2, "depth": 2,
"excerpt": child1.excerpt, "excerpt": child1.excerpt,
"id": str(child1.id), "id": str(child1.id),
@@ -46,12 +51,19 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
"path": child1.path, "path": child1.path,
"title": child1.title, "title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [], "user_role": None,
}, },
{ {
"abilities": grand_child.get_abilities(AnonymousUser()), "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"), "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id), "creator": str(grand_child.creator.id),
"deleted_at": None,
"depth": 3, "depth": 3,
"excerpt": grand_child.excerpt, "excerpt": grand_child.excerpt,
"id": str(grand_child.id), "id": str(grand_child.id),
@@ -64,12 +76,17 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
"path": grand_child.path, "path": grand_child.path,
"title": grand_child.title, "title": grand_child.title,
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [], "user_role": None,
}, },
{ {
"abilities": child2.get_abilities(AnonymousUser()), "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"), "created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id), "creator": str(child2.creator.id),
"deleted_at": None,
"depth": 2, "depth": 2,
"excerpt": child2.excerpt, "excerpt": child2.excerpt,
"id": str(child2.id), "id": str(child2.id),
@@ -82,7 +99,7 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
"path": child2.path, "path": child2.path,
"title": child2.title, "title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [], "user_role": None,
}, },
], ],
} }
@@ -115,8 +132,13 @@ def test_api_documents_descendants_list_anonymous_public_parent():
"results": [ "results": [
{ {
"abilities": child1.get_abilities(AnonymousUser()), "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"), "created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id), "creator": str(child1.creator.id),
"deleted_at": None,
"depth": 4, "depth": 4,
"excerpt": child1.excerpt, "excerpt": child1.excerpt,
"id": str(child1.id), "id": str(child1.id),
@@ -129,12 +151,17 @@ def test_api_documents_descendants_list_anonymous_public_parent():
"path": child1.path, "path": child1.path,
"title": child1.title, "title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [], "user_role": None,
}, },
{ {
"abilities": grand_child.get_abilities(AnonymousUser()), "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"), "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id), "creator": str(grand_child.creator.id),
"deleted_at": None,
"depth": 5, "depth": 5,
"excerpt": grand_child.excerpt, "excerpt": grand_child.excerpt,
"id": str(grand_child.id), "id": str(grand_child.id),
@@ -147,12 +174,17 @@ def test_api_documents_descendants_list_anonymous_public_parent():
"path": grand_child.path, "path": grand_child.path,
"title": grand_child.title, "title": grand_child.title,
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [], "user_role": None,
}, },
{ {
"abilities": child2.get_abilities(AnonymousUser()), "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"), "created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id), "creator": str(child2.creator.id),
"deleted_at": None,
"depth": 4, "depth": 4,
"excerpt": child2.excerpt, "excerpt": child2.excerpt,
"id": str(child2.id), "id": str(child2.id),
@@ -165,7 +197,7 @@ def test_api_documents_descendants_list_anonymous_public_parent():
"path": child2.path, "path": child2.path,
"title": child2.title, "title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [], "user_role": None,
}, },
], ],
} }
@@ -201,7 +233,9 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
client.force_login(user) client.force_login(user)
document = factories.DocumentFactory(link_reach=reach) 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) grand_child = factories.DocumentFactory(parent=child1)
factories.UserDocumentAccessFactory(document=child1) factories.UserDocumentAccessFactory(document=child1)
@@ -217,8 +251,13 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
"results": [ "results": [
{ {
"abilities": child1.get_abilities(user), "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"), "created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id), "creator": str(child1.creator.id),
"deleted_at": None,
"depth": 2, "depth": 2,
"excerpt": child1.excerpt, "excerpt": child1.excerpt,
"id": str(child1.id), "id": str(child1.id),
@@ -231,12 +270,17 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
"path": child1.path, "path": child1.path,
"title": child1.title, "title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [], "user_role": None,
}, },
{ {
"abilities": grand_child.get_abilities(user), "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"), "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id), "creator": str(grand_child.creator.id),
"deleted_at": None,
"depth": 3, "depth": 3,
"excerpt": grand_child.excerpt, "excerpt": grand_child.excerpt,
"id": str(grand_child.id), "id": str(grand_child.id),
@@ -249,12 +293,17 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
"path": grand_child.path, "path": grand_child.path,
"title": grand_child.title, "title": grand_child.title,
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [], "user_role": None,
}, },
{ {
"abilities": child2.get_abilities(user), "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"), "created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id), "creator": str(child2.creator.id),
"deleted_at": None,
"depth": 2, "depth": 2,
"excerpt": child2.excerpt, "excerpt": child2.excerpt,
"id": str(child2.id), "id": str(child2.id),
@@ -267,7 +316,7 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
"path": child2.path, "path": child2.path,
"title": child2.title, "title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [], "user_role": None,
}, },
], ],
} }
@@ -289,7 +338,9 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
grand_parent = factories.DocumentFactory(link_reach=reach) grand_parent = factories.DocumentFactory(link_reach=reach)
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted") parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
document = factories.DocumentFactory(link_reach="restricted", parent=parent) 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) grand_child = factories.DocumentFactory(parent=child1)
factories.UserDocumentAccessFactory(document=child1) factories.UserDocumentAccessFactory(document=child1)
@@ -304,8 +355,13 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
"results": [ "results": [
{ {
"abilities": child1.get_abilities(user), "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"), "created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id), "creator": str(child1.creator.id),
"deleted_at": None,
"depth": 4, "depth": 4,
"excerpt": child1.excerpt, "excerpt": child1.excerpt,
"id": str(child1.id), "id": str(child1.id),
@@ -318,12 +374,17 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
"path": child1.path, "path": child1.path,
"title": child1.title, "title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [], "user_role": None,
}, },
{ {
"abilities": grand_child.get_abilities(user), "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"), "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id), "creator": str(grand_child.creator.id),
"deleted_at": None,
"depth": 5, "depth": 5,
"excerpt": grand_child.excerpt, "excerpt": grand_child.excerpt,
"id": str(grand_child.id), "id": str(grand_child.id),
@@ -336,12 +397,17 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
"path": grand_child.path, "path": grand_child.path,
"title": grand_child.title, "title": grand_child.title,
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [], "user_role": None,
}, },
{ {
"abilities": child2.get_abilities(user), "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"), "created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id), "creator": str(child2.creator.id),
"deleted_at": None,
"depth": 4, "depth": 4,
"excerpt": child2.excerpt, "excerpt": child2.excerpt,
"id": str(child2.id), "id": str(child2.id),
@@ -354,7 +420,7 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
"path": child2.path, "path": child2.path,
"title": child2.title, "title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [], "user_role": None,
}, },
], ],
} }
@@ -414,8 +480,13 @@ def test_api_documents_descendants_list_authenticated_related_direct():
"results": [ "results": [
{ {
"abilities": child1.get_abilities(user), "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"), "created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id), "creator": str(child1.creator.id),
"deleted_at": None,
"depth": 2, "depth": 2,
"excerpt": child1.excerpt, "excerpt": child1.excerpt,
"id": str(child1.id), "id": str(child1.id),
@@ -428,12 +499,17 @@ def test_api_documents_descendants_list_authenticated_related_direct():
"path": child1.path, "path": child1.path,
"title": child1.title, "title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role], "user_role": access.role,
}, },
{ {
"abilities": grand_child.get_abilities(user), "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"), "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id), "creator": str(grand_child.creator.id),
"deleted_at": None,
"depth": 3, "depth": 3,
"excerpt": grand_child.excerpt, "excerpt": grand_child.excerpt,
"id": str(grand_child.id), "id": str(grand_child.id),
@@ -446,12 +522,17 @@ def test_api_documents_descendants_list_authenticated_related_direct():
"path": grand_child.path, "path": grand_child.path,
"title": grand_child.title, "title": grand_child.title,
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role], "user_role": access.role,
}, },
{ {
"abilities": child2.get_abilities(user), "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"), "created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id), "creator": str(child2.creator.id),
"deleted_at": None,
"depth": 2, "depth": 2,
"excerpt": child2.excerpt, "excerpt": child2.excerpt,
"id": str(child2.id), "id": str(child2.id),
@@ -464,7 +545,7 @@ def test_api_documents_descendants_list_authenticated_related_direct():
"path": child2.path, "path": child2.path,
"title": child2.title, "title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role], "user_role": access.role,
}, },
], ],
} }
@@ -504,8 +585,13 @@ def test_api_documents_descendants_list_authenticated_related_parent():
"results": [ "results": [
{ {
"abilities": child1.get_abilities(user), "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"), "created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id), "creator": str(child1.creator.id),
"deleted_at": None,
"depth": 4, "depth": 4,
"excerpt": child1.excerpt, "excerpt": child1.excerpt,
"id": str(child1.id), "id": str(child1.id),
@@ -518,12 +604,17 @@ def test_api_documents_descendants_list_authenticated_related_parent():
"path": child1.path, "path": child1.path,
"title": child1.title, "title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), "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), "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"), "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id), "creator": str(grand_child.creator.id),
"deleted_at": None,
"depth": 5, "depth": 5,
"excerpt": grand_child.excerpt, "excerpt": grand_child.excerpt,
"id": str(grand_child.id), "id": str(grand_child.id),
@@ -536,12 +627,17 @@ def test_api_documents_descendants_list_authenticated_related_parent():
"path": grand_child.path, "path": grand_child.path,
"title": grand_child.title, "title": grand_child.title,
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), "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), "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"), "created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id), "creator": str(child2.creator.id),
"deleted_at": None,
"depth": 4, "depth": 4,
"excerpt": child2.excerpt, "excerpt": child2.excerpt,
"id": str(child2.id), "id": str(child2.id),
@@ -554,7 +650,7 @@ def test_api_documents_descendants_list_authenticated_related_parent():
"path": child2.path, "path": child2.path,
"title": child2.title, "title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [grand_parent_access.role], "user_role": grand_parent_access.role,
}, },
], ],
} }
@@ -640,8 +736,13 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
"results": [ "results": [
{ {
"abilities": child1.get_abilities(user), "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"), "created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id), "creator": str(child1.creator.id),
"deleted_at": None,
"depth": 2, "depth": 2,
"excerpt": child1.excerpt, "excerpt": child1.excerpt,
"id": str(child1.id), "id": str(child1.id),
@@ -654,12 +755,17 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
"path": child1.path, "path": child1.path,
"title": child1.title, "title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role], "user_role": access.role,
}, },
{ {
"abilities": grand_child.get_abilities(user), "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"), "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id), "creator": str(grand_child.creator.id),
"deleted_at": None,
"depth": 3, "depth": 3,
"excerpt": grand_child.excerpt, "excerpt": grand_child.excerpt,
"id": str(grand_child.id), "id": str(grand_child.id),
@@ -672,12 +778,17 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
"path": grand_child.path, "path": grand_child.path,
"title": grand_child.title, "title": grand_child.title,
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role], "user_role": access.role,
}, },
{ {
"abilities": child2.get_abilities(user), "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"), "created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id), "creator": str(child2.creator.id),
"deleted_at": None,
"depth": 2, "depth": 2,
"excerpt": child2.excerpt, "excerpt": child2.excerpt,
"id": str(child2.id), "id": str(child2.id),
@@ -690,7 +801,7 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
"path": child2.path, "path": child2.path,
"title": child2.title, "title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role], "user_role": access.role,
}, },
], ],
} }

View File

@@ -14,6 +14,7 @@ from django.utils import timezone
import pycrdt import pycrdt
import pytest import pytest
import requests import requests
from freezegun import freeze_time
from rest_framework.test import APIClient from rest_framework.test import APIClient
from core import factories, models from core import factories, models
@@ -60,7 +61,7 @@ def test_api_documents_duplicate_forbidden():
def test_api_documents_duplicate_anonymous(): def test_api_documents_duplicate_anonymous():
"""Anonymous users should not be able to duplicate documents even with read access.""" """Anonymous users should not be able to duplicate documents even with read access."""
document = factories.DocumentFactory(link_reach="public") document = factories.DocumentFactory(link_reach="public", link_role="reader")
response = APIClient().post(f"/api/v1.0/documents/{document.id!s}/duplicate/") response = APIClient().post(f"/api/v1.0/documents/{document.id!s}/duplicate/")
@@ -133,19 +134,21 @@ def test_api_documents_duplicate_success(index):
# Ensure access persists after the owner loses access to the original document # Ensure access persists after the owner loses access to the original document
models.DocumentAccess.objects.filter(document=document).delete() 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.status_code == 200
assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ")
authorization = response["Authorization"] authorization = response["Authorization"]
assert "AWS4-HMAC-SHA256 Credential=" in authorization assert "AWS4-HMAC-SHA256 Credential=" in authorization
assert ( assert (
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=" "SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization in authorization
) )
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL) s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
response = requests.get( response = requests.get(
@@ -168,14 +171,17 @@ def test_api_documents_duplicate_success(index):
assert response.status_code == 403 assert response.status_code == 403
def test_api_documents_duplicate_with_accesses(): @pytest.mark.parametrize("role", ["owner", "administrator"])
"""Accesses should be duplicated if the user requests it specifically.""" def test_api_documents_duplicate_with_accesses_admin(role):
"""
Accesses should be duplicated if the user requests it specifically and is owner or admin.
"""
user = factories.UserFactory() user = factories.UserFactory()
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
document = factories.DocumentFactory( document = factories.DocumentFactory(
users=[user], users=[(user, role)],
title="document with accesses", title="document with accesses",
) )
user_access = factories.UserDocumentAccessFactory(document=document) user_access = factories.UserDocumentAccessFactory(document=document)
@@ -205,3 +211,110 @@ def test_api_documents_duplicate_with_accesses():
assert duplicated_accesses.get(user=user).role == "owner" assert duplicated_accesses.get(user=user).role == "owner"
assert duplicated_accesses.get(user=user_access.user).role == user_access.role assert duplicated_accesses.get(user=user_access.user).role == user_access.role
assert duplicated_accesses.get(team=team_access.team).role == team_access.role assert duplicated_accesses.get(team=team_access.team).role == team_access.role
@pytest.mark.parametrize("role", ["editor", "reader"])
def test_api_documents_duplicate_with_accesses_non_admin(role):
"""
Accesses should not be duplicated if the user requests it specifically and is not owner
or admin.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(
users=[(user, role)],
title="document with accesses",
)
factories.UserDocumentAccessFactory(document=document)
factories.TeamDocumentAccessFactory(document=document)
# Duplicate the document via the API endpoint requesting to duplicate accesses
response = client.post(
f"/api/v1.0/documents/{document.id!s}/duplicate/",
{"with_accesses": True},
format="json",
)
assert response.status_code == 201
duplicated_document = models.Document.objects.get(id=response.json()["id"])
assert duplicated_document.title == "Copy of document with accesses"
assert duplicated_document.content == document.content
assert duplicated_document.link_reach == document.link_reach
assert duplicated_document.link_role == document.link_role
assert duplicated_document.creator == user
assert duplicated_document.duplicated_from == document
assert duplicated_document.attachments == []
# Check that accesses were duplicated and the user who did the duplicate is forced as owner
duplicated_accesses = duplicated_document.accesses
assert duplicated_accesses.count() == 1
assert duplicated_accesses.get(user=user).role == "owner"
@pytest.mark.parametrize("role", ["editor", "reader"])
def test_api_documents_duplicate_non_root_document(role):
"""
Non-root documents can be duplicated but without accesses.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "owner")])
child = factories.DocumentFactory(
parent=document, users=[(user, role)], title="document with accesses"
)
assert child.accesses.count() == 1
# Duplicate the document via the API endpoint requesting to duplicate accesses
response = client.post(
f"/api/v1.0/documents/{child.id!s}/duplicate/",
{"with_accesses": True},
format="json",
)
assert response.status_code == 201
duplicated_document = models.Document.objects.get(id=response.json()["id"])
assert duplicated_document.title == "Copy of document with accesses"
assert duplicated_document.content == child.content
assert duplicated_document.link_reach == child.link_reach
assert duplicated_document.link_role == child.link_role
assert duplicated_document.creator == user
assert duplicated_document.duplicated_from == child
assert duplicated_document.attachments == []
# No access should be created for non root documents
duplicated_accesses = duplicated_document.accesses
assert duplicated_accesses.count() == 0
assert duplicated_document.is_sibling_of(child)
assert duplicated_document.is_child_of(document)
def test_api_documents_duplicate_reader_non_root_document():
"""
Reader users should be able to duplicate non-root documents but will be
created as a root document.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "reader")])
child = factories.DocumentFactory(parent=document)
assert child.get_role(user) == "reader"
response = client.post(
f"/api/v1.0/documents/{child.id!s}/duplicate/", format="json"
)
assert response.status_code == 201
duplicated_document = models.Document.objects.get(id=response.json()["id"])
assert duplicated_document.is_root()
assert duplicated_document.accesses.count() == 1
assert duplicated_document.accesses.get(user=user).role == "owner"

View File

@@ -45,7 +45,10 @@ def test_api_document_favorite_anonymous_user(method, reach):
], ],
) )
def test_api_document_favorite_authenticated_post_allowed(reach, has_role): def test_api_document_favorite_authenticated_post_allowed(reach, has_role):
"""Authenticated users should be able to mark a document as favorite using POST.""" """
Authenticated users should be able to mark a document to which they have access
as favorite using POST.
"""
user = factories.UserFactory() user = factories.UserFactory()
document = factories.DocumentFactory(link_reach=reach) document = factories.DocumentFactory(link_reach=reach)
client = APIClient() client = APIClient()
@@ -69,7 +72,10 @@ def test_api_document_favorite_authenticated_post_allowed(reach, has_role):
def test_api_document_favorite_authenticated_post_forbidden(): def test_api_document_favorite_authenticated_post_forbidden():
"""Authenticated users should be able to mark a document as favorite using POST.""" """
Authenticated users should not be allowed to mark a document to which they don't
have access as favorite using POST.
"""
user = factories.UserFactory() user = factories.UserFactory()
document = factories.DocumentFactory(link_reach="restricted") document = factories.DocumentFactory(link_reach="restricted")
client = APIClient() client = APIClient()

View File

@@ -41,8 +41,8 @@ def test_api_document_favorite_list_authenticated_with_favorite():
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
# User don't have access to this document, let say it had access and this access has been # If the user doesn't have access to this document (e.g the user had access
# removed. It should not be in the favorite list anymore. # and this access was removed), it should not be in the favorite list anymore.
factories.DocumentFactory(favorited_by=[user]) factories.DocumentFactory(favorited_by=[user])
document = factories.UserDocumentAccessFactory( document = factories.UserDocumentAccessFactory(
@@ -59,8 +59,13 @@ def test_api_document_favorite_list_authenticated_with_favorite():
"results": [ "results": [
{ {
"abilities": document.get_abilities(user), "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"), "created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id), "creator": str(document.creator.id),
"deleted_at": None,
"content": document.content, "content": document.content,
"depth": document.depth, "depth": document.depth,
"excerpt": document.excerpt, "excerpt": document.excerpt,
@@ -74,7 +79,7 @@ def test_api_document_favorite_list_authenticated_with_favorite():
"path": document.path, "path": document.path,
"title": document.title, "title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": ["reader"], "user_role": "reader",
} }
], ],
} }

View File

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

View File

@@ -63,8 +63,13 @@ def test_api_documents_list_format():
assert results[0] == { assert results[0] == {
"id": str(document.id), "id": str(document.id),
"abilities": document.get_abilities(user), "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"), "created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id), "creator": str(document.creator.id),
"deleted_at": None,
"depth": 1, "depth": 1,
"excerpt": document.excerpt, "excerpt": document.excerpt,
"is_favorite": True, "is_favorite": True,
@@ -76,7 +81,7 @@ def test_api_documents_list_format():
"path": document.path, "path": document.path,
"title": document.title, "title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role], "user_role": access.role,
} }
@@ -148,11 +153,11 @@ def test_api_documents_list_authenticated_direct(django_assert_num_queries):
str(child4_with_access.id), str(child4_with_access.id),
} }
with django_assert_num_queries(12): with django_assert_num_queries(14):
response = client.get("/api/v1.0/documents/") response = client.get("/api/v1.0/documents/")
# nb_accesses should now be cached # 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/") response = client.get("/api/v1.0/documents/")
assert response.status_code == 200 assert response.status_code == 200
@@ -268,11 +273,11 @@ def test_api_documents_list_authenticated_link_reach_public_or_authenticated(
expected_ids = {str(document1.id), str(document2.id), str(visible_child.id)} 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/") response = client.get("/api/v1.0/documents/")
# nb_accesses should now be cached # 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/") response = client.get("/api/v1.0/documents/")
assert response.status_code == 200 assert response.status_code == 200
@@ -423,3 +428,20 @@ def test_api_documents_list_favorites_no_extra_queries(django_assert_num_queries
assert result["is_favorite"] is True assert result["is_favorite"] is True
else: else:
assert result["is_favorite"] is False assert result["is_favorite"] is False
def test_api_documents_list_throttling(settings):
"""Test api documents throttling."""
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"]
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"] = "2/minute"
client = APIClient()
for _i in range(2):
response = client.get("/api/v1.0/documents/")
assert response.status_code == 200
with mock.patch("core.api.throttling.capture_message") as mock_capture_message:
response = client.get("/api/v1.0/documents/")
assert response.status_code == 429
mock_capture_message.assert_called_once_with(
"Rate limit exceeded for scope document", "warning"
)
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"] = current_rate

View File

@@ -312,6 +312,84 @@ def test_api_documents_list_filter_is_favorite_invalid():
assert len(results) == 5 assert len(results) == 5
# Filters: is_masked
def test_api_documents_list_filter_is_masked_true():
"""
Authenticated users should be able to filter documents they marked as masked.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(2, users=[user])
masked_documents = factories.DocumentFactory.create_batch(
3, users=[user], masked_by=[user]
)
unmasked_documents = factories.DocumentFactory.create_batch(2, users=[user])
for document in unmasked_documents:
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
response = client.get("/api/v1.0/documents/?is_masked=true")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 3
# Ensure all results are marked as masked by the current user
masked_documents_ids = [str(doc.id) for doc in masked_documents]
for result in results:
assert result["id"] in masked_documents_ids
def test_api_documents_list_filter_is_masked_false():
"""
Authenticated users should be able to filter documents they didn't mark as masked.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(2, users=[user])
masked_documents = factories.DocumentFactory.create_batch(
3, users=[user], masked_by=[user]
)
unmasked_documents = factories.DocumentFactory.create_batch(2, users=[user])
for document in unmasked_documents:
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
response = client.get("/api/v1.0/documents/?is_masked=false")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 4
# Ensure all results are not marked as masked by the current user
masked_documents_ids = [str(doc.id) for doc in masked_documents]
for result in results:
assert result["id"] not in masked_documents_ids
def test_api_documents_list_filter_is_masked_invalid():
"""Filtering with an invalid `is_masked` value should do nothing."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(2, users=[user])
factories.DocumentFactory.create_batch(3, users=[user], masked_by=[user])
unmasked_documents = factories.DocumentFactory.create_batch(2, users=[user])
for document in unmasked_documents:
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
response = client.get("/api/v1.0/documents/?is_masked=invalid")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 7
# Filters: title # Filters: title

View File

@@ -0,0 +1,353 @@
"""Test mask document API endpoint for users in impress's core app."""
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize(
"reach",
[
"restricted",
"authenticated",
"public",
],
)
@pytest.mark.parametrize("method", ["post", "delete"])
def test_api_document_mask_anonymous_user(method, reach):
"""Anonymous users should not be able to mask/unmask documents."""
document = factories.DocumentFactory(link_reach=reach)
response = getattr(APIClient(), method)(
f"/api/v1.0/documents/{document.id!s}/mask/"
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
# Verify in database
assert models.LinkTrace.objects.exists() is False
@pytest.mark.parametrize(
"reach, has_role",
[
["restricted", True],
["authenticated", False],
["authenticated", True],
["public", False],
["public", True],
],
)
def test_api_document_mask_authenticated_post_allowed(reach, has_role):
"""Authenticated users should be able to mask a document to which they have access."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach)
if has_role:
models.DocumentAccess.objects.create(document=document, user=user)
# Try masking the document without a link trace
response = client.post(f"/api/v1.0/documents/{document.id!s}/mask/")
assert response.status_code == 400
assert response.json() == {"detail": "User never accessed this document before."}
assert not models.LinkTrace.objects.filter(document=document, user=user).exists()
models.LinkTrace.objects.create(document=document, user=user)
# Mask document
response = client.post(f"/api/v1.0/documents/{document.id!s}/mask/")
assert response.status_code == 201
assert response.json() == {"detail": "Document was masked"}
assert models.LinkTrace.objects.filter(
document=document, user=user, is_masked=True
).exists()
def test_api_document_mask_authenticated_post_forbidden():
"""
Authenticated users should no be allowed to mask a document
to which they don't have access.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted")
# Try masking
response = client.post(f"/api/v1.0/documents/{document.id!s}/mask/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
# Verify in database
assert (
models.LinkTrace.objects.filter(document=document, user=user).exists() is False
)
@pytest.mark.parametrize(
"reach, has_role",
[
["restricted", True],
["authenticated", False],
["authenticated", True],
["public", False],
["public", True],
],
)
def test_api_document_mask_authenticated_post_already_masked_allowed(reach, has_role):
"""POST should not create duplicate link trace if already marked."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach, masked_by=[user])
if has_role:
models.DocumentAccess.objects.create(document=document, user=user)
# Try masking again
response = client.post(f"/api/v1.0/documents/{document.id!s}/mask/")
assert response.status_code == 200
assert response.json() == {"detail": "Document was already masked"}
assert models.LinkTrace.objects.filter(
document=document, user=user, is_masked=True
).exists()
def test_api_document_mask_authenticated_post_already_masked_forbidden():
"""POST should not create duplicate masks if already marked."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted", masked_by=[user])
# Try masking again
response = client.post(f"/api/v1.0/documents/{document.id!s}/mask/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert models.LinkTrace.objects.filter(document=document, user=user).exists()
@pytest.mark.parametrize(
"reach, has_role",
[
["restricted", True],
["authenticated", False],
["authenticated", True],
["public", False],
["public", True],
],
)
def test_api_document_mask_authenticated_post_unmasked_allowed(reach, has_role):
"""POST should not create duplicate link trace if unmasked."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach)
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
if has_role:
models.DocumentAccess.objects.create(document=document, user=user)
# Try masking again
response = client.post(f"/api/v1.0/documents/{document.id!s}/mask/")
assert response.status_code == 201
assert response.json() == {"detail": "Document was masked"}
assert models.LinkTrace.objects.filter(
document=document, user=user, is_masked=True
).exists()
def test_api_document_mask_authenticated_post_unmasked_forbidden():
"""POST should not create duplicate masks if unmasked."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted")
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
# Try masking again
response = client.post(f"/api/v1.0/documents/{document.id!s}/mask/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert models.LinkTrace.objects.filter(
document=document, user=user, is_masked=False
).exists()
@pytest.mark.parametrize(
"reach, has_role",
[
["restricted", True],
["authenticated", False],
["authenticated", True],
["public", False],
["public", True],
],
)
def test_api_document_mask_authenticated_delete_allowed(reach, has_role):
"""Authenticated users should be able to unmask a document using DELETE."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach, masked_by=[user])
if has_role:
models.DocumentAccess.objects.create(document=document, user=user)
# Unmask document
response = client.delete(f"/api/v1.0/documents/{document.id!s}/mask/")
assert response.status_code == 204
assert response.content == b"" # No body
assert response.text == "" # Empty decoded text
assert "Content-Type" not in response.headers # No Content-Type for 204
assert models.LinkTrace.objects.filter(
document=document, user=user, is_masked=False
).exists()
def test_api_document_mask_authenticated_delete_forbidden():
"""
Authenticated users should not be allowed to unmask a document if
they don't have access to it.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted", masked_by=[user])
# Unmask document
response = client.delete(f"/api/v1.0/documents/{document.id!s}/mask/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert models.LinkTrace.objects.filter(
document=document, user=user, is_masked=True
).exists()
@pytest.mark.parametrize(
"reach, has_role",
[
["restricted", True],
["authenticated", False],
["authenticated", True],
["public", False],
["public", True],
],
)
def test_api_document_mask_authenticated_delete_not_masked_allowed(reach, has_role):
"""DELETE should be idempotent if the document is not masked."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach)
if has_role:
models.DocumentAccess.objects.create(document=document, user=user)
# Try unmasking the document without a link trace
response = client.delete(f"/api/v1.0/documents/{document.id!s}/mask/")
assert response.status_code == 400
assert response.json() == {"detail": "User never accessed this document before."}
assert not models.LinkTrace.objects.filter(document=document, user=user).exists()
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
# Unmask document
response = client.delete(f"/api/v1.0/documents/{document.id!s}/mask/")
assert response.status_code == 200
assert response.json() == {"detail": "Document was already not masked"}
assert models.LinkTrace.objects.filter(
document=document, user=user, is_masked=False
).exists()
def test_api_document_mask_authenticated_delete_not_masked_forbidden():
"""DELETE should be idempotent if the document is not masked."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted")
# Try to unmask when no entry exists
response = client.delete(f"/api/v1.0/documents/{document.id!s}/mask/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert (
models.LinkTrace.objects.filter(document=document, user=user).exists() is False
)
@pytest.mark.parametrize(
"reach, has_role",
[
["restricted", True],
["authenticated", False],
["authenticated", True],
["public", False],
["public", True],
],
)
def test_api_document_mask_authenticated_post_unmark_then_mark_again_allowed(
reach, has_role
):
"""A user should be able to mask, unmask, and mask a document again."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach)
if has_role:
models.DocumentAccess.objects.create(document=document, user=user)
models.LinkTrace.objects.create(document=document, user=user, is_masked=False)
url = f"/api/v1.0/documents/{document.id!s}/mask/"
# Mask document
response = client.post(url)
assert response.status_code == 201
# Unmask document
response = client.delete(url)
assert response.status_code == 204
assert response.content == b"" # No body
assert response.text == "" # Empty decoded text
assert "Content-Type" not in response.headers # No Content-Type for 204
# Mask document again
response = client.post(url)
assert response.status_code == 201
assert response.json() == {"detail": "Document was masked"}
assert models.LinkTrace.objects.filter(
document=document, user=user, is_masked=True
).exists()

View File

@@ -12,6 +12,7 @@ from django.utils import timezone
import pytest import pytest
import requests import requests
from freezegun import freeze_time
from rest_framework.test import APIClient from rest_framework.test import APIClient
from core import factories, models 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]) factories.DocumentFactory(id=document_id, link_reach="public", attachments=[key])
original_url = f"http://localhost/media/{key:s}" original_url = f"http://localhost/media/{key:s}"
response = APIClient().get( now = timezone.now()
"/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 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=" "SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization 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) s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}" 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") parent = factories.DocumentFactory(link_reach="public")
factories.DocumentFactory(parent=parent, link_reach="restricted", attachments=[key]) factories.DocumentFactory(parent=parent, link_reach="restricted", attachments=[key])
response = APIClient().get( now = timezone.now()
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url with freeze_time(now):
) response = APIClient().get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
)
assert response.status_code == 200 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=" "SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization 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) s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}" 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]) factories.DocumentFactory(id=document_id, link_reach=reach, attachments=[key])
response = client.get( now = timezone.now()
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url with freeze_time(now):
) response = client.get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
)
assert response.status_code == 200 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=" "SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization 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) s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}" 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"] mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(document=document, team="lasuite") factories.TeamDocumentAccessFactory(document=document, team="lasuite")
response = client.get( now = timezone.now()
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url with freeze_time(now):
) response = client.get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
)
assert response.status_code == 200 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=" "SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization 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) s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}" 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]) factories.DocumentFactory(id=document_id, link_reach="public", attachments=[key])
now = timezone.now()
original_url = f"http://localhost/media/{key:s}" original_url = f"http://localhost/media/{key:s}"
response = APIClient().get( with freeze_time(now):
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url response = APIClient().get(
) "/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200 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=" "SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization 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) s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}" file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"

View File

@@ -124,8 +124,8 @@ def test_api_documents_move_authenticated_target_roles_mocked(
target_role, target_parent_role, position target_role, target_parent_role, position
): ):
""" """
Authenticated users with insufficient permissions on the target document (or its Only authenticated users with sufficient permissions on the target document (or its
parent depending on the position chosen), should not be allowed to move documents. parent depending on the position chosen), should be allowed to move documents.
""" """
user = factories.UserFactory() user = factories.UserFactory()
@@ -208,6 +208,107 @@ def test_api_documents_move_authenticated_target_roles_mocked(
assert document.is_root() is True 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(): def test_api_documents_move_authenticated_deleted_document():
""" """
It should not be possible to move a deleted document or its descendants, even It should not be possible to move a deleted document or its descendants, even

View File

@@ -1,6 +1,7 @@
""" """
Tests for Documents API endpoint in impress's core app: retrieve Tests for Documents API endpoint in impress's core app: retrieve
""" """
# pylint: disable=too-many-lines
import random import random
from datetime import timedelta from datetime import timedelta
@@ -11,7 +12,7 @@ from django.utils import timezone
import pytest import pytest
from rest_framework.test import APIClient from rest_framework.test import APIClient
from core import factories, models from core import choices, factories, models
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@@ -31,22 +32,26 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"ai_transform": False, "ai_transform": False,
"ai_translate": False, "ai_translate": False,
"attachment_upload": document.link_role == "editor", "attachment_upload": document.link_role == "editor",
"can_edit": document.link_role == "editor",
"children_create": False, "children_create": False,
"children_list": True, "children_list": True,
"collaboration_auth": True, "collaboration_auth": True,
"comment": document.link_role in ["commenter", "editor"],
"cors_proxy": True, "cors_proxy": True,
"content": True,
"descendants": True, "descendants": True,
"destroy": False, "destroy": False,
"duplicate": True, "duplicate": False,
# Anonymous user can't favorite a document even with read access # Anonymous user can't favorite a document even with read access
"favorite": False, "favorite": False,
"invite_owner": False, "invite_owner": False,
"link_configuration": False, "link_configuration": False,
"link_select_options": { "link_select_options": {
"authenticated": ["reader", "editor"], "authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "editor"], "public": ["reader", "commenter", "editor"],
"restricted": ["reader", "editor"], "restricted": None,
}, },
"mask": False,
"media_auth": True, "media_auth": True,
"media_check": True, "media_check": True,
"move": False, "move": False,
@@ -59,9 +64,14 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"versions_list": False, "versions_list": False,
"versions_retrieve": 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, "content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"), "created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id), "creator": str(document.creator.id),
"deleted_at": None,
"depth": 1, "depth": 1,
"excerpt": document.excerpt, "excerpt": document.excerpt,
"is_favorite": False, "is_favorite": False,
@@ -73,7 +83,7 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"path": document.path, "path": document.path,
"title": document.title, "title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [], "user_role": None,
} }
@@ -91,6 +101,7 @@ def test_api_documents_retrieve_anonymous_public_parent():
assert response.status_code == 200 assert response.status_code == 200
links = document.get_ancestors().values("link_reach", "link_role") links = document.get_ancestors().values("link_reach", "link_role")
links_definition = choices.get_equivalent_link_definition(links)
assert response.json() == { assert response.json() == {
"id": str(document.id), "id": str(document.id),
"abilities": { "abilities": {
@@ -99,18 +110,24 @@ def test_api_documents_retrieve_anonymous_public_parent():
"ai_transform": False, "ai_transform": False,
"ai_translate": False, "ai_translate": False,
"attachment_upload": grand_parent.link_role == "editor", "attachment_upload": grand_parent.link_role == "editor",
"can_edit": grand_parent.link_role == "editor",
"children_create": False, "children_create": False,
"children_list": True, "children_list": True,
"collaboration_auth": True, "collaboration_auth": True,
"comment": grand_parent.link_role in ["commenter", "editor"],
"descendants": True, "descendants": True,
"cors_proxy": True, "cors_proxy": True,
"content": True,
"destroy": False, "destroy": False,
"duplicate": True, "duplicate": False,
# Anonymous user can't favorite a document even with read access # Anonymous user can't favorite a document even with read access
"favorite": False, "favorite": False,
"invite_owner": False, "invite_owner": False,
"link_configuration": False, "link_configuration": False,
"link_select_options": models.LinkReachChoices.get_select_options(links), "link_select_options": models.LinkReachChoices.get_select_options(
**links_definition
),
"mask": False,
"media_auth": True, "media_auth": True,
"media_check": True, "media_check": True,
"move": False, "move": False,
@@ -123,9 +140,14 @@ def test_api_documents_retrieve_anonymous_public_parent():
"versions_list": False, "versions_list": False,
"versions_retrieve": 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, "content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"), "created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id), "creator": str(document.creator.id),
"deleted_at": None,
"depth": 3, "depth": 3,
"excerpt": document.excerpt, "excerpt": document.excerpt,
"is_favorite": False, "is_favorite": False,
@@ -137,7 +159,7 @@ def test_api_documents_retrieve_anonymous_public_parent():
"path": document.path, "path": document.path,
"title": document.title, "title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [], "user_role": None,
} }
@@ -196,21 +218,25 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"ai_transform": document.link_role == "editor", "ai_transform": document.link_role == "editor",
"ai_translate": document.link_role == "editor", "ai_translate": document.link_role == "editor",
"attachment_upload": document.link_role == "editor", "attachment_upload": document.link_role == "editor",
"can_edit": document.link_role == "editor",
"children_create": document.link_role == "editor", "children_create": document.link_role == "editor",
"children_list": True, "children_list": True,
"collaboration_auth": True, "collaboration_auth": True,
"comment": document.link_role in ["commenter", "editor"],
"descendants": True, "descendants": True,
"cors_proxy": True, "cors_proxy": True,
"content": True,
"destroy": False, "destroy": False,
"duplicate": True, "duplicate": True,
"favorite": True, "favorite": True,
"invite_owner": False, "invite_owner": False,
"link_configuration": False, "link_configuration": False,
"link_select_options": { "link_select_options": {
"authenticated": ["reader", "editor"], "authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "editor"], "public": ["reader", "commenter", "editor"],
"restricted": ["reader", "editor"], "restricted": None,
}, },
"mask": True,
"media_auth": True, "media_auth": True,
"media_check": True, "media_check": True,
"move": False, "move": False,
@@ -223,10 +249,15 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"versions_list": False, "versions_list": False,
"versions_retrieve": 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, "content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"), "created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id), "creator": str(document.creator.id),
"depth": 1, "depth": 1,
"deleted_at": None,
"excerpt": document.excerpt, "excerpt": document.excerpt,
"is_favorite": False, "is_favorite": False,
"link_reach": reach, "link_reach": reach,
@@ -237,7 +268,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"path": document.path, "path": document.path,
"title": document.title, "title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [], "user_role": None,
} }
assert ( assert (
models.LinkTrace.objects.filter(document=document, user=user).exists() is True models.LinkTrace.objects.filter(document=document, user=user).exists() is True
@@ -263,6 +294,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
assert response.status_code == 200 assert response.status_code == 200
links = document.get_ancestors().values("link_reach", "link_role") links = document.get_ancestors().values("link_reach", "link_role")
links_definition = choices.get_equivalent_link_definition(links)
assert response.json() == { assert response.json() == {
"id": str(document.id), "id": str(document.id),
"abilities": { "abilities": {
@@ -271,20 +303,26 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
"ai_transform": grand_parent.link_role == "editor", "ai_transform": grand_parent.link_role == "editor",
"ai_translate": grand_parent.link_role == "editor", "ai_translate": grand_parent.link_role == "editor",
"attachment_upload": grand_parent.link_role == "editor", "attachment_upload": grand_parent.link_role == "editor",
"can_edit": grand_parent.link_role == "editor",
"children_create": grand_parent.link_role == "editor", "children_create": grand_parent.link_role == "editor",
"children_list": True, "children_list": True,
"collaboration_auth": True, "collaboration_auth": True,
"comment": grand_parent.link_role in ["commenter", "editor"],
"descendants": True, "descendants": True,
"cors_proxy": True, "cors_proxy": True,
"content": True,
"destroy": False, "destroy": False,
"duplicate": True, "duplicate": True,
"favorite": True, "favorite": True,
"invite_owner": False, "invite_owner": False,
"link_configuration": False, "link_configuration": False,
"link_select_options": models.LinkReachChoices.get_select_options(links), "link_select_options": models.LinkReachChoices.get_select_options(
**links_definition
),
"mask": True,
"move": False,
"media_auth": True, "media_auth": True,
"media_check": True, "media_check": True,
"move": False,
"partial_update": grand_parent.link_role == "editor", "partial_update": grand_parent.link_role == "editor",
"restore": False, "restore": False,
"retrieve": True, "retrieve": True,
@@ -294,10 +332,15 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
"versions_list": False, "versions_list": False,
"versions_retrieve": 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, "content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"), "created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id), "creator": str(document.creator.id),
"depth": 3, "depth": 3,
"deleted_at": None,
"excerpt": document.excerpt, "excerpt": document.excerpt,
"is_favorite": False, "is_favorite": False,
"link_reach": document.link_reach, "link_reach": document.link_reach,
@@ -308,7 +351,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
"path": document.path, "path": document.path,
"title": document.title, "title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [], "user_role": None,
} }
@@ -404,9 +447,14 @@ def test_api_documents_retrieve_authenticated_related_direct():
assert response.json() == { assert response.json() == {
"id": str(document.id), "id": str(document.id),
"abilities": document.get_abilities(user), "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, "content": document.content,
"creator": str(document.creator.id), "creator": str(document.creator.id),
"created_at": document.created_at.isoformat().replace("+00:00", "Z"), "created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"deleted_at": None,
"depth": 1, "depth": 1,
"excerpt": document.excerpt, "excerpt": document.excerpt,
"is_favorite": False, "is_favorite": False,
@@ -418,7 +466,7 @@ def test_api_documents_retrieve_authenticated_related_direct():
"path": document.path, "path": document.path,
"title": document.title, "title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role], "user_role": access.role,
} }
@@ -444,41 +492,53 @@ def test_api_documents_retrieve_authenticated_related_parent():
) )
assert response.status_code == 200 assert response.status_code == 200
links = document.get_ancestors().values("link_reach", "link_role") links = document.get_ancestors().values("link_reach", "link_role")
link_definition = choices.get_equivalent_link_definition(links)
assert response.json() == { assert response.json() == {
"id": str(document.id), "id": str(document.id),
"abilities": { "abilities": {
"accesses_manage": access.role in ["administrator", "owner"], "accesses_manage": access.role in ["administrator", "owner"],
"accesses_view": True, "accesses_view": True,
"ai_transform": access.role != "reader", "ai_transform": access.role not in ["reader", "commenter"],
"ai_translate": access.role != "reader", "ai_translate": access.role not in ["reader", "commenter"],
"attachment_upload": access.role != "reader", "attachment_upload": access.role not in ["reader", "commenter"],
"children_create": access.role != "reader", "can_edit": access.role not in ["reader", "commenter"],
"children_create": access.role not in ["reader", "commenter"],
"children_list": True, "children_list": True,
"collaboration_auth": True, "collaboration_auth": True,
"comment": access.role != "reader",
"descendants": True, "descendants": True,
"cors_proxy": True, "cors_proxy": True,
"destroy": access.role == "owner", "content": True,
"destroy": access.role in ["administrator", "owner"],
"duplicate": True, "duplicate": True,
"favorite": True, "favorite": True,
"invite_owner": access.role == "owner", "invite_owner": access.role == "owner",
"link_configuration": access.role in ["administrator", "owner"], "link_configuration": access.role in ["administrator", "owner"],
"link_select_options": models.LinkReachChoices.get_select_options(links), "link_select_options": models.LinkReachChoices.get_select_options(
**link_definition
),
"mask": True,
"media_auth": True, "media_auth": True,
"media_check": True, "media_check": True,
"move": access.role in ["administrator", "owner"], "move": access.role in ["administrator", "owner"],
"partial_update": access.role != "reader", "partial_update": access.role not in ["reader", "commenter"],
"restore": access.role == "owner", "restore": access.role == "owner",
"retrieve": True, "retrieve": True,
"tree": True, "tree": True,
"update": access.role != "reader", "update": access.role not in ["reader", "commenter"],
"versions_destroy": access.role in ["administrator", "owner"], "versions_destroy": access.role in ["administrator", "owner"],
"versions_list": True, "versions_list": True,
"versions_retrieve": True, "versions_retrieve": True,
}, },
"ancestors_link_reach": "restricted",
"ancestors_link_role": None,
"computed_link_reach": "restricted",
"computed_link_role": None,
"content": document.content, "content": document.content,
"creator": str(document.creator.id), "creator": str(document.creator.id),
"created_at": document.created_at.isoformat().replace("+00:00", "Z"), "created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"depth": 3, "depth": 3,
"deleted_at": None,
"excerpt": document.excerpt, "excerpt": document.excerpt,
"is_favorite": False, "is_favorite": False,
"link_reach": "restricted", "link_reach": "restricted",
@@ -489,7 +549,7 @@ def test_api_documents_retrieve_authenticated_related_parent():
"path": document.path, "path": document.path,
"title": document.title, "title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role], "user_role": access.role,
} }
@@ -585,16 +645,16 @@ def test_api_documents_retrieve_authenticated_related_team_none(mock_user_teams)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"teams,roles", "teams,role",
[ [
[["readers"], ["reader"]], [["readers"], "reader"],
[["unknown", "readers"], ["reader"]], [["unknown", "readers"], "reader"],
[["editors"], ["editor"]], [["editors"], "editor"],
[["unknown", "editors"], ["editor"]], [["unknown", "editors"], "editor"],
], ],
) )
def test_api_documents_retrieve_authenticated_related_team_members( 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 Authenticated users should be allowed to retrieve a document to which they
@@ -627,9 +687,14 @@ def test_api_documents_retrieve_authenticated_related_team_members(
assert response.json() == { assert response.json() == {
"id": str(document.id), "id": str(document.id),
"abilities": document.get_abilities(user), "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, "content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"), "created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id), "creator": str(document.creator.id),
"deleted_at": None,
"depth": 1, "depth": 1,
"excerpt": document.excerpt, "excerpt": document.excerpt,
"is_favorite": False, "is_favorite": False,
@@ -641,20 +706,20 @@ def test_api_documents_retrieve_authenticated_related_team_members(
"path": document.path, "path": document.path,
"title": document.title, "title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": roles, "user_role": role,
} }
@pytest.mark.parametrize( @pytest.mark.parametrize(
"teams,roles", "teams,role",
[ [
[["administrators"], ["administrator"]], [["administrators"], "administrator"],
[["editors", "administrators"], ["administrator", "editor"]], [["editors", "administrators"], "administrator"],
[["unknown", "administrators"], ["administrator"]], [["unknown", "administrators"], "administrator"],
], ],
) )
def test_api_documents_retrieve_authenticated_related_team_administrators( 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 Authenticated users should be allowed to retrieve a document to which they
@@ -689,9 +754,14 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
assert response.json() == { assert response.json() == {
"id": str(document.id), "id": str(document.id),
"abilities": document.get_abilities(user), "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, "content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"), "created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id), "creator": str(document.creator.id),
"deleted_at": None,
"depth": 1, "depth": 1,
"excerpt": document.excerpt, "excerpt": document.excerpt,
"is_favorite": False, "is_favorite": False,
@@ -703,21 +773,21 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
"path": document.path, "path": document.path,
"title": document.title, "title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": roles, "user_role": role,
} }
@pytest.mark.parametrize( @pytest.mark.parametrize(
"teams,roles", "teams,role",
[ [
[["owners"], ["owner"]], [["owners"], "owner"],
[["owners", "administrators"], ["owner", "administrator"]], [["owners", "administrators"], "owner"],
[["members", "administrators", "owners"], ["owner", "administrator"]], [["members", "administrators", "owners"], "owner"],
[["unknown", "owners"], ["owner"]], [["unknown", "owners"], "owner"],
], ],
) )
def test_api_documents_retrieve_authenticated_related_team_owners( 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 Authenticated users should be allowed to retrieve a restricted document to which
@@ -751,9 +821,14 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
assert response.json() == { assert response.json() == {
"id": str(document.id), "id": str(document.id),
"abilities": document.get_abilities(user), "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, "content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"), "created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id), "creator": str(document.creator.id),
"deleted_at": None,
"depth": 1, "depth": 1,
"excerpt": document.excerpt, "excerpt": document.excerpt,
"is_favorite": False, "is_favorite": False,
@@ -765,11 +840,11 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
"path": document.path, "path": document.path,
"title": document.title, "title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), "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. Roles should be annotated on querysets taking into account all documents ancestors.
""" """
@@ -792,15 +867,14 @@ def test_api_documents_retrieve_user_roles(django_assert_max_num_queries):
factories.UserDocumentAccessFactory(document=parent, user=user), factories.UserDocumentAccessFactory(document=parent, user=user),
factories.UserDocumentAccessFactory(document=document, 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): with django_assert_max_num_queries(14):
response = client.get(f"/api/v1.0/documents/{document.id!s}/") response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 200 assert response.status_code == 200
user_roles = response.json()["user_roles"] assert response.json()["user_role"] == expected_role
assert set(user_roles) == expected_roles
def test_api_documents_retrieve_numqueries_with_link_trace(django_assert_num_queries): def test_api_documents_retrieve_numqueries_with_link_trace(django_assert_num_queries):

File diff suppressed because it is too large Load Diff

View File

@@ -48,11 +48,11 @@ def test_api_documents_trashbin_format():
other_users = factories.UserFactory.create_batch(3) other_users = factories.UserFactory.create_batch(3)
document = factories.DocumentFactory( document = factories.DocumentFactory(
deleted_at=timezone.now(),
users=factories.UserFactory.create_batch(2), users=factories.UserFactory.create_batch(2),
favorited_by=[user, *other_users], favorited_by=[user, *other_users],
link_traces=other_users, link_traces=other_users,
) )
document.soft_delete()
factories.UserDocumentAccessFactory(document=document, user=user, role="owner") factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
response = client.get("/api/v1.0/documents/trashbin/") response = client.get("/api/v1.0/documents/trashbin/")
@@ -70,42 +70,51 @@ def test_api_documents_trashbin_format():
assert results[0] == { assert results[0] == {
"id": str(document.id), "id": str(document.id),
"abilities": { "abilities": {
"accesses_manage": True, "accesses_manage": False,
"accesses_view": True, "accesses_view": False,
"ai_transform": True, "ai_transform": False,
"ai_translate": True, "ai_translate": False,
"attachment_upload": True, "attachment_upload": False,
"children_create": True, "can_edit": False,
"children_list": True, "children_create": False,
"collaboration_auth": True, "children_list": False,
"descendants": True, "collaboration_auth": False,
"cors_proxy": True, "descendants": False,
"destroy": True, "cors_proxy": False,
"duplicate": True, "comment": False,
"favorite": True, "content": False,
"invite_owner": True, "destroy": False,
"link_configuration": True, "duplicate": False,
"favorite": False,
"invite_owner": False,
"link_configuration": False,
"link_select_options": { "link_select_options": {
"authenticated": ["reader", "editor"], "authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "editor"], "public": ["reader", "commenter", "editor"],
"restricted": ["reader", "editor"], "restricted": None,
}, },
"media_auth": True, "mask": False,
"media_check": True, "media_auth": False,
"media_check": False,
"move": False, # Can't move a deleted document "move": False, # Can't move a deleted document
"partial_update": True, "partial_update": False,
"restore": True, "restore": True,
"retrieve": True, "retrieve": True,
"tree": True, "tree": True,
"update": True, "update": False,
"versions_destroy": True, "versions_destroy": False,
"versions_list": True, "versions_list": False,
"versions_retrieve": True, "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,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"), "created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id), "creator": str(document.creator.id),
"depth": 1, "depth": 1,
"excerpt": document.excerpt, "excerpt": document.excerpt,
"deleted_at": document.ancestors_deleted_at.isoformat().replace("+00:00", "Z"),
"link_reach": document.link_reach, "link_reach": document.link_reach,
"link_role": document.link_role, "link_role": document.link_role,
"nb_accesses_ancestors": 0, "nb_accesses_ancestors": 0,
@@ -114,7 +123,7 @@ def test_api_documents_trashbin_format():
"path": document.path, "path": document.path,
"title": document.title, "title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": ["owner"], "user_role": "owner",
} }
@@ -158,10 +167,10 @@ def test_api_documents_trashbin_authenticated_direct(django_assert_num_queries):
expected_ids = {str(document1.id), str(document2.id), str(document3.id)} expected_ids = {str(document1.id), str(document2.id), str(document3.id)}
with django_assert_num_queries(10): with django_assert_num_queries(11):
response = client.get("/api/v1.0/documents/trashbin/") response = client.get("/api/v1.0/documents/trashbin/")
with django_assert_num_queries(4): with django_assert_num_queries(5):
response = client.get("/api/v1.0/documents/trashbin/") response = client.get("/api/v1.0/documents/trashbin/")
assert response.status_code == 200 assert response.status_code == 200
@@ -200,10 +209,10 @@ def test_api_documents_trashbin_authenticated_via_team(
expected_ids = {str(deleted_document_team1.id), str(deleted_document_team2.id)} expected_ids = {str(deleted_document_team1.id), str(deleted_document_team2.id)}
with django_assert_num_queries(7): with django_assert_num_queries(8):
response = client.get("/api/v1.0/documents/trashbin/") response = client.get("/api/v1.0/documents/trashbin/")
with django_assert_num_queries(3): with django_assert_num_queries(4):
response = client.get("/api/v1.0/documents/trashbin/") response = client.get("/api/v1.0/documents/trashbin/")
assert response.status_code == 200 assert response.status_code == 200
@@ -285,3 +294,29 @@ def test_api_documents_trashbin_distinct():
content = response.json() content = response.json()
assert len(content["results"]) == 1 assert len(content["results"]) == 1
assert content["results"][0]["id"] == str(document.id) assert content["results"][0]["id"] == str(document.id)
def test_api_documents_trashbin_empty_queryset_bug():
"""
Test that users with no owner role don't see documents.
"""
# Create a new user with no owner access to any document
new_user = factories.UserFactory()
client = APIClient()
client.force_login(new_user)
# Create some deleted documents owned by other users
other_user = factories.UserFactory()
item1 = factories.DocumentFactory(users=[(other_user, "owner")])
item1.soft_delete()
item2 = factories.DocumentFactory(users=[(other_user, "owner")])
item2.soft_delete()
item3 = factories.DocumentFactory(users=[(other_user, "owner")])
item3.soft_delete()
response = client.get("/api/v1.0/documents/trashbin/")
assert response.status_code == 200
content = response.json()
assert content["count"] == 0
assert len(content["results"]) == 0

View File

@@ -32,18 +32,25 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == { assert response.json() == {
"abilities": parent.get_abilities(AnonymousUser()), "abilities": parent.get_abilities(AnonymousUser()),
"ancestors_link_reach": parent.ancestors_link_reach,
"ancestors_link_role": parent.ancestors_link_role,
"children": [ "children": [
{ {
"abilities": document.get_abilities(AnonymousUser()), "abilities": document.get_abilities(AnonymousUser()),
"children": [ "children": [
{ {
"abilities": child.get_abilities(AnonymousUser()), "abilities": child.get_abilities(AnonymousUser()),
"ancestors_link_reach": child.ancestors_link_reach,
"ancestors_link_role": child.ancestors_link_role,
"children": [], "children": [],
"computed_link_reach": child.computed_link_reach,
"computed_link_role": child.computed_link_role,
"created_at": child.created_at.isoformat().replace( "created_at": child.created_at.isoformat().replace(
"+00:00", "Z" "+00:00", "Z"
), ),
"creator": str(child.creator.id), "creator": str(child.creator.id),
"depth": 3, "depth": 3,
"deleted_at": None,
"excerpt": child.excerpt, "excerpt": child.excerpt,
"id": str(child.id), "id": str(child.id),
"is_favorite": False, "is_favorite": False,
@@ -57,12 +64,17 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
"updated_at": child.updated_at.isoformat().replace( "updated_at": child.updated_at.isoformat().replace(
"+00:00", "Z" "+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"), "created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id), "creator": str(document.creator.id),
"depth": 2, "depth": 2,
"deleted_at": None,
"excerpt": document.excerpt, "excerpt": document.excerpt,
"id": str(document.id), "id": str(document.id),
"is_favorite": False, "is_favorite": False,
@@ -74,14 +86,19 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
"path": document.path, "path": document.path,
"title": document.title, "title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [], "user_role": None,
}, },
{ {
"abilities": sibling1.get_abilities(AnonymousUser()), "abilities": sibling1.get_abilities(AnonymousUser()),
"ancestors_link_reach": sibling1.ancestors_link_reach,
"ancestors_link_role": sibling1.ancestors_link_role,
"children": [], "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"), "created_at": sibling1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(sibling1.creator.id), "creator": str(sibling1.creator.id),
"depth": 2, "depth": 2,
"deleted_at": None,
"excerpt": sibling1.excerpt, "excerpt": sibling1.excerpt,
"id": str(sibling1.id), "id": str(sibling1.id),
"is_favorite": False, "is_favorite": False,
@@ -93,14 +110,19 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
"path": sibling1.path, "path": sibling1.path,
"title": sibling1.title, "title": sibling1.title,
"updated_at": sibling1.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": sibling1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [], "user_role": None,
}, },
{ {
"abilities": sibling2.get_abilities(AnonymousUser()), "abilities": sibling2.get_abilities(AnonymousUser()),
"ancestors_link_reach": sibling2.ancestors_link_reach,
"ancestors_link_role": sibling2.ancestors_link_role,
"children": [], "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"), "created_at": sibling2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(sibling2.creator.id), "creator": str(sibling2.creator.id),
"depth": 2, "depth": 2,
"deleted_at": None,
"excerpt": sibling2.excerpt, "excerpt": sibling2.excerpt,
"id": str(sibling2.id), "id": str(sibling2.id),
"is_favorite": False, "is_favorite": False,
@@ -112,12 +134,15 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
"path": sibling2.path, "path": sibling2.path,
"title": sibling2.title, "title": sibling2.title,
"updated_at": sibling2.updated_at.isoformat().replace("+00:00", "Z"), "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"), "created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(parent.creator.id), "creator": str(parent.creator.id),
"depth": 1, "depth": 1,
"deleted_at": None,
"excerpt": parent.excerpt, "excerpt": parent.excerpt,
"id": str(parent.id), "id": str(parent.id),
"is_favorite": False, "is_favorite": False,
@@ -129,7 +154,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
"path": parent.path, "path": parent.path,
"title": parent.title, "title": parent.title,
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [], "user_role": None,
} }
@@ -163,23 +188,34 @@ def test_api_documents_tree_list_anonymous_public_parent():
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/tree/") response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/tree/")
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == { expected_tree = {
"abilities": grand_parent.get_abilities(AnonymousUser()), "abilities": grand_parent.get_abilities(AnonymousUser()),
"ancestors_link_reach": grand_parent.ancestors_link_reach,
"ancestors_link_role": grand_parent.ancestors_link_role,
"children": [ "children": [
{ {
"abilities": parent.get_abilities(AnonymousUser()), "abilities": parent.get_abilities(AnonymousUser()),
"ancestors_link_reach": parent.ancestors_link_reach,
"ancestors_link_role": parent.ancestors_link_role,
"children": [ "children": [
{ {
"abilities": document.get_abilities(AnonymousUser()), "abilities": document.get_abilities(AnonymousUser()),
"ancestors_link_reach": document.ancestors_link_reach,
"ancestors_link_role": document.ancestors_link_role,
"children": [ "children": [
{ {
"abilities": child.get_abilities(AnonymousUser()), "abilities": child.get_abilities(AnonymousUser()),
"ancestors_link_reach": child.ancestors_link_reach,
"ancestors_link_role": child.ancestors_link_role,
"children": [], "children": [],
"computed_link_reach": child.computed_link_reach,
"computed_link_role": child.computed_link_role,
"created_at": child.created_at.isoformat().replace( "created_at": child.created_at.isoformat().replace(
"+00:00", "Z" "+00:00", "Z"
), ),
"creator": str(child.creator.id), "creator": str(child.creator.id),
"depth": 5, "depth": 5,
"deleted_at": None,
"excerpt": child.excerpt, "excerpt": child.excerpt,
"id": str(child.id), "id": str(child.id),
"is_favorite": False, "is_favorite": False,
@@ -193,14 +229,17 @@ def test_api_documents_tree_list_anonymous_public_parent():
"updated_at": child.updated_at.isoformat().replace( "updated_at": child.updated_at.isoformat().replace(
"+00:00", "Z" "+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( "created_at": document.created_at.isoformat().replace(
"+00:00", "Z" "+00:00", "Z"
), ),
"creator": str(document.creator.id), "creator": str(document.creator.id),
"depth": 4, "depth": 4,
"deleted_at": None,
"excerpt": document.excerpt, "excerpt": document.excerpt,
"id": str(document.id), "id": str(document.id),
"is_favorite": False, "is_favorite": False,
@@ -214,16 +253,21 @@ def test_api_documents_tree_list_anonymous_public_parent():
"updated_at": document.updated_at.isoformat().replace( "updated_at": document.updated_at.isoformat().replace(
"+00:00", "Z" "+00:00", "Z"
), ),
"user_roles": [], "user_role": None,
}, },
{ {
"abilities": document_sibling.get_abilities(AnonymousUser()), "abilities": document_sibling.get_abilities(AnonymousUser()),
"ancestors_link_reach": document_sibling.ancestors_link_reach,
"ancestors_link_role": document_sibling.ancestors_link_role,
"children": [], "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( "created_at": document_sibling.created_at.isoformat().replace(
"+00:00", "Z" "+00:00", "Z"
), ),
"creator": str(document_sibling.creator.id), "creator": str(document_sibling.creator.id),
"depth": 4, "depth": 4,
"deleted_at": None,
"excerpt": document_sibling.excerpt, "excerpt": document_sibling.excerpt,
"id": str(document_sibling.id), "id": str(document_sibling.id),
"is_favorite": False, "is_favorite": False,
@@ -237,12 +281,15 @@ def test_api_documents_tree_list_anonymous_public_parent():
"updated_at": document_sibling.updated_at.isoformat().replace( "updated_at": document_sibling.updated_at.isoformat().replace(
"+00:00", "Z" "+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"), "created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(parent.creator.id), "creator": str(parent.creator.id),
"depth": 3, "depth": 3,
"deleted_at": None,
"excerpt": parent.excerpt, "excerpt": parent.excerpt,
"id": str(parent.id), "id": str(parent.id),
"is_favorite": False, "is_favorite": False,
@@ -254,16 +301,21 @@ def test_api_documents_tree_list_anonymous_public_parent():
"path": parent.path, "path": parent.path,
"title": parent.title, "title": parent.title,
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [], "user_role": None,
}, },
{ {
"abilities": parent_sibling.get_abilities(AnonymousUser()), "abilities": parent_sibling.get_abilities(AnonymousUser()),
"ancestors_link_reach": parent_sibling.ancestors_link_reach,
"ancestors_link_role": parent_sibling.ancestors_link_role,
"children": [], "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( "created_at": parent_sibling.created_at.isoformat().replace(
"+00:00", "Z" "+00:00", "Z"
), ),
"creator": str(parent_sibling.creator.id), "creator": str(parent_sibling.creator.id),
"depth": 3, "depth": 3,
"deleted_at": None,
"excerpt": parent_sibling.excerpt, "excerpt": parent_sibling.excerpt,
"id": str(parent_sibling.id), "id": str(parent_sibling.id),
"is_favorite": False, "is_favorite": False,
@@ -277,12 +329,15 @@ def test_api_documents_tree_list_anonymous_public_parent():
"updated_at": parent_sibling.updated_at.isoformat().replace( "updated_at": parent_sibling.updated_at.isoformat().replace(
"+00:00", "Z" "+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"), "created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_parent.creator.id), "creator": str(grand_parent.creator.id),
"depth": 2, "depth": 2,
"deleted_at": None,
"excerpt": grand_parent.excerpt, "excerpt": grand_parent.excerpt,
"id": str(grand_parent.id), "id": str(grand_parent.id),
"is_favorite": False, "is_favorite": False,
@@ -294,8 +349,9 @@ def test_api_documents_tree_list_anonymous_public_parent():
"path": grand_parent.path, "path": grand_parent.path,
"title": grand_parent.title, "title": grand_parent.title,
"updated_at": grand_parent.updated_at.isoformat().replace("+00:00", "Z"), "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"]) @pytest.mark.parametrize("reach", ["restricted", "authenticated"])
@@ -341,18 +397,27 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == { assert response.json() == {
"abilities": parent.get_abilities(user), "abilities": parent.get_abilities(user),
"ancestors_link_reach": None,
"ancestors_link_role": None,
"children": [ "children": [
{ {
"abilities": document.get_abilities(user), "abilities": document.get_abilities(user),
"ancestors_link_reach": document.ancestors_link_reach,
"ancestors_link_role": document.ancestors_link_role,
"children": [ "children": [
{ {
"abilities": child.get_abilities(user), "abilities": child.get_abilities(user),
"ancestors_link_reach": child.ancestors_link_reach,
"ancestors_link_role": child.ancestors_link_role,
"children": [], "children": [],
"computed_link_reach": child.computed_link_reach,
"computed_link_role": child.computed_link_role,
"created_at": child.created_at.isoformat().replace( "created_at": child.created_at.isoformat().replace(
"+00:00", "Z" "+00:00", "Z"
), ),
"creator": str(child.creator.id), "creator": str(child.creator.id),
"depth": 3, "depth": 3,
"deleted_at": None,
"excerpt": child.excerpt, "excerpt": child.excerpt,
"id": str(child.id), "id": str(child.id),
"is_favorite": False, "is_favorite": False,
@@ -366,12 +431,15 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
"updated_at": child.updated_at.isoformat().replace( "updated_at": child.updated_at.isoformat().replace(
"+00:00", "Z" "+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"), "created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id), "creator": str(document.creator.id),
"depth": 2, "depth": 2,
"deleted_at": None,
"excerpt": document.excerpt, "excerpt": document.excerpt,
"id": str(document.id), "id": str(document.id),
"is_favorite": False, "is_favorite": False,
@@ -383,14 +451,19 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
"path": document.path, "path": document.path,
"title": document.title, "title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [], "user_role": None,
}, },
{ {
"abilities": sibling.get_abilities(user), "abilities": sibling.get_abilities(user),
"ancestors_link_reach": sibling.ancestors_link_reach,
"ancestors_link_role": sibling.ancestors_link_role,
"children": [], "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"), "created_at": sibling.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(sibling.creator.id), "creator": str(sibling.creator.id),
"depth": 2, "depth": 2,
"deleted_at": None,
"excerpt": sibling.excerpt, "excerpt": sibling.excerpt,
"id": str(sibling.id), "id": str(sibling.id),
"is_favorite": False, "is_favorite": False,
@@ -402,12 +475,15 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
"path": sibling.path, "path": sibling.path,
"title": sibling.title, "title": sibling.title,
"updated_at": sibling.updated_at.isoformat().replace("+00:00", "Z"), "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"), "created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(parent.creator.id), "creator": str(parent.creator.id),
"depth": 1, "depth": 1,
"deleted_at": None,
"excerpt": parent.excerpt, "excerpt": parent.excerpt,
"id": str(parent.id), "id": str(parent.id),
"is_favorite": False, "is_favorite": False,
@@ -419,7 +495,7 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
"path": parent.path, "path": parent.path,
"title": parent.title, "title": parent.title,
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [], "user_role": None,
} }
@@ -460,21 +536,32 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == { assert response.json() == {
"abilities": grand_parent.get_abilities(user), "abilities": grand_parent.get_abilities(user),
"ancestors_link_reach": grand_parent.ancestors_link_reach,
"ancestors_link_role": grand_parent.ancestors_link_role,
"children": [ "children": [
{ {
"abilities": parent.get_abilities(user), "abilities": parent.get_abilities(user),
"ancestors_link_reach": parent.ancestors_link_reach,
"ancestors_link_role": parent.ancestors_link_role,
"children": [ "children": [
{ {
"abilities": document.get_abilities(user), "abilities": document.get_abilities(user),
"ancestors_link_reach": document.ancestors_link_reach,
"ancestors_link_role": document.ancestors_link_role,
"children": [ "children": [
{ {
"abilities": child.get_abilities(user), "abilities": child.get_abilities(user),
"ancestors_link_reach": child.ancestors_link_reach,
"ancestors_link_role": child.ancestors_link_role,
"children": [], "children": [],
"computed_link_reach": child.computed_link_reach,
"computed_link_role": child.computed_link_role,
"created_at": child.created_at.isoformat().replace( "created_at": child.created_at.isoformat().replace(
"+00:00", "Z" "+00:00", "Z"
), ),
"creator": str(child.creator.id), "creator": str(child.creator.id),
"depth": 5, "depth": 5,
"deleted_at": None,
"excerpt": child.excerpt, "excerpt": child.excerpt,
"id": str(child.id), "id": str(child.id),
"is_favorite": False, "is_favorite": False,
@@ -488,14 +575,17 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
"updated_at": child.updated_at.isoformat().replace( "updated_at": child.updated_at.isoformat().replace(
"+00:00", "Z" "+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( "created_at": document.created_at.isoformat().replace(
"+00:00", "Z" "+00:00", "Z"
), ),
"creator": str(document.creator.id), "creator": str(document.creator.id),
"depth": 4, "depth": 4,
"deleted_at": None,
"excerpt": document.excerpt, "excerpt": document.excerpt,
"id": str(document.id), "id": str(document.id),
"is_favorite": False, "is_favorite": False,
@@ -509,16 +599,21 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
"updated_at": document.updated_at.isoformat().replace( "updated_at": document.updated_at.isoformat().replace(
"+00:00", "Z" "+00:00", "Z"
), ),
"user_roles": [], "user_role": None,
}, },
{ {
"abilities": document_sibling.get_abilities(user), "abilities": document_sibling.get_abilities(user),
"ancestors_link_reach": document_sibling.ancestors_link_reach,
"ancestors_link_role": document_sibling.ancestors_link_role,
"children": [], "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( "created_at": document_sibling.created_at.isoformat().replace(
"+00:00", "Z" "+00:00", "Z"
), ),
"creator": str(document_sibling.creator.id), "creator": str(document_sibling.creator.id),
"depth": 4, "depth": 4,
"deleted_at": None,
"excerpt": document_sibling.excerpt, "excerpt": document_sibling.excerpt,
"id": str(document_sibling.id), "id": str(document_sibling.id),
"is_favorite": False, "is_favorite": False,
@@ -532,12 +627,15 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
"updated_at": document_sibling.updated_at.isoformat().replace( "updated_at": document_sibling.updated_at.isoformat().replace(
"+00:00", "Z" "+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"), "created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(parent.creator.id), "creator": str(parent.creator.id),
"depth": 3, "depth": 3,
"deleted_at": None,
"excerpt": parent.excerpt, "excerpt": parent.excerpt,
"id": str(parent.id), "id": str(parent.id),
"is_favorite": False, "is_favorite": False,
@@ -549,16 +647,21 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
"path": parent.path, "path": parent.path,
"title": parent.title, "title": parent.title,
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [], "user_role": None,
}, },
{ {
"abilities": parent_sibling.get_abilities(user), "abilities": parent_sibling.get_abilities(user),
"ancestors_link_reach": parent_sibling.ancestors_link_reach,
"ancestors_link_role": parent_sibling.ancestors_link_role,
"children": [], "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( "created_at": parent_sibling.created_at.isoformat().replace(
"+00:00", "Z" "+00:00", "Z"
), ),
"creator": str(parent_sibling.creator.id), "creator": str(parent_sibling.creator.id),
"depth": 3, "depth": 3,
"deleted_at": None,
"excerpt": parent_sibling.excerpt, "excerpt": parent_sibling.excerpt,
"id": str(parent_sibling.id), "id": str(parent_sibling.id),
"is_favorite": False, "is_favorite": False,
@@ -572,12 +675,15 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
"updated_at": parent_sibling.updated_at.isoformat().replace( "updated_at": parent_sibling.updated_at.isoformat().replace(
"+00:00", "Z" "+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"), "created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_parent.creator.id), "creator": str(grand_parent.creator.id),
"depth": 2, "depth": 2,
"deleted_at": None,
"excerpt": grand_parent.excerpt, "excerpt": grand_parent.excerpt,
"id": str(grand_parent.id), "id": str(grand_parent.id),
"is_favorite": False, "is_favorite": False,
@@ -589,7 +695,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
"path": grand_parent.path, "path": grand_parent.path,
"title": grand_parent.title, "title": grand_parent.title,
"updated_at": grand_parent.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": grand_parent.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [], "user_role": None,
} }
@@ -639,18 +745,27 @@ def test_api_documents_tree_list_authenticated_related_direct():
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == { assert response.json() == {
"abilities": parent.get_abilities(user), "abilities": parent.get_abilities(user),
"ancestors_link_reach": parent.ancestors_link_reach,
"ancestors_link_role": parent.ancestors_link_role,
"children": [ "children": [
{ {
"abilities": document.get_abilities(user), "abilities": document.get_abilities(user),
"ancestors_link_reach": document.ancestors_link_reach,
"ancestors_link_role": document.ancestors_link_role,
"children": [ "children": [
{ {
"abilities": child.get_abilities(user), "abilities": child.get_abilities(user),
"ancestors_link_reach": child.ancestors_link_reach,
"ancestors_link_role": child.ancestors_link_role,
"children": [], "children": [],
"computed_link_reach": child.computed_link_reach,
"computed_link_role": child.computed_link_role,
"created_at": child.created_at.isoformat().replace( "created_at": child.created_at.isoformat().replace(
"+00:00", "Z" "+00:00", "Z"
), ),
"creator": str(child.creator.id), "creator": str(child.creator.id),
"depth": 3, "depth": 3,
"deleted_at": None,
"excerpt": child.excerpt, "excerpt": child.excerpt,
"id": str(child.id), "id": str(child.id),
"is_favorite": False, "is_favorite": False,
@@ -664,12 +779,15 @@ def test_api_documents_tree_list_authenticated_related_direct():
"updated_at": child.updated_at.isoformat().replace( "updated_at": child.updated_at.isoformat().replace(
"+00:00", "Z" "+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"), "created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id), "creator": str(document.creator.id),
"depth": 2, "depth": 2,
"deleted_at": None,
"excerpt": document.excerpt, "excerpt": document.excerpt,
"id": str(document.id), "id": str(document.id),
"is_favorite": False, "is_favorite": False,
@@ -681,14 +799,19 @@ def test_api_documents_tree_list_authenticated_related_direct():
"path": document.path, "path": document.path,
"title": document.title, "title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role], "user_role": access.role,
}, },
{ {
"abilities": sibling.get_abilities(user), "abilities": sibling.get_abilities(user),
"ancestors_link_reach": sibling.ancestors_link_reach,
"ancestors_link_role": sibling.ancestors_link_role,
"children": [], "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"), "created_at": sibling.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(sibling.creator.id), "creator": str(sibling.creator.id),
"depth": 2, "depth": 2,
"deleted_at": None,
"excerpt": sibling.excerpt, "excerpt": sibling.excerpt,
"id": str(sibling.id), "id": str(sibling.id),
"is_favorite": False, "is_favorite": False,
@@ -700,12 +823,15 @@ def test_api_documents_tree_list_authenticated_related_direct():
"path": sibling.path, "path": sibling.path,
"title": sibling.title, "title": sibling.title,
"updated_at": sibling.updated_at.isoformat().replace("+00:00", "Z"), "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"), "created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(parent.creator.id), "creator": str(parent.creator.id),
"depth": 1, "depth": 1,
"deleted_at": None,
"excerpt": parent.excerpt, "excerpt": parent.excerpt,
"id": str(parent.id), "id": str(parent.id),
"is_favorite": False, "is_favorite": False,
@@ -717,7 +843,7 @@ def test_api_documents_tree_list_authenticated_related_direct():
"path": parent.path, "path": parent.path,
"title": parent.title, "title": parent.title,
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role], "user_role": access.role,
} }
@@ -762,21 +888,32 @@ def test_api_documents_tree_list_authenticated_related_parent():
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == { assert response.json() == {
"abilities": grand_parent.get_abilities(user), "abilities": grand_parent.get_abilities(user),
"ancestors_link_reach": grand_parent.ancestors_link_reach,
"ancestors_link_role": grand_parent.ancestors_link_role,
"children": [ "children": [
{ {
"abilities": parent.get_abilities(user), "abilities": parent.get_abilities(user),
"ancestors_link_reach": parent.ancestors_link_reach,
"ancestors_link_role": parent.ancestors_link_role,
"children": [ "children": [
{ {
"abilities": document.get_abilities(user), "abilities": document.get_abilities(user),
"ancestors_link_reach": document.ancestors_link_reach,
"ancestors_link_role": document.ancestors_link_role,
"children": [ "children": [
{ {
"abilities": child.get_abilities(user), "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": [], "children": [],
"computed_link_role": child.computed_link_role,
"created_at": child.created_at.isoformat().replace( "created_at": child.created_at.isoformat().replace(
"+00:00", "Z" "+00:00", "Z"
), ),
"creator": str(child.creator.id), "creator": str(child.creator.id),
"depth": 5, "depth": 5,
"deleted_at": None,
"excerpt": child.excerpt, "excerpt": child.excerpt,
"id": str(child.id), "id": str(child.id),
"is_favorite": False, "is_favorite": False,
@@ -790,14 +927,17 @@ def test_api_documents_tree_list_authenticated_related_parent():
"updated_at": child.updated_at.isoformat().replace( "updated_at": child.updated_at.isoformat().replace(
"+00:00", "Z" "+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( "created_at": document.created_at.isoformat().replace(
"+00:00", "Z" "+00:00", "Z"
), ),
"creator": str(document.creator.id), "creator": str(document.creator.id),
"depth": 4, "depth": 4,
"deleted_at": None,
"excerpt": document.excerpt, "excerpt": document.excerpt,
"id": str(document.id), "id": str(document.id),
"is_favorite": False, "is_favorite": False,
@@ -811,16 +951,21 @@ def test_api_documents_tree_list_authenticated_related_parent():
"updated_at": document.updated_at.isoformat().replace( "updated_at": document.updated_at.isoformat().replace(
"+00:00", "Z" "+00:00", "Z"
), ),
"user_roles": [access.role], "user_role": access.role,
}, },
{ {
"abilities": document_sibling.get_abilities(user), "abilities": document_sibling.get_abilities(user),
"ancestors_link_reach": document_sibling.ancestors_link_reach,
"ancestors_link_role": document_sibling.ancestors_link_role,
"children": [], "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( "created_at": document_sibling.created_at.isoformat().replace(
"+00:00", "Z" "+00:00", "Z"
), ),
"creator": str(document_sibling.creator.id), "creator": str(document_sibling.creator.id),
"depth": 4, "depth": 4,
"deleted_at": None,
"excerpt": document_sibling.excerpt, "excerpt": document_sibling.excerpt,
"id": str(document_sibling.id), "id": str(document_sibling.id),
"is_favorite": False, "is_favorite": False,
@@ -834,12 +979,15 @@ def test_api_documents_tree_list_authenticated_related_parent():
"updated_at": document_sibling.updated_at.isoformat().replace( "updated_at": document_sibling.updated_at.isoformat().replace(
"+00:00", "Z" "+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"), "created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(parent.creator.id), "creator": str(parent.creator.id),
"depth": 3, "depth": 3,
"deleted_at": None,
"excerpt": parent.excerpt, "excerpt": parent.excerpt,
"id": str(parent.id), "id": str(parent.id),
"is_favorite": False, "is_favorite": False,
@@ -851,16 +999,21 @@ def test_api_documents_tree_list_authenticated_related_parent():
"path": parent.path, "path": parent.path,
"title": parent.title, "title": parent.title,
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role], "user_role": access.role,
}, },
{ {
"abilities": parent_sibling.get_abilities(user), "abilities": parent_sibling.get_abilities(user),
"ancestors_link_reach": parent_sibling.ancestors_link_reach,
"ancestors_link_role": parent_sibling.ancestors_link_role,
"children": [], "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( "created_at": parent_sibling.created_at.isoformat().replace(
"+00:00", "Z" "+00:00", "Z"
), ),
"creator": str(parent_sibling.creator.id), "creator": str(parent_sibling.creator.id),
"depth": 3, "depth": 3,
"deleted_at": None,
"excerpt": parent_sibling.excerpt, "excerpt": parent_sibling.excerpt,
"id": str(parent_sibling.id), "id": str(parent_sibling.id),
"is_favorite": False, "is_favorite": False,
@@ -874,12 +1027,15 @@ def test_api_documents_tree_list_authenticated_related_parent():
"updated_at": parent_sibling.updated_at.isoformat().replace( "updated_at": parent_sibling.updated_at.isoformat().replace(
"+00:00", "Z" "+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"), "created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_parent.creator.id), "creator": str(grand_parent.creator.id),
"depth": 2, "depth": 2,
"deleted_at": None,
"excerpt": grand_parent.excerpt, "excerpt": grand_parent.excerpt,
"id": str(grand_parent.id), "id": str(grand_parent.id),
"is_favorite": False, "is_favorite": False,
@@ -891,7 +1047,7 @@ def test_api_documents_tree_list_authenticated_related_parent():
"path": grand_parent.path, "path": grand_parent.path,
"title": grand_parent.title, "title": grand_parent.title,
"updated_at": grand_parent.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": grand_parent.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role], "user_role": access.role,
} }
@@ -949,18 +1105,27 @@ def test_api_documents_tree_list_authenticated_related_team_members(
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == { assert response.json() == {
"abilities": parent.get_abilities(user), "abilities": parent.get_abilities(user),
"ancestors_link_reach": None,
"ancestors_link_role": None,
"children": [ "children": [
{ {
"abilities": document.get_abilities(user), "abilities": document.get_abilities(user),
"ancestors_link_reach": "restricted",
"ancestors_link_role": None,
"children": [ "children": [
{ {
"abilities": child.get_abilities(user), "abilities": child.get_abilities(user),
"ancestors_link_reach": "restricted",
"ancestors_link_role": None,
"children": [], "children": [],
"computed_link_reach": child.computed_link_reach,
"computed_link_role": child.computed_link_role,
"created_at": child.created_at.isoformat().replace( "created_at": child.created_at.isoformat().replace(
"+00:00", "Z" "+00:00", "Z"
), ),
"creator": str(child.creator.id), "creator": str(child.creator.id),
"depth": 3, "depth": 3,
"deleted_at": None,
"excerpt": child.excerpt, "excerpt": child.excerpt,
"id": str(child.id), "id": str(child.id),
"is_favorite": False, "is_favorite": False,
@@ -974,12 +1139,15 @@ def test_api_documents_tree_list_authenticated_related_team_members(
"updated_at": child.updated_at.isoformat().replace( "updated_at": child.updated_at.isoformat().replace(
"+00:00", "Z" "+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"), "created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id), "creator": str(document.creator.id),
"depth": 2, "depth": 2,
"deleted_at": None,
"excerpt": document.excerpt, "excerpt": document.excerpt,
"id": str(document.id), "id": str(document.id),
"is_favorite": False, "is_favorite": False,
@@ -991,14 +1159,19 @@ def test_api_documents_tree_list_authenticated_related_team_members(
"path": document.path, "path": document.path,
"title": document.title, "title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role], "user_role": access.role,
}, },
{ {
"abilities": sibling.get_abilities(user), "abilities": sibling.get_abilities(user),
"ancestors_link_reach": "restricted",
"ancestors_link_role": None,
"children": [], "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"), "created_at": sibling.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(sibling.creator.id), "creator": str(sibling.creator.id),
"depth": 2, "depth": 2,
"deleted_at": None,
"excerpt": sibling.excerpt, "excerpt": sibling.excerpt,
"id": str(sibling.id), "id": str(sibling.id),
"is_favorite": False, "is_favorite": False,
@@ -1010,12 +1183,15 @@ def test_api_documents_tree_list_authenticated_related_team_members(
"path": sibling.path, "path": sibling.path,
"title": sibling.title, "title": sibling.title,
"updated_at": sibling.updated_at.isoformat().replace("+00:00", "Z"), "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"), "created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(parent.creator.id), "creator": str(parent.creator.id),
"depth": 1, "depth": 1,
"deleted_at": None,
"excerpt": parent.excerpt, "excerpt": parent.excerpt,
"id": str(parent.id), "id": str(parent.id),
"is_favorite": False, "is_favorite": False,
@@ -1027,5 +1203,58 @@ def test_api_documents_tree_list_authenticated_related_team_members(
"path": parent.path, "path": parent.path,
"title": parent.title, "title": parent.title,
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"), "updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role], "user_role": access.role,
} }
def test_api_documents_tree_list_deleted_document():
"""
Tree of a deleted document should only be accessible to the owner.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
parent = factories.DocumentFactory(link_reach="public")
document, _ = factories.DocumentFactory.create_batch(2, parent=parent)
factories.DocumentFactory(link_reach="public", parent=document)
document.soft_delete()
response = client.get(f"/api/v1.0/documents/{document.id!s}/tree/")
assert response.status_code == 403
def test_api_documents_tree_list_deleted_document_owner(django_assert_num_queries):
"""
Tree of a deleted document should only be accessible to the owner.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
parent = factories.DocumentFactory(link_reach="public", users=[(user, "owner")])
document, _ = factories.DocumentFactory.create_batch(2, parent=parent)
child = factories.DocumentFactory(parent=document)
document.soft_delete()
document.refresh_from_db()
child.refresh_from_db()
with django_assert_num_queries(9):
client.get(f"/api/v1.0/documents/{document.id!s}/tree/")
with django_assert_num_queries(5):
response = client.get(f"/api/v1.0/documents/{document.id!s}/tree/")
assert response.status_code == 200
content = response.json()
assert content["id"] == str(document.id)
assert content["deleted_at"] == document.deleted_at.isoformat().replace(
"+00:00", "Z"
)
assert len(content["children"]) == 1
assert content["children"][0]["id"] == str(child.id)
assert content["children"][0][
"deleted_at"
] == child.ancestors_deleted_at.isoformat().replace("+00:00", "Z")

View File

@@ -5,8 +5,10 @@ Tests for Documents API endpoint in impress's core app: update
import random import random
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.core.cache import cache
import pytest import pytest
import responses
from rest_framework.test import APIClient from rest_framework.test import APIClient
from core import factories, models from core import factories, models
@@ -44,6 +46,7 @@ def test_api_documents_update_anonymous_forbidden(reach, role, via_parent):
new_document_values = serializers.DocumentSerializer( new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory() instance=factories.DocumentFactory()
).data ).data
new_document_values["websocket"] = True
response = APIClient().put( response = APIClient().put(
f"/api/v1.0/documents/{document.id!s}/", f"/api/v1.0/documents/{document.id!s}/",
new_document_values, new_document_values,
@@ -90,8 +93,9 @@ def test_api_documents_update_authenticated_unrelated_forbidden(
old_document_values = serializers.DocumentSerializer(instance=document).data old_document_values = serializers.DocumentSerializer(instance=document).data
new_document_values = serializers.DocumentSerializer( new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory() instance=factories.DocumentFactory(),
).data ).data
new_document_values["websocket"] = True
response = client.put( response = client.put(
f"/api/v1.0/documents/{document.id!s}/", f"/api/v1.0/documents/{document.id!s}/",
new_document_values, new_document_values,
@@ -141,8 +145,9 @@ def test_api_documents_update_anonymous_or_authenticated_unrelated(
old_document_values = serializers.DocumentSerializer(instance=document).data old_document_values = serializers.DocumentSerializer(instance=document).data
new_document_values = serializers.DocumentSerializer( new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory() instance=factories.DocumentFactory(),
).data ).data
new_document_values["websocket"] = True
response = client.put( response = client.put(
f"/api/v1.0/documents/{document.id!s}/", f"/api/v1.0/documents/{document.id!s}/",
new_document_values, new_document_values,
@@ -155,6 +160,10 @@ def test_api_documents_update_anonymous_or_authenticated_unrelated(
for key, value in document_values.items(): for key, value in document_values.items():
if key in [ if key in [
"id", "id",
"ancestors_link_reach",
"ancestors_link_role",
"computed_link_reach",
"computed_link_role",
"accesses", "accesses",
"created_at", "created_at",
"creator", "creator",
@@ -206,6 +215,7 @@ def test_api_documents_update_authenticated_reader(via, via_parent, mock_user_te
new_document_values = serializers.DocumentSerializer( new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory() instance=factories.DocumentFactory()
).data ).data
new_document_values["websocket"] = True
response = client.put( response = client.put(
f"/api/v1.0/documents/{document.id!s}/", f"/api/v1.0/documents/{document.id!s}/",
new_document_values, new_document_values,
@@ -258,6 +268,7 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
new_document_values = serializers.DocumentSerializer( new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory() instance=factories.DocumentFactory()
).data ).data
new_document_values["websocket"] = True
response = client.put( response = client.put(
f"/api/v1.0/documents/{document.id!s}/", f"/api/v1.0/documents/{document.id!s}/",
new_document_values, new_document_values,
@@ -270,6 +281,10 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
for key, value in document_values.items(): for key, value in document_values.items():
if key in [ if key in [
"id", "id",
"ancestors_link_reach",
"ancestors_link_role",
"computed_link_reach",
"computed_link_role",
"created_at", "created_at",
"creator", "creator",
"depth", "depth",
@@ -287,6 +302,359 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
assert value == new_document_values[key] assert value == new_document_values[key]
@responses.activate
def test_api_documents_update_authenticated_no_websocket(settings):
"""
When a user updates the document, not connected to the websocket and is the first to update,
the document should be updated.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = False
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
assert cache.get(f"docs:no-websocket:{document.id}") is None
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
format="json",
)
assert response.status_code == 200
assert cache.get(f"docs:no-websocket:{document.id}") == session_key
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_update_authenticated_no_websocket_user_already_editing(settings):
"""
When a user updates the document, not connected to the websocket and is not the first to update,
the document should not be updated.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = False
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
format="json",
)
assert response.status_code == 403
assert response.json() == {"detail": "You are not allowed to edit this document."}
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_update_no_websocket_other_user_connected_to_websocket(settings):
"""
When a user updates the document, not connected to the websocket and another user is connected
to the websocket, the document should not be updated.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = False
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": False})
assert cache.get(f"docs:no-websocket:{document.id}") is None
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
format="json",
)
assert response.status_code == 403
assert response.json() == {"detail": "You are not allowed to edit this document."}
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_update_user_connected_to_websocket(settings):
"""
When a user updates the document, connected to the websocket, the document should be updated.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = False
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": True})
assert cache.get(f"docs:no-websocket:{document.id}") is None
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
format="json",
)
assert response.status_code == 200
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_update_websocket_server_unreachable_fallback_to_no_websocket(
settings,
):
"""
When the websocket server is unreachable, the document should be updated like if the user was
not connected to the websocket.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = False
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
assert cache.get(f"docs:no-websocket:{document.id}") is None
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
format="json",
)
assert response.status_code == 200
assert cache.get(f"docs:no-websocket:{document.id}") == session_key
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_update_websocket_server_unreachable_fallback_to_no_websocket_other_users(
settings,
):
"""
When the websocket server is unreachable, the behavior fallback to the no websocket one.
If an other user is already editing, the document should not be updated.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = False
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
format="json",
)
assert response.status_code == 403
assert cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_update_websocket_server_room_not_found_fallback_to_no_websocket_other_users(
settings,
):
"""
When the WebSocket server does not have the room created, the logic should fallback to
no-WebSocket. If another user is already editing, the update must be denied.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = False
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=404)
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
format="json",
)
assert response.status_code == 403
assert cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_update_force_websocket_param_to_true(settings):
"""
When the websocket parameter is set to true, the document should be updated without any check.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = True
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
assert cache.get(f"docs:no-websocket:{document.id}") is None
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
format="json",
)
assert response.status_code == 200
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 0
@responses.activate
def test_api_documents_update_feature_flag_disabled(settings):
"""
When the feature flag is disabled, the document should be updated without any check.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = False
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = False
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
assert cache.get(f"docs:no-websocket:{document.id}") is None
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
format="json",
)
assert response.status_code == 200
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 0
@pytest.mark.parametrize("via", VIA) @pytest.mark.parametrize("via", VIA)
def test_api_documents_update_administrator_or_owner_of_another(via, mock_user_teams): def test_api_documents_update_administrator_or_owner_of_another(via, mock_user_teams):
""" """
@@ -317,6 +685,7 @@ def test_api_documents_update_administrator_or_owner_of_another(via, mock_user_t
new_document_values = serializers.DocumentSerializer( new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory() instance=factories.DocumentFactory()
).data ).data
new_document_values["websocket"] = True
response = client.put( response = client.put(
f"/api/v1.0/documents/{other_document.id!s}/", f"/api/v1.0/documents/{other_document.id!s}/",
new_document_values, new_document_values,

View File

@@ -50,7 +50,7 @@ def test_api_documents_update_new_attachment_keys_anonymous(django_assert_num_qu
with django_assert_num_queries(11): with django_assert_num_queries(11):
response = APIClient().put( response = APIClient().put(
f"/api/v1.0/documents/{document.id!s}/", f"/api/v1.0/documents/{document.id!s}/",
{"content": get_ydoc_with_mages(image_keys)}, {"content": get_ydoc_with_mages(image_keys), "websocket": True},
format="json", format="json",
) )
assert response.status_code == 200 assert response.status_code == 200
@@ -63,7 +63,7 @@ def test_api_documents_update_new_attachment_keys_anonymous(django_assert_num_qu
with django_assert_num_queries(7): with django_assert_num_queries(7):
response = APIClient().put( response = APIClient().put(
f"/api/v1.0/documents/{document.id!s}/", f"/api/v1.0/documents/{document.id!s}/",
{"content": get_ydoc_with_mages(image_keys[:2])}, {"content": get_ydoc_with_mages(image_keys[:2]), "websocket": True},
format="json", format="json",
) )
assert response.status_code == 200 assert response.status_code == 200

View File

@@ -0,0 +1,44 @@
"""Test user light serializer."""
import pytest
from core import factories
from core.api.serializers import UserLightSerializer
pytestmark = pytest.mark.django_db
def test_user_light_serializer():
"""Test user light serializer."""
user = factories.UserFactory(
email="test@test.com",
full_name="John Doe",
short_name="John",
)
serializer = UserLightSerializer(user)
assert serializer.data["full_name"] == "John Doe"
assert serializer.data["short_name"] == "John"
def test_user_light_serializer_no_full_name():
"""Test user light serializer without full name."""
user = factories.UserFactory(
email="test_foo@test.com",
full_name=None,
short_name="John",
)
serializer = UserLightSerializer(user)
assert serializer.data["full_name"] == "test_foo"
assert serializer.data["short_name"] == "John"
def test_user_light_serializer_no_short_name():
"""Test user light serializer without short name."""
user = factories.UserFactory(
email="test_foo@test.com",
full_name=None,
short_name=None,
)
serializer = UserLightSerializer(user)
assert serializer.data["full_name"] == "test_foo"
assert serializer.data["short_name"] == "test_foo"

View File

@@ -1,780 +0,0 @@
"""
Test template accesses API endpoints for users in impress's core app.
"""
import random
from uuid import uuid4
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.api import serializers
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
def test_api_template_accesses_list_anonymous():
"""Anonymous users should not be allowed to list template accesses."""
template = factories.TemplateFactory()
factories.UserTemplateAccessFactory.create_batch(2, template=template)
response = APIClient().get(f"/api/v1.0/templates/{template.id!s}/accesses/")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_template_accesses_list_authenticated_unrelated():
"""
Authenticated users should not be allowed to list template accesses for a template
to which they are not related.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
factories.UserTemplateAccessFactory.create_batch(3, template=template)
# Accesses for other templates to which the user is related should not be listed either
other_access = factories.UserTemplateAccessFactory(user=user)
factories.UserTemplateAccessFactory(template=other_access.template)
response = client.get(
f"/api/v1.0/templates/{template.id!s}/accesses/",
)
assert response.status_code == 200
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_list_authenticated_related(via, mock_user_teams):
"""
Authenticated users should be able to list template accesses for a template
to which they are directly related, whatever their role in the template.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
user_access = None
if via == USER:
user_access = models.TemplateAccess.objects.create(
template=template,
user=user,
role=random.choice(models.RoleChoices.values),
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
user_access = models.TemplateAccess.objects.create(
template=template,
team="lasuite",
role=random.choice(models.RoleChoices.values),
)
access1 = factories.TeamTemplateAccessFactory(template=template)
access2 = factories.UserTemplateAccessFactory(template=template)
# Accesses for other templates to which the user is related should not be listed either
other_access = factories.UserTemplateAccessFactory(user=user)
factories.UserTemplateAccessFactory(template=other_access.template)
response = client.get(
f"/api/v1.0/templates/{template.id!s}/accesses/",
)
assert response.status_code == 200
content = response.json()
assert len(content["results"]) == 3
assert sorted(content["results"], key=lambda x: x["id"]) == sorted(
[
{
"id": str(user_access.id),
"user": str(user.id) 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": str(access2.user.id),
"team": "",
"role": access2.role,
"abilities": access2.get_abilities(user),
},
],
key=lambda x: x["id"],
)
def test_api_template_accesses_retrieve_anonymous():
"""
Anonymous users should not be allowed to retrieve a template access.
"""
access = factories.UserTemplateAccessFactory()
response = APIClient().get(
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_template_accesses_retrieve_authenticated_unrelated():
"""
Authenticated users should not be allowed to retrieve a template access for
a template to which they are not related.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
access = factories.UserTemplateAccessFactory(template=template)
response = client.get(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
# Accesses related to another template should be excluded even if the user is related to it
for access in [
factories.UserTemplateAccessFactory(),
factories.UserTemplateAccessFactory(user=user),
]:
response = client.get(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 404
assert response.json() == {
"detail": "No TemplateAccess matches the given query."
}
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_retrieve_authenticated_related(via, mock_user_teams):
"""
A user who is related to a template should be allowed to retrieve the
associated template user accesses.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(template=template, team="lasuite")
access = factories.UserTemplateAccessFactory(template=template)
response = client.get(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 200
assert response.json() == {
"id": str(access.id),
"user": str(access.user.id),
"team": "",
"role": access.role,
"abilities": access.get_abilities(user),
}
def test_api_template_accesses_update_anonymous():
"""Anonymous users should not be allowed to update a template access."""
access = factories.UserTemplateAccessFactory()
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.values),
}
api_client = APIClient()
for field, value in new_values.items():
response = api_client.put(
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
)
assert response.status_code == 401
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
assert updated_values == old_values
def test_api_template_accesses_update_authenticated_unrelated():
"""
Authenticated users should not be allowed to update a template access for a template to which
they are not related.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
access = factories.UserTemplateAccessFactory()
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.values),
}
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
assert updated_values == old_values
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_authenticated_editor_or_reader(
via, role, mock_user_teams
):
"""Editors or readers of a template should not be allowed to update its accesses."""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
)
access = factories.UserTemplateAccessFactory(template=template)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.values),
}
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
{**old_values, field: value},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
assert updated_values == old_values
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_administrator_except_owner(via, mock_user_teams):
"""
A user who is a direct administrator in a template should be allowed to update a user
access for this template, as long as they don't try to set the role to owner.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
access = factories.UserTemplateAccessFactory(
template=template,
role=random.choice(["administrator", "editor", "reader"]),
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(["administrator", "editor", "reader"]),
}
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
if (
new_data["role"] == old_values["role"]
): # we are not really updating the role
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
if field == "role":
assert updated_values == {**old_values, "role": new_values["role"]}
else:
assert updated_values == old_values
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_administrator_from_owner(via, mock_user_teams):
"""
A user who is an administrator in a template, should not be allowed to update
the user access of an "owner" for this template.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
other_user = factories.UserFactory()
access = factories.UserTemplateAccessFactory(
template=template, user=other_user, role="owner"
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.values),
}
for field, value in new_values.items():
response = client.put(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
data={**old_values, field: value},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
assert updated_values == old_values
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_administrator_to_owner(via, mock_user_teams):
"""
A user who is an administrator in a template, should not be allowed to update
the user access of another user to grant template ownership.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
other_user = factories.UserFactory()
access = factories.UserTemplateAccessFactory(
template=template,
user=other_user,
role=random.choice(["administrator", "editor", "reader"]),
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": "owner",
}
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
# We are not allowed or not really updating the role
if field == "role" or new_data["role"] == old_values["role"]:
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
assert updated_values == old_values
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_owner(via, mock_user_teams):
"""
A user who is an owner in a template should be allowed to update
a user access for this template whatever the role.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
factories.UserFactory()
access = factories.UserTemplateAccessFactory(
template=template,
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
"role": random.choice(models.RoleChoices.values),
}
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
if (
new_data["role"] == old_values["role"]
): # we are not really updating the role
assert response.status_code == 403
else:
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.TemplateAccessSerializer(instance=access).data
if field == "role":
assert updated_values == {**old_values, "role": new_values["role"]}
else:
assert updated_values == old_values
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_owner_self(via, mock_user_teams):
"""
A user who is owner of a template should be allowed to update
their own user access provided there are other owners in the template.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
access = factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
else:
access = factories.UserTemplateAccessFactory(
template=template, user=user, role="owner"
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_role = random.choice(["administrator", "editor", "reader"])
response = client.put(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
data={**old_values, "role": new_role},
format="json",
)
assert response.status_code == 403
access.refresh_from_db()
assert access.role == "owner"
# Add another owner and it should now work
factories.UserTemplateAccessFactory(template=template, role="owner")
response = client.put(
f"/api/v1.0/templates/{template.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
def test_api_template_accesses_delete_anonymous():
"""Anonymous users should not be allowed to destroy a template access."""
access = factories.UserTemplateAccessFactory()
response = APIClient().delete(
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 401
assert models.TemplateAccess.objects.count() == 1
def test_api_template_accesses_delete_authenticated():
"""
Authenticated users should not be allowed to delete a template access for a
template to which they are not related.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
access = factories.UserTemplateAccessFactory()
response = client.delete(
f"/api/v1.0/templates/{access.template_id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TemplateAccess.objects.count() == 2
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_editor_or_reader(via, role, mock_user_teams):
"""
Authenticated users should not be allowed to delete a template access for a
template in which they are a simple editor or reader.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
)
access = factories.UserTemplateAccessFactory(template=template)
assert models.TemplateAccess.objects.count() == 3
assert models.TemplateAccess.objects.filter(user=access.user).exists()
response = client.delete(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TemplateAccess.objects.count() == 3
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_administrators_except_owners(
via, mock_user_teams
):
"""
Users who are administrators in a template should be allowed to delete an access
from the template provided it is not ownership.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
access = factories.UserTemplateAccessFactory(
template=template, role=random.choice(["reader", "editor", "administrator"])
)
assert models.TemplateAccess.objects.count() == 2
assert models.TemplateAccess.objects.filter(user=access.user).exists()
response = client.delete(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert models.TemplateAccess.objects.count() == 1
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_administrator_on_owners(via, mock_user_teams):
"""
Users who are administrators in a template should not be allowed to delete an ownership
access from the template.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
access = factories.UserTemplateAccessFactory(template=template, role="owner")
assert models.TemplateAccess.objects.count() == 3
assert models.TemplateAccess.objects.filter(user=access.user).exists()
response = client.delete(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TemplateAccess.objects.count() == 3
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_owners(via, mock_user_teams):
"""
Users should be able to delete the template access of another user
for a template of which they are owner.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
access = factories.UserTemplateAccessFactory(template=template)
assert models.TemplateAccess.objects.count() == 2
assert models.TemplateAccess.objects.filter(user=access.user).exists()
response = client.delete(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert models.TemplateAccess.objects.count() == 1
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_owners_last_owner(via, mock_user_teams):
"""
It should not be possible to delete the last owner access from a template
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
access = None
if via == USER:
access = factories.UserTemplateAccessFactory(
template=template, user=user, role="owner"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
access = factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
assert models.TemplateAccess.objects.count() == 2
response = client.delete(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TemplateAccess.objects.count() == 2

View File

@@ -1,206 +0,0 @@
"""
Test template accesses create API endpoint for users in impress's core app.
"""
import random
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
def test_api_template_accesses_create_anonymous():
"""Anonymous users should not be allowed to create template accesses."""
template = factories.TemplateFactory()
other_user = factories.UserFactory()
response = APIClient().post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"template": str(template.id),
"role": random.choice(models.RoleChoices.values),
},
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert models.TemplateAccess.objects.exists() is False
def test_api_template_accesses_create_authenticated_unrelated():
"""
Authenticated users should not be allowed to create template accesses for a template to
which they are not related.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
template = factories.TemplateFactory()
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
},
format="json",
)
assert response.status_code == 403
assert not models.TemplateAccess.objects.filter(user=other_user).exists()
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_create_authenticated_editor_or_reader(
via, role, mock_user_teams
):
"""Editors or readers of a template should not be allowed to create template accesses."""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
)
other_user = factories.UserFactory()
for new_role in [role[0] for role in models.RoleChoices.choices]:
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"role": new_role,
},
format="json",
)
assert response.status_code == 403
assert not models.TemplateAccess.objects.filter(user=other_user).exists()
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_create_authenticated_administrator(via, mock_user_teams):
"""
Administrators of a template should be able to create template accesses
except for the "owner" role.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
other_user = factories.UserFactory()
# It should not be allowed to create an owner access
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"role": "owner",
},
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "Only owners of a resource 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"]
)
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.TemplateAccess.objects.filter(user=other_user).count() == 1
new_template_access = models.TemplateAccess.objects.filter(user=other_user).get()
assert response.json() == {
"abilities": new_template_access.get_abilities(user),
"id": str(new_template_access.id),
"team": "",
"role": role,
"user": str(other_user.id),
}
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_create_authenticated_owner(via, mock_user_teams):
"""
Owners of a template should be able to create template accesses whatever the role.
"""
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
other_user = factories.UserFactory()
role = random.choice([role[0] for role in models.RoleChoices.choices])
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.TemplateAccess.objects.filter(user=other_user).count() == 1
new_template_access = models.TemplateAccess.objects.filter(user=other_user).get()
assert response.json() == {
"id": str(new_template_access.id),
"user": str(other_user.id),
"team": "",
"role": role,
"abilities": new_template_access.get_abilities(user),
}

View File

@@ -42,7 +42,5 @@ def test_api_templates_create_authenticated():
format="json", format="json",
) )
assert response.status_code == 201 assert response.status_code == 405
template = Template.objects.get() assert not Template.objects.exists()
assert template.title == "my template"
assert template.accesses.filter(role="owner", user=user).exists()

View File

@@ -8,7 +8,6 @@ import pytest
from rest_framework.test import APIClient from rest_framework.test import APIClient
from core import factories, models from core import factories, models
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@@ -25,7 +24,7 @@ def test_api_templates_delete_anonymous():
assert models.Template.objects.count() == 1 assert models.Template.objects.count() == 1
def test_api_templates_delete_authenticated_unrelated(): def test_api_templates_delete_not_implemented():
""" """
Authenticated users should not be allowed to delete a template to which they are not Authenticated users should not be allowed to delete a template to which they are not
related. related.
@@ -36,72 +35,11 @@ def test_api_templates_delete_authenticated_unrelated():
client.force_login(user) client.force_login(user)
is_public = random.choice([True, False]) is_public = random.choice([True, False])
template = factories.TemplateFactory(is_public=is_public) template = factories.TemplateFactory(is_public=is_public, users=[(user, "owner")])
response = client.delete( response = client.delete(
f"/api/v1.0/templates/{template.id!s}/", f"/api/v1.0/templates/{template.id!s}/",
) )
assert response.status_code == 403 if is_public else 404 assert response.status_code == 405
assert models.Template.objects.count() == 1 assert models.Template.objects.count() == 1
@pytest.mark.parametrize("role", ["reader", "editor", "administrator"])
@pytest.mark.parametrize("via", VIA)
def test_api_templates_delete_authenticated_member_or_administrator(
via, role, mock_user_teams
):
"""
Authenticated users should not be allowed to delete a template for which they are
only a member or administrator.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
)
response = client.delete(
f"/api/v1.0/templates/{template.id}/",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert models.Template.objects.count() == 1
@pytest.mark.parametrize("via", VIA)
def test_api_templates_delete_authenticated_owner(via, mock_user_teams):
"""
Authenticated users should be able to delete a template they own.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
response = client.delete(
f"/api/v1.0/templates/{template.id}/",
)
assert response.status_code == 204
assert models.Template.objects.exists() is False

View File

@@ -218,3 +218,20 @@ def test_api_templates_list_order_param():
assert response_template_ids == templates_ids, ( assert response_template_ids == templates_ids, (
"created_at values are not sorted from oldest to newest" "created_at values are not sorted from oldest to newest"
) )
def test_api_template_throttling(settings):
"""Test api template throttling."""
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["template"]
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["template"] = "2/minute"
client = APIClient()
for _i in range(2):
response = client.get("/api/v1.0/templates/")
assert response.status_code == 200
with mock.patch("core.api.throttling.capture_message") as mock_capture_message:
response = client.get("/api/v1.0/templates/")
assert response.status_code == 429
mock_capture_message.assert_called_once_with(
"Rate limit exceeded for scope template", "warning"
)
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["template"] = current_rate

View File

@@ -2,14 +2,11 @@
Tests for Templates API endpoint in impress's core app: update Tests for Templates API endpoint in impress's core app: update
""" """
import random
import pytest import pytest
from rest_framework.test import APIClient from rest_framework.test import APIClient
from core import factories from core import factories
from core.api import serializers from core.api import serializers
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@@ -17,7 +14,6 @@ pytestmark = pytest.mark.django_db
def test_api_templates_update_anonymous(): def test_api_templates_update_anonymous():
"""Anonymous users should not be allowed to update a template.""" """Anonymous users should not be allowed to update a template."""
template = factories.TemplateFactory() template = factories.TemplateFactory()
old_template_values = serializers.TemplateSerializer(instance=template).data
new_template_values = serializers.TemplateSerializer( new_template_values = serializers.TemplateSerializer(
instance=factories.TemplateFactory() instance=factories.TemplateFactory()
@@ -28,145 +24,18 @@ def test_api_templates_update_anonymous():
format="json", format="json",
) )
assert response.status_code == 401 assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
template.refresh_from_db()
template_values = serializers.TemplateSerializer(instance=template).data
assert template_values == old_template_values
def test_api_templates_update_authenticated_unrelated(): def test_api_templates_update_not_implemented():
""" """
Authenticated users should not be allowed to update a template to which they are not related. Authenticated users should not be allowed to update a template.
""" """
user = factories.UserFactory() user = factories.UserFactory()
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
template = factories.TemplateFactory(is_public=False) template = factories.TemplateFactory(users=[(user, "owner")])
old_template_values = serializers.TemplateSerializer(instance=template).data
new_template_values = serializers.TemplateSerializer(
instance=factories.TemplateFactory()
).data
response = client.put(
f"/api/v1.0/templates/{template.id!s}/",
new_template_values,
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
template.refresh_from_db()
template_values = serializers.TemplateSerializer(instance=template).data
assert template_values == old_template_values
@pytest.mark.parametrize("via", VIA)
def test_api_templates_update_authenticated_readers(via, mock_user_teams):
"""
Users who are readers of a template should not be allowed to update it.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="reader")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="reader"
)
old_template_values = serializers.TemplateSerializer(instance=template).data
new_template_values = serializers.TemplateSerializer(
instance=factories.TemplateFactory()
).data
response = client.put(
f"/api/v1.0/templates/{template.id!s}/",
new_template_values,
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
template.refresh_from_db()
template_values = serializers.TemplateSerializer(instance=template).data
assert template_values == old_template_values
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
@pytest.mark.parametrize("via", VIA)
def test_api_templates_update_authenticated_editor_or_administrator_or_owner(
via, role, mock_user_teams
):
"""Administrator or owner of a template should be allowed to update it."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
)
old_template_values = serializers.TemplateSerializer(instance=template).data
new_template_values = serializers.TemplateSerializer(
instance=factories.TemplateFactory()
).data
response = client.put(
f"/api/v1.0/templates/{template.id!s}/",
new_template_values,
format="json",
)
assert response.status_code == 200
template.refresh_from_db()
template_values = serializers.TemplateSerializer(instance=template).data
for key, value in template_values.items():
if key in ["id", "accesses"]:
assert value == old_template_values[key]
else:
assert value == new_template_values[key]
@pytest.mark.parametrize("via", VIA)
def test_api_templates_update_authenticated_owners(via, mock_user_teams):
"""Administrators of a template should be allowed to update it."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
old_template_values = serializers.TemplateSerializer(instance=template).data
new_template_values = serializers.TemplateSerializer( new_template_values = serializers.TemplateSerializer(
instance=factories.TemplateFactory() instance=factories.TemplateFactory()
@@ -176,55 +45,10 @@ def test_api_templates_update_authenticated_owners(via, mock_user_teams):
f"/api/v1.0/templates/{template.id!s}/", new_template_values, format="json" f"/api/v1.0/templates/{template.id!s}/", new_template_values, format="json"
) )
assert response.status_code == 200 assert response.status_code == 405
template.refresh_from_db()
template_values = serializers.TemplateSerializer(instance=template).data
for key, value in template_values.items():
if key in ["id", "accesses"]:
assert value == old_template_values[key]
else:
assert value == new_template_values[key]
response = client.patch(
@pytest.mark.parametrize("via", VIA) f"/api/v1.0/templates/{template.id!s}/", new_template_values, format="json"
def test_api_templates_update_administrator_or_owner_of_another(via, mock_user_teams):
"""
Being administrator or owner of a template should not grant authorization to update
another template.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(
template=template, user=user, role=random.choice(["administrator", "owner"])
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template,
team="lasuite",
role=random.choice(["administrator", "owner"]),
)
is_public = random.choice([True, False])
template = factories.TemplateFactory(title="Old title", is_public=is_public)
old_template_values = serializers.TemplateSerializer(instance=template).data
new_template_values = serializers.TemplateSerializer(
instance=factories.TemplateFactory()
).data
response = client.put(
f"/api/v1.0/templates/{template.id!s}/",
new_template_values,
format="json",
) )
assert response.status_code == 403 if is_public else 404 assert response.status_code == 405
template.refresh_from_db()
template_values = serializers.TemplateSerializer(instance=template).data
assert template_values == old_template_values

View File

@@ -3,6 +3,7 @@ Test config API endpoints in the Impress core app.
""" """
import json import json
from unittest.mock import patch
from django.test import override_settings from django.test import override_settings
@@ -41,6 +42,7 @@ def test_api_config(is_authenticated):
response = client.get("/api/v1.0/config/") response = client.get("/api/v1.0/config/")
assert response.status_code == HTTP_200_OK assert response.status_code == HTTP_200_OK
assert response.json() == { assert response.json() == {
"AI_FEATURE_ENABLED": False,
"COLLABORATION_WS_URL": "http://testcollab/", "COLLABORATION_WS_URL": "http://testcollab/",
"COLLABORATION_WS_NOT_CONNECTED_READY_ONLY": True, "COLLABORATION_WS_NOT_CONNECTED_READY_ONLY": True,
"CRISP_WEBSITE_ID": "123", "CRISP_WEBSITE_ID": "123",
@@ -59,7 +61,7 @@ def test_api_config(is_authenticated):
"MEDIA_BASE_URL": "http://testserver/", "MEDIA_BASE_URL": "http://testserver/",
"POSTHOG_KEY": {"id": "132456", "host": "https://eu.i.posthog-test.com"}, "POSTHOG_KEY": {"id": "132456", "host": "https://eu.i.posthog-test.com"},
"SENTRY_DSN": "https://sentry.test/123", "SENTRY_DSN": "https://sentry.test/123",
"AI_FEATURE_ENABLED": False, "TRASHBIN_CUTOFF_DAYS": 30,
"theme_customization": {}, "theme_customization": {},
} }
policy_list = sorted(response.headers["Content-Security-Policy"].split("; ")) policy_list = sorted(response.headers["Content-Security-Policy"].split("; "))
@@ -174,3 +176,20 @@ def test_api_config_with_original_theme_customization(is_authenticated, settings
theme_customization = json.load(f) theme_customization = json.load(f)
assert content["theme_customization"] == theme_customization assert content["theme_customization"] == theme_customization
def test_api_config_throttling(settings):
"""Test api config throttling."""
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["config"]
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["config"] = "2/minute"
client = APIClient()
for _i in range(2):
response = client.get("/api/v1.0/config/")
assert response.status_code == 200
with patch("core.api.throttling.capture_message") as mock_capture_message:
response = client.get("/api/v1.0/config/")
assert response.status_code == 429
mock_capture_message.assert_called_once_with(
"Rate limit exceeded for scope config", "warning"
)
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["config"] = current_rate

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. Queries shorter than 5 characters should return an empty result set.
""" """
user = factories.UserFactory() user = factories.UserFactory(email="paul@example.com")
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
@@ -194,18 +194,41 @@ def test_api_users_list_query_short_queries():
factories.UserFactory(email="john.lennon@example.com") factories.UserFactory(email="john.lennon@example.com")
response = client.get("/api/v1.0/users/?q=jo") response = client.get("/api/v1.0/users/?q=jo")
assert response.status_code == 200 assert response.status_code == 400
assert response.json() == [] assert response.json() == {
"q": ["Ensure this value has at least 5 characters (it has 2)."]
}
response = client.get("/api/v1.0/users/?q=john") response = client.get("/api/v1.0/users/?q=john")
assert response.status_code == 200 assert response.status_code == 400
assert response.json() == [] assert response.json() == {
"q": ["Ensure this value has at least 5 characters (it has 4)."]
}
response = client.get("/api/v1.0/users/?q=john.") response = client.get("/api/v1.0/users/?q=john.")
assert response.status_code == 200 assert response.status_code == 200
assert len(response.json()) == 2 assert len(response.json()) == 2
def test_api_users_list_query_long_queries():
"""
Queries longer than 255 characters should return an empty result set.
"""
user = factories.UserFactory(email="paul@example.com")
client = APIClient()
client.force_login(user)
factories.UserFactory(email="john.doe@example.com")
factories.UserFactory(email="john.lennon@example.com")
query = "a" * 244
response = client.get(f"/api/v1.0/users/?q={query}@example.com")
assert response.status_code == 400
assert response.json() == {
"q": ["Ensure this value has at most 254 characters (it has 256)."]
}
def test_api_users_list_query_inactive(): def test_api_users_list_query_inactive():
"""Inactive users should not be listed.""" """Inactive users should not be listed."""
user = factories.UserFactory() user = factories.UserFactory()
@@ -255,6 +278,35 @@ def test_api_users_retrieve_me_authenticated():
} }
def test_api_users_retrieve_me_authenticated_empty_name():
"""
Authenticated users should be able to retrieve their own user via the "/users/me" path.
when no name is provided, the full name and short name should be the email without the domain.
"""
user = factories.UserFactory(
email="test_foo@test.com",
full_name=None,
short_name=None,
)
client = APIClient()
client.force_login(user)
factories.UserFactory.create_batch(2)
response = client.get(
"/api/v1.0/users/me/",
)
assert response.status_code == 200
assert response.json() == {
"id": str(user.id),
"email": "test_foo@test.com",
"full_name": "test_foo",
"language": user.language,
"short_name": "test_foo",
}
def test_api_users_retrieve_anonymous(): def test_api_users_retrieve_anonymous():
"""Anonymous users should not be allowed to retrieve a user.""" """Anonymous users should not be allowed to retrieve a user."""
client = APIClient() client = APIClient()

View File

@@ -0,0 +1,283 @@
"""Test the comment model."""
import random
from django.contrib.auth.models import AnonymousUser
import pytest
from core import factories
from core.models import LinkReachChoices, LinkRoleChoices, RoleChoices
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize(
"role,can_comment",
[
(LinkRoleChoices.READER, False),
(LinkRoleChoices.COMMENTER, True),
(LinkRoleChoices.EDITOR, True),
],
)
def test_comment_get_abilities_anonymous_user_public_document(role, can_comment):
"""Anonymous users cannot comment on a document."""
document = factories.DocumentFactory(
link_role=role, link_reach=LinkReachChoices.PUBLIC
)
comment = factories.CommentFactory(thread__document=document)
user = AnonymousUser()
assert comment.get_abilities(user) == {
"destroy": False,
"update": False,
"partial_update": False,
"reactions": False,
"retrieve": can_comment,
}
@pytest.mark.parametrize(
"link_reach", [LinkReachChoices.RESTRICTED, LinkReachChoices.AUTHENTICATED]
)
def test_comment_get_abilities_anonymous_user_restricted_document(link_reach):
"""Anonymous users cannot comment on a restricted document."""
document = factories.DocumentFactory(link_reach=link_reach)
comment = factories.CommentFactory(thread__document=document)
user = AnonymousUser()
assert comment.get_abilities(user) == {
"destroy": False,
"update": False,
"partial_update": False,
"reactions": False,
"retrieve": False,
}
@pytest.mark.parametrize(
"link_role,link_reach,can_comment",
[
(LinkRoleChoices.READER, LinkReachChoices.PUBLIC, False),
(LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC, True),
(LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC, True),
(LinkRoleChoices.READER, LinkReachChoices.RESTRICTED, False),
(LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED, False),
(LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED, False),
(LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED, False),
(LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED, True),
(LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED, True),
],
)
def test_comment_get_abilities_user_reader(link_role, link_reach, can_comment):
"""Readers cannot comment on a document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_role=link_role, link_reach=link_reach, users=[(user, RoleChoices.READER)]
)
comment = factories.CommentFactory(thread__document=document)
assert comment.get_abilities(user) == {
"destroy": False,
"update": False,
"partial_update": False,
"reactions": can_comment,
"retrieve": can_comment,
}
@pytest.mark.parametrize(
"link_role,link_reach,can_comment",
[
(LinkRoleChoices.READER, LinkReachChoices.PUBLIC, False),
(LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC, True),
(LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC, True),
(LinkRoleChoices.READER, LinkReachChoices.RESTRICTED, False),
(LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED, False),
(LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED, False),
(LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED, False),
(LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED, True),
(LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED, True),
],
)
def test_comment_get_abilities_user_reader_own_comment(
link_role, link_reach, can_comment
):
"""User with reader role on a document has all accesses to its own comment."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_role=link_role, link_reach=link_reach, users=[(user, RoleChoices.READER)]
)
comment = factories.CommentFactory(
thread__document=document, user=user if can_comment else None
)
assert comment.get_abilities(user) == {
"destroy": can_comment,
"update": can_comment,
"partial_update": can_comment,
"reactions": can_comment,
"retrieve": can_comment,
}
@pytest.mark.parametrize(
"link_role,link_reach",
[
(LinkRoleChoices.READER, LinkReachChoices.PUBLIC),
(LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC),
(LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC),
(LinkRoleChoices.READER, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED),
],
)
def test_comment_get_abilities_user_commenter(link_role, link_reach):
"""Commenters can comment on a document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_role=link_role,
link_reach=link_reach,
users=[(user, RoleChoices.COMMENTER)],
)
comment = factories.CommentFactory(thread__document=document)
assert comment.get_abilities(user) == {
"destroy": False,
"update": False,
"partial_update": False,
"reactions": True,
"retrieve": True,
}
@pytest.mark.parametrize(
"link_role,link_reach",
[
(LinkRoleChoices.READER, LinkReachChoices.PUBLIC),
(LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC),
(LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC),
(LinkRoleChoices.READER, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED),
],
)
def test_comment_get_abilities_user_commenter_own_comment(link_role, link_reach):
"""Commenters have all accesses to its own comment."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_role=link_role,
link_reach=link_reach,
users=[(user, RoleChoices.COMMENTER)],
)
comment = factories.CommentFactory(thread__document=document, user=user)
assert comment.get_abilities(user) == {
"destroy": True,
"update": True,
"partial_update": True,
"reactions": True,
"retrieve": True,
}
@pytest.mark.parametrize(
"link_role,link_reach",
[
(LinkRoleChoices.READER, LinkReachChoices.PUBLIC),
(LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC),
(LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC),
(LinkRoleChoices.READER, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED),
],
)
def test_comment_get_abilities_user_editor(link_role, link_reach):
"""Editors can comment on a document."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_role=link_role, link_reach=link_reach, users=[(user, RoleChoices.EDITOR)]
)
comment = factories.CommentFactory(thread__document=document)
assert comment.get_abilities(user) == {
"destroy": False,
"update": False,
"partial_update": False,
"reactions": True,
"retrieve": True,
}
@pytest.mark.parametrize(
"link_role,link_reach",
[
(LinkRoleChoices.READER, LinkReachChoices.PUBLIC),
(LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC),
(LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC),
(LinkRoleChoices.READER, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED),
(LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED),
(LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED),
],
)
def test_comment_get_abilities_user_editor_own_comment(link_role, link_reach):
"""Editors have all accesses to its own comment."""
user = factories.UserFactory()
document = factories.DocumentFactory(
link_role=link_role, link_reach=link_reach, users=[(user, RoleChoices.EDITOR)]
)
comment = factories.CommentFactory(thread__document=document, user=user)
assert comment.get_abilities(user) == {
"destroy": True,
"update": True,
"partial_update": True,
"reactions": True,
"retrieve": True,
}
def test_comment_get_abilities_user_admin():
"""Admins have all accesses to a comment."""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, RoleChoices.ADMIN)])
comment = factories.CommentFactory(
thread__document=document, user=random.choice([user, None])
)
assert comment.get_abilities(user) == {
"destroy": True,
"update": True,
"partial_update": True,
"reactions": True,
"retrieve": True,
}
def test_comment_get_abilities_user_owner():
"""Owners have all accesses to a comment."""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, RoleChoices.OWNER)])
comment = factories.CommentFactory(
thread__document=document, user=random.choice([user, None])
)
assert comment.get_abilities(user) == {
"destroy": True,
"update": True,
"partial_update": True,
"reactions": True,
"retrieve": True,
}

View File

@@ -123,16 +123,22 @@ def test_models_document_access_get_abilities_for_owner_of_self_allowed():
"retrieve": True, "retrieve": True,
"update": True, "update": True,
"partial_update": True, "partial_update": True,
"set_role_to": ["administrator", "editor", "reader"], "set_role_to": ["reader", "commenter", "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") 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 == { assert abilities == {
"destroy": False, "destroy": False,
"retrieve": True, "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", "commenter", "editor", "administrator", "owner"],
}
def test_models_document_access_get_abilities_for_owner_of_owner(): def test_models_document_access_get_abilities_for_owner_of_owner():
"""Check abilities of owner access for the owner of a document.""" """Check abilities of owner access for the owner of a document."""
access = factories.UserDocumentAccessFactory(role="owner") access = factories.UserDocumentAccessFactory(role="owner")
@@ -155,7 +183,7 @@ def test_models_document_access_get_abilities_for_owner_of_owner():
"retrieve": True, "retrieve": True,
"update": True, "update": True,
"partial_update": True, "partial_update": True,
"set_role_to": ["administrator", "editor", "reader"], "set_role_to": ["reader", "commenter", "editor", "administrator", "owner"],
} }
@@ -172,7 +200,7 @@ def test_models_document_access_get_abilities_for_owner_of_administrator():
"retrieve": True, "retrieve": True,
"update": True, "update": True,
"partial_update": True, "partial_update": True,
"set_role_to": ["owner", "editor", "reader"], "set_role_to": ["reader", "commenter", "editor", "administrator", "owner"],
} }
@@ -189,7 +217,7 @@ def test_models_document_access_get_abilities_for_owner_of_editor():
"retrieve": True, "retrieve": True,
"update": True, "update": True,
"partial_update": True, "partial_update": True,
"set_role_to": ["owner", "administrator", "reader"], "set_role_to": ["reader", "commenter", "editor", "administrator", "owner"],
} }
@@ -206,7 +234,7 @@ def test_models_document_access_get_abilities_for_owner_of_reader():
"retrieve": True, "retrieve": True,
"update": True, "update": True,
"partial_update": True, "partial_update": True,
"set_role_to": ["owner", "administrator", "editor"], "set_role_to": ["reader", "commenter", "editor", "administrator", "owner"],
} }
@@ -243,7 +271,7 @@ def test_models_document_access_get_abilities_for_administrator_of_administrator
"retrieve": True, "retrieve": True,
"update": True, "update": True,
"partial_update": True, "partial_update": True,
"set_role_to": ["editor", "reader"], "set_role_to": ["reader", "commenter", "editor", "administrator"],
} }
@@ -260,7 +288,7 @@ def test_models_document_access_get_abilities_for_administrator_of_editor():
"retrieve": True, "retrieve": True,
"update": True, "update": True,
"partial_update": True, "partial_update": True,
"set_role_to": ["administrator", "reader"], "set_role_to": ["reader", "commenter", "editor", "administrator"],
} }
@@ -277,7 +305,7 @@ def test_models_document_access_get_abilities_for_administrator_of_reader():
"retrieve": True, "retrieve": True,
"update": True, "update": True,
"partial_update": True, "partial_update": True,
"set_role_to": ["administrator", "editor"], "set_role_to": ["reader", "commenter", "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): 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") access = factories.UserDocumentAccessFactory(role="reader")
user = factories.UserDocumentAccessFactory( user = factories.UserDocumentAccessFactory(
document=access.document, role="reader" document=access.document, role="reader"
).user ).user
access.user_roles = ["reader"] access.set_user_roles_tuple(None, "reader")
with django_assert_num_queries(0): with django_assert_num_queries(0):
abilities = access.get_abilities(user) abilities = access.get_abilities(user)

View File

@@ -134,10 +134,13 @@ def test_models_documents_soft_delete(depth):
[ [
(True, "restricted", "reader"), (True, "restricted", "reader"),
(True, "restricted", "editor"), (True, "restricted", "editor"),
(True, "restricted", "commenter"),
(False, "restricted", "reader"), (False, "restricted", "reader"),
(False, "restricted", "editor"), (False, "restricted", "editor"),
(False, "restricted", "commenter"),
(False, "authenticated", "reader"), (False, "authenticated", "reader"),
(False, "authenticated", "editor"), (False, "authenticated", "editor"),
(False, "authenticated", "commenter"),
], ],
) )
def test_models_documents_get_abilities_forbidden( def test_models_documents_get_abilities_forbidden(
@@ -155,23 +158,27 @@ def test_models_documents_get_abilities_forbidden(
"ai_transform": False, "ai_transform": False,
"ai_translate": False, "ai_translate": False,
"attachment_upload": False, "attachment_upload": False,
"can_edit": False,
"children_create": False, "children_create": False,
"children_list": False, "children_list": False,
"collaboration_auth": False, "collaboration_auth": False,
"descendants": False, "descendants": False,
"cors_proxy": False, "cors_proxy": False,
"content": False,
"destroy": False, "destroy": False,
"duplicate": False, "duplicate": False,
"favorite": False, "favorite": False,
"comment": False,
"invite_owner": False, "invite_owner": False,
"mask": False,
"media_auth": False, "media_auth": False,
"media_check": False, "media_check": False,
"move": False, "move": False,
"link_configuration": False, "link_configuration": False,
"link_select_options": { "link_select_options": {
"authenticated": ["reader", "editor"], "authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "editor"], "public": ["reader", "commenter", "editor"],
"restricted": ["reader", "editor"], "restricted": None,
}, },
"partial_update": False, "partial_update": False,
"restore": False, "restore": False,
@@ -216,21 +223,25 @@ def test_models_documents_get_abilities_reader(
"ai_transform": False, "ai_transform": False,
"ai_translate": False, "ai_translate": False,
"attachment_upload": False, "attachment_upload": False,
"can_edit": False,
"children_create": False, "children_create": False,
"children_list": True, "children_list": True,
"collaboration_auth": True, "collaboration_auth": True,
"comment": False,
"descendants": True, "descendants": True,
"cors_proxy": True, "cors_proxy": True,
"content": True,
"destroy": False, "destroy": False,
"duplicate": True, "duplicate": is_authenticated,
"favorite": is_authenticated, "favorite": is_authenticated,
"invite_owner": False, "invite_owner": False,
"link_configuration": False, "link_configuration": False,
"link_select_options": { "link_select_options": {
"authenticated": ["reader", "editor"], "authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "editor"], "public": ["reader", "commenter", "editor"],
"restricted": ["reader", "editor"], "restricted": None,
}, },
"mask": is_authenticated,
"media_auth": True, "media_auth": True,
"media_check": True, "media_check": True,
"move": False, "move": False,
@@ -252,7 +263,77 @@ def test_models_documents_get_abilities_reader(
assert all( assert all(
value is False value is False
for key, value in document.get_abilities(user).items() for key, value in document.get_abilities(user).items()
if key != "link_select_options" if key not in ["link_select_options", "ancestors_links_definition"]
)
@override_settings(
AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"])
)
@pytest.mark.parametrize(
"is_authenticated,reach",
[
(True, "public"),
(False, "public"),
(True, "authenticated"),
],
)
def test_models_documents_get_abilities_commenter(
is_authenticated, reach, django_assert_num_queries
):
"""
Check abilities returned for a document giving commenter role to link holders
i.e anonymous users or authenticated users who have no specific role on the document.
"""
document = factories.DocumentFactory(link_reach=reach, link_role="commenter")
user = factories.UserFactory() if is_authenticated else AnonymousUser()
expected_abilities = {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"can_edit": False,
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"comment": True,
"content": True,
"descendants": True,
"cors_proxy": True,
"destroy": False,
"duplicate": is_authenticated,
"favorite": is_authenticated,
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"restricted": None,
},
"mask": is_authenticated,
"media_auth": True,
"media_check": True,
"move": False,
"partial_update": False,
"restore": False,
"retrieve": True,
"tree": True,
"update": False,
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
}
nb_queries = 1 if is_authenticated else 0
with django_assert_num_queries(nb_queries):
assert document.get_abilities(user) == expected_abilities
document.soft_delete()
document.refresh_from_db()
assert all(
value is False
for key, value in document.get_abilities(user).items()
if key not in ["link_select_options", "ancestors_links_definition"]
) )
@@ -279,21 +360,25 @@ def test_models_documents_get_abilities_editor(
"ai_transform": is_authenticated, "ai_transform": is_authenticated,
"ai_translate": is_authenticated, "ai_translate": is_authenticated,
"attachment_upload": True, "attachment_upload": True,
"can_edit": True,
"children_create": is_authenticated, "children_create": is_authenticated,
"children_list": True, "children_list": True,
"collaboration_auth": True, "collaboration_auth": True,
"comment": True,
"descendants": True, "descendants": True,
"cors_proxy": True, "cors_proxy": True,
"content": True,
"destroy": False, "destroy": False,
"duplicate": True, "duplicate": is_authenticated,
"favorite": is_authenticated, "favorite": is_authenticated,
"invite_owner": False, "invite_owner": False,
"link_configuration": False, "link_configuration": False,
"link_select_options": { "link_select_options": {
"authenticated": ["reader", "editor"], "authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "editor"], "public": ["reader", "commenter", "editor"],
"restricted": ["reader", "editor"], "restricted": None,
}, },
"mask": is_authenticated,
"media_auth": True, "media_auth": True,
"media_check": True, "media_check": True,
"move": False, "move": False,
@@ -314,7 +399,7 @@ def test_models_documents_get_abilities_editor(
assert all( assert all(
value is False value is False
for key, value in document.get_abilities(user).items() for key, value in document.get_abilities(user).items()
if key != "link_select_options" if key not in ["link_select_options", "ancestors_links_definition"]
) )
@@ -331,21 +416,25 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
"ai_transform": True, "ai_transform": True,
"ai_translate": True, "ai_translate": True,
"attachment_upload": True, "attachment_upload": True,
"can_edit": True,
"children_create": True, "children_create": True,
"children_list": True, "children_list": True,
"collaboration_auth": True, "collaboration_auth": True,
"comment": True,
"descendants": True, "descendants": True,
"cors_proxy": True, "cors_proxy": True,
"content": True,
"destroy": True, "destroy": True,
"duplicate": True, "duplicate": True,
"favorite": True, "favorite": True,
"invite_owner": True, "invite_owner": True,
"link_configuration": True, "link_configuration": True,
"link_select_options": { "link_select_options": {
"authenticated": ["reader", "editor"], "authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "editor"], "public": ["reader", "commenter", "editor"],
"restricted": ["reader", "editor"], "restricted": None,
}, },
"mask": True,
"media_auth": True, "media_auth": True,
"media_check": True, "media_check": True,
"move": True, "move": True,
@@ -363,8 +452,43 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
document.soft_delete() document.soft_delete()
document.refresh_from_db() document.refresh_from_db()
expected_abilities["move"] = False assert document.get_abilities(user) == {
assert document.get_abilities(user) == expected_abilities "accesses_manage": False,
"accesses_view": False,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"can_edit": False,
"children_create": False,
"children_list": False,
"collaboration_auth": False,
"comment": False,
"descendants": False,
"cors_proxy": False,
"content": False,
"destroy": False,
"duplicate": False,
"favorite": False,
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"restricted": None,
},
"mask": False,
"media_auth": False,
"media_check": False,
"move": False,
"partial_update": False,
"restore": True,
"retrieve": True,
"tree": True,
"update": False,
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
}
@override_settings( @override_settings(
@@ -380,21 +504,25 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
"ai_transform": True, "ai_transform": True,
"ai_translate": True, "ai_translate": True,
"attachment_upload": True, "attachment_upload": True,
"can_edit": True,
"children_create": True, "children_create": True,
"children_list": True, "children_list": True,
"collaboration_auth": True, "collaboration_auth": True,
"comment": True,
"descendants": True, "descendants": True,
"cors_proxy": True, "cors_proxy": True,
"content": True,
"destroy": False, "destroy": False,
"duplicate": True, "duplicate": True,
"favorite": True, "favorite": True,
"invite_owner": False, "invite_owner": False,
"link_configuration": True, "link_configuration": True,
"link_select_options": { "link_select_options": {
"authenticated": ["reader", "editor"], "authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "editor"], "public": ["reader", "commenter", "editor"],
"restricted": ["reader", "editor"], "restricted": None,
}, },
"mask": True,
"media_auth": True, "media_auth": True,
"media_check": True, "media_check": True,
"move": True, "move": True,
@@ -415,7 +543,7 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
assert all( assert all(
value is False value is False
for key, value in document.get_abilities(user).items() for key, value in document.get_abilities(user).items()
if key != "link_select_options" if key not in ["link_select_options", "ancestors_links_definition"]
) )
@@ -432,21 +560,25 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
"ai_transform": True, "ai_transform": True,
"ai_translate": True, "ai_translate": True,
"attachment_upload": True, "attachment_upload": True,
"can_edit": True,
"children_create": True, "children_create": True,
"children_list": True, "children_list": True,
"collaboration_auth": True, "collaboration_auth": True,
"comment": True,
"descendants": True, "descendants": True,
"cors_proxy": True, "cors_proxy": True,
"content": True,
"destroy": False, "destroy": False,
"duplicate": True, "duplicate": True,
"favorite": True, "favorite": True,
"invite_owner": False, "invite_owner": False,
"link_configuration": False, "link_configuration": False,
"link_select_options": { "link_select_options": {
"authenticated": ["reader", "editor"], "authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "editor"], "public": ["reader", "commenter", "editor"],
"restricted": ["reader", "editor"], "restricted": None,
}, },
"mask": True,
"media_auth": True, "media_auth": True,
"media_check": True, "media_check": True,
"move": False, "move": False,
@@ -467,7 +599,7 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
assert all( assert all(
value is False value is False
for key, value in document.get_abilities(user).items() for key, value in document.get_abilities(user).items()
if key != "link_select_options" if key not in ["link_select_options", "ancestors_links_definition"]
) )
@@ -491,21 +623,26 @@ def test_models_documents_get_abilities_reader_user(
"ai_transform": access_from_link and ai_access_setting != "restricted", "ai_transform": access_from_link and ai_access_setting != "restricted",
"ai_translate": access_from_link and ai_access_setting != "restricted", "ai_translate": access_from_link and ai_access_setting != "restricted",
"attachment_upload": access_from_link, "attachment_upload": access_from_link,
"can_edit": access_from_link,
"children_create": access_from_link, "children_create": access_from_link,
"children_list": True, "children_list": True,
"collaboration_auth": True, "collaboration_auth": True,
"comment": document.link_reach != "restricted"
and document.link_role in ["commenter", "editor"],
"descendants": True, "descendants": True,
"cors_proxy": True, "cors_proxy": True,
"content": True,
"destroy": False, "destroy": False,
"duplicate": True, "duplicate": True,
"favorite": True, "favorite": True,
"invite_owner": False, "invite_owner": False,
"link_configuration": False, "link_configuration": False,
"link_select_options": { "link_select_options": {
"authenticated": ["reader", "editor"], "authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "editor"], "public": ["reader", "commenter", "editor"],
"restricted": ["reader", "editor"], "restricted": None,
}, },
"mask": True,
"media_auth": True, "media_auth": True,
"media_check": True, "media_check": True,
"move": False, "move": False,
@@ -528,7 +665,72 @@ def test_models_documents_get_abilities_reader_user(
assert all( assert all(
value is False value is False
for key, value in document.get_abilities(user).items() for key, value in document.get_abilities(user).items()
if key != "link_select_options" if key not in ["link_select_options", "ancestors_links_definition"]
)
@pytest.mark.parametrize("ai_access_setting", ["public", "authenticated", "restricted"])
def test_models_documents_get_abilities_commenter_user(
ai_access_setting, django_assert_num_queries
):
"""Check abilities returned for the commenter of a document."""
user = factories.UserFactory()
document = factories.DocumentFactory(users=[(user, "commenter")])
access_from_link = (
document.link_reach != "restricted" and document.link_role == "editor"
)
expected_abilities = {
"accesses_manage": False,
"accesses_view": True,
# If you get your editor rights from the link role and not your access role
# You should not access AI if it's restricted to users with specific access
"ai_transform": access_from_link and ai_access_setting != "restricted",
"ai_translate": access_from_link and ai_access_setting != "restricted",
"attachment_upload": access_from_link,
"can_edit": access_from_link,
"children_create": access_from_link,
"children_list": True,
"collaboration_auth": True,
"comment": True,
"content": True,
"descendants": True,
"cors_proxy": True,
"destroy": False,
"duplicate": True,
"favorite": True,
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"restricted": None,
},
"mask": True,
"media_auth": True,
"media_check": True,
"move": False,
"partial_update": access_from_link,
"restore": False,
"retrieve": True,
"tree": True,
"update": access_from_link,
"versions_destroy": False,
"versions_list": True,
"versions_retrieve": True,
}
with override_settings(AI_ALLOW_REACH_FROM=ai_access_setting):
with django_assert_num_queries(1):
assert document.get_abilities(user) == expected_abilities
document.soft_delete()
document.refresh_from_db()
assert all(
value is False
for key, value in document.get_abilities(user).items()
if key not in ["link_select_options", "ancestors_links_definition"]
) )
@@ -548,21 +750,25 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
"ai_transform": False, "ai_transform": False,
"ai_translate": False, "ai_translate": False,
"attachment_upload": False, "attachment_upload": False,
"can_edit": False,
"children_create": False, "children_create": False,
"children_list": True, "children_list": True,
"collaboration_auth": True, "collaboration_auth": True,
"comment": False,
"descendants": True, "descendants": True,
"cors_proxy": True, "cors_proxy": True,
"content": True,
"destroy": False, "destroy": False,
"duplicate": True, "duplicate": True,
"favorite": True, "favorite": True,
"invite_owner": False, "invite_owner": False,
"link_configuration": False, "link_configuration": False,
"link_select_options": { "link_select_options": {
"authenticated": ["reader", "editor"], "authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "editor"], "public": ["reader", "commenter", "editor"],
"restricted": ["reader", "editor"], "restricted": None,
}, },
"mask": True,
"media_auth": True, "media_auth": True,
"media_check": True, "media_check": True,
"move": False, "move": False,
@@ -577,6 +783,86 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
} }
@pytest.mark.parametrize(
"is_authenticated, is_creator,role,link_reach,link_role,can_destroy",
[
(True, False, "owner", "restricted", "editor", True),
(True, True, "owner", "restricted", "editor", True),
(True, False, "owner", "restricted", "reader", True),
(True, True, "owner", "restricted", "reader", True),
(True, False, "owner", "authenticated", "editor", True),
(True, True, "owner", "authenticated", "editor", True),
(True, False, "owner", "authenticated", "reader", True),
(True, True, "owner", "authenticated", "reader", True),
(True, False, "owner", "public", "editor", True),
(True, True, "owner", "public", "editor", True),
(True, False, "owner", "public", "reader", True),
(True, True, "owner", "public", "reader", True),
(True, False, "administrator", "restricted", "editor", True),
(True, True, "administrator", "restricted", "editor", True),
(True, False, "administrator", "restricted", "reader", True),
(True, True, "administrator", "restricted", "reader", True),
(True, False, "administrator", "authenticated", "editor", True),
(True, True, "administrator", "authenticated", "editor", True),
(True, False, "administrator", "authenticated", "reader", True),
(True, True, "administrator", "authenticated", "reader", True),
(True, False, "administrator", "public", "editor", True),
(True, True, "administrator", "public", "editor", True),
(True, False, "administrator", "public", "reader", True),
(True, True, "administrator", "public", "reader", True),
(True, False, "editor", "restricted", "editor", False),
(True, True, "editor", "restricted", "editor", True),
(True, False, "editor", "restricted", "reader", False),
(True, True, "editor", "restricted", "reader", True),
(True, False, "editor", "authenticated", "editor", False),
(True, True, "editor", "authenticated", "editor", True),
(True, False, "editor", "authenticated", "reader", False),
(True, True, "editor", "authenticated", "reader", True),
(True, False, "editor", "public", "editor", False),
(True, True, "editor", "public", "editor", True),
(True, False, "editor", "public", "reader", False),
(True, True, "editor", "public", "reader", True),
(True, False, "reader", "restricted", "editor", False),
(True, False, "reader", "restricted", "reader", False),
(True, False, "reader", "authenticated", "editor", False),
(True, True, "reader", "authenticated", "editor", True),
(True, False, "reader", "authenticated", "reader", False),
(True, False, "reader", "public", "editor", False),
(True, True, "reader", "public", "editor", True),
(True, False, "reader", "public", "reader", False),
(False, False, None, "restricted", "editor", False),
(False, False, None, "restricted", "reader", False),
(False, False, None, "authenticated", "editor", False),
(False, False, None, "authenticated", "reader", False),
(False, False, None, "public", "editor", False),
(False, False, None, "public", "reader", False),
],
)
# pylint: disable=too-many-arguments, too-many-positional-arguments
def test_models_documents_get_abilities_children_destroy( # noqa: PLR0913
is_authenticated,
is_creator,
role,
link_reach,
link_role,
can_destroy,
):
"""For a sub document, if a user can create children, he can destroy it."""
user = factories.UserFactory() if is_authenticated else AnonymousUser()
parent = factories.DocumentFactory(link_reach=link_reach, link_role=link_role)
document = factories.DocumentFactory(
link_reach=link_reach,
link_role=link_role,
parent=parent,
creator=user if is_creator else None,
)
if is_authenticated:
factories.UserDocumentAccessFactory(document=parent, user=user, role=role)
abilities = document.get_abilities(user)
assert abilities["destroy"] is can_destroy
@override_settings(AI_ALLOW_REACH_FROM="public") @override_settings(AI_ALLOW_REACH_FROM="public")
@pytest.mark.parametrize( @pytest.mark.parametrize(
"is_authenticated,reach", "is_authenticated,reach",
@@ -1176,184 +1462,158 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
@pytest.mark.parametrize( @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", "commenter", "editor"],
},
),
(
"public",
"commenter",
{
"public": ["commenter", "editor"],
},
),
("public", "editor", {"public": ["editor"]}),
(
"authenticated",
"reader",
{
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
},
),
(
"authenticated",
"commenter",
{
"authenticated": ["commenter", "editor"],
"public": ["commenter", "editor"],
},
),
(
"authenticated",
"editor",
{"authenticated": ["editor"], "public": ["editor"]},
),
(
"restricted",
"reader",
{
"restricted": None,
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
},
),
(
"restricted",
"commenter",
{
"restricted": None,
"authenticated": ["commenter", "editor"],
"public": ["commenter", "editor"],
},
),
(
"restricted",
"editor",
{
"restricted": None,
"authenticated": ["editor"], "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"], "public": ["reader", "commenter", "editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
}, },
), ),
( (
[{"link_reach": "authenticated", "link_role": "editor"}], None,
{"authenticated": ["editor"], "public": ["reader", "editor"]}, "reader",
),
(
[{"link_reach": "restricted", "link_role": "reader"}],
{ {
"restricted": ["reader", "editor"], "public": ["reader", "commenter", "editor"],
"authenticated": ["reader", "editor"], "authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "editor"], "restricted": None,
}, },
), ),
( (
[{"link_reach": "restricted", "link_role": "editor"}], None,
None,
{ {
"restricted": ["editor"], "public": ["reader", "commenter", "editor"],
"authenticated": ["reader", "editor"], "authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "editor"], "restricted": None,
},
),
# 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)
(
[],
{
"public": ["reader", "editor"],
"authenticated": ["reader", "editor"],
"restricted": ["reader", "editor"],
}, },
), ),
], ],
) )
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.""" """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(): def test_models_documents_compute_ancestors_links_paths_mapping_single(
"""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(
django_assert_num_queries, 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() user = factories.UserFactory()
other_user = factories.UserFactory() other_user = factories.UserFactory()
root = factories.DocumentFactory(
link_reach="restricted", link_role="reader", users=[user]
)
factories.DocumentFactory( root = factories.DocumentFactory(link_reach="restricted", users=[user])
parent=root, link_reach="public", link_role="reader", users=[user] document = factories.DocumentFactory(
)
child2 = factories.DocumentFactory(
parent=root, parent=root,
link_reach="authenticated", link_reach="authenticated",
link_role="editor", link_role="editor",
users=[user, other_user], users=[user, other_user],
) )
child3 = factories.DocumentFactory( sibling = factories.DocumentFactory(parent=root, link_reach="public", users=[user])
parent=child2, child = factories.DocumentFactory(
parent=document,
link_reach="authenticated", link_reach="authenticated",
link_role="reader", link_role="reader",
users=[user, other_user], users=[user, other_user],
) )
with django_assert_num_queries(2): # Child
assert child3.compute_ancestors_links(user=user) == [ with django_assert_num_queries(1):
{"link_reach": root.link_reach, "link_role": root.link_role}, assert child.compute_ancestors_links_paths_mapping() == {
{"link_reach": child2.link_reach, "link_role": child2.link_role}, 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): # Sibling
assert child3.compute_ancestors_links(user=other_user) == [ with django_assert_num_queries(1):
{"link_reach": child2.link_reach, "link_role": child2.link_role}, 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},
],
}

View File

@@ -8,7 +8,7 @@ from django.core.exceptions import ValidationError
import pytest import pytest
from core import factories from core import factories, models
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@@ -44,3 +44,55 @@ def test_models_users_send_mail_main_missing():
user.email_user("my subject", "my message") user.email_user("my subject", "my message")
assert str(excinfo.value) == "User has no email address." assert str(excinfo.value) == "User has no email address."
@pytest.mark.parametrize(
"sub,is_valid",
[
("valid_sub.@+-:=/", True),
("invalid süb", False),
(12345, True),
],
)
def test_models_users_sub_validator(sub, is_valid):
"""The "sub" field should be validated."""
user = factories.UserFactory()
user.sub = sub
if is_valid:
user.full_clean()
else:
with pytest.raises(
ValidationError,
match=("Enter a valid sub. This value should be ASCII only."),
):
user.full_clean()
def test_modes_users_convert_valid_invitations():
"""
The "convert_valid_invitations" method should convert valid invitations to document accesses.
"""
email = "test@example.com"
document = factories.DocumentFactory()
other_document = factories.DocumentFactory()
invitation_document = factories.InvitationFactory(email=email, document=document)
invitation_other_document = factories.InvitationFactory(
email="Test@example.coM", document=other_document
)
other_email_invitation = factories.InvitationFactory(
email="pre_test@example.com", document=document
)
assert document.accesses.count() == 0
assert other_document.accesses.count() == 0
user = factories.UserFactory(email=email)
assert document.accesses.filter(user=user).count() == 1
assert other_document.accesses.filter(user=user).count() == 1
assert not models.Invitation.objects.filter(id=invitation_document.id).exists()
assert not models.Invitation.objects.filter(
id=invitation_other_document.id
).exists()
assert models.Invitation.objects.filter(id=other_email_invitation.id).exists()

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