Compare commits

..

349 Commits

Author SHA1 Message Date
Sylvain Boissel
270b374a17 📝(doc) fix publiccode.yml syntax
This fixes publiccode.yml according to the 0.5.0 syntax: remove or rename
non-existing fields, add the missing mandatory ones, fix a few typos.
2026-01-05 17:46:05 +01:00
Anthony LC
ea3a4a6da3 (project) add custom js support via config
From the config, we can add custom JS file URL
to be included in the frontend.
2026-01-05 15:06:53 +01:00
Anthony LC
b78ad27a71 🐛(frontend) fix children not display when first resize
When we resize the window for the first time, then
open the panel, the children were not displayed.
This fix this issue.
2026-01-05 13:21:54 +01:00
Anthony LC
e4b8ffb304 ✈️(frontend) pause Posthog when offline
Posthog keeps trying to send events when the user
is offline, causing the network request queue to fill up
and slowing down the app. This commit pauses Posthog
when the user is offline and resumes it when back online.
2026-01-05 12:07:47 +01:00
Anthony LC
78c7ab247b 🦺(frontend) check content type pdf on PdfBlock
Pdfblock was quite permissive on the content type
it was accepting. Now it checks that the content
type is exactly 'application/pdf' before rendering
the PDF viewer.
2026-01-05 11:47:55 +01:00
Anthony LC
b0bd6e2c01 🥅(frontend) intercept 401 error on GET threads
We intercept 401 errors on GET /threads to avoid
spamming Sentry with authentication errors
when users are not logged in.
2026-01-05 11:23:43 +01:00
Anthony LC
37527416f2 🩹(frontend) small ui improvement
- center initial loader before app load
- add name on input to remove warning
- fix hover on interlinking link
2026-01-05 11:02:30 +01:00
Anthony LC
30bc959340 ⬆️(dependency) fix CVE by bumping qs dependency
Fix CVE by bumping qs from 6.14.0 to 6.14.1
2026-01-05 10:20:17 +01:00
Anthony LC
a73d9c1c78 📱(frontend) add comments for smaller device
Add comments support for mobile devices by
removing the desktop-only restriction and
ensuring the UI adapts well to smaller screens.
2026-01-05 10:04:37 +01:00
Anthony LC
a920daf05b ⬆️(dependencies) bump to blocknote 0.45.0
Bump to BlockNote 0.45.0 to get the latest
features and fixes.
This release includes the fix for the table
deletion that breaks the editor when
deleting tables.
2025-12-22 10:53:12 +01:00
Anthony LC
ff88465398 ⬇️(dependencies) downgrade next from 16.0.10 to 15.5.9
Passing the Next 16 will need more work to be compatible
with our application. We will do this upgrade later
in a dedicated PR.
We add it in the renovate.json to avoid having Renovate
trying to upgrade it again.
2025-12-22 10:52:03 +01:00
renovate[bot]
3617e4f7b8 ⬆️(dependencies) update js dependencies 2025-12-22 10:52:02 +01:00
Anthony LC
efaec45bfd (helm) create ingress-redirects template
Create a new Helm template for ingress redirects
and update the values.yaml file accordingly.
We will be able to manage ingress redirects
through Helm charts easily.
2025-12-22 10:11:54 +01:00
Anthony LC
715d88ba3c ♻️(frontend) replace auth redirect logic for home
To be intercepted by ingress redirects, we need
to redirect using window.location instead of
using Next.js router. The Next.js router does not
trigger a full page reload, so the ingress
redirect logic is not executed.
2025-12-22 10:09:13 +01:00
Anthony LC
7d64d79eeb 🐛(helm) fix OIDC authentication with standard scopes
"usual_name" does not seem to be standard,
it gives error during login.
We replace "usual_name" by "family_name".
2025-12-22 09:24:44 +01:00
Anthony LC
2e66b87dab 🔧(helm) add OIDC_REDIRECT_ALLOWED_HOSTS to fix authentication flow
Add OIDC_REDIRECT_ALLOWED_HOSTS setting to dev and
feature environments to properly allow Keycloak
redirect callbacks after authentication.
2025-12-22 09:24:44 +01:00
Anthony LC
fb368ef86f 🔖(minor) release 4.2.0
Added:
- (backend) allow to create a new user in a marketing system
- (backend) add async indexation of documents on save
  (or access save)
- (backend) add debounce mechanism to limit indexation jobs
- (api) add API route to search for indexed documents in Find
- 🥅(frontend) add boundary error page

Changed:
- 🛂(backend) stop throttling collaboration servers
- 🚸(backend) use unaccented full name for user search
- 🌐(backend) internationalize demo
- (frontend) improve accessibility:
  - ️Improve keyboard accessibility for the document tree

Fixed:
- 🐛(frontend) paste content with comments from another document
- 🐛(frontend) Select text + Go back one page crash the app
2025-12-17 17:03:27 +01:00
Anthony LC
e340463d35 🐛(frontend) fix versioning conflict
We switching from one version to the other, depending on
the blocks inside, the version editor could
crash due to conflicts between the different versions.
We now reset the previous content
when switching version to avoid these conflicts.
2025-12-17 17:03:27 +01:00
Anthony LC
344e9a83e4 🥅(frontend) add boundary error page
Add a custom error page to handle unexpected errors
gracefully. This page provides users with options
to navigate back to the home page or refresh
the current page, enhancing the overall user
experience during error scenarios.
It is quite hard to test this page, it cannot
be trigger in development mode, we have to build
the app and have a real error in production to
see it.
2025-12-17 09:26:15 +01:00
Cyril
48aa4971ec (frontend) keyboard support in sub-documents with f2 options access
adds f2 shortcut to open options menu in sub-documents

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

(frontend) adds f2 shortcut using a fakenode since it's outside the treeview

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

(frontend) add sr-only instructions with aria-describedby links

improves screen reader support with contextual accessibility guidance

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

(frontend) add e2e test to check focus behavior with F2 shortcut

ensures F2 correctly focuses the expected UI element

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-17 08:26:48 +01:00
Fabre Florian
d47b5e6a90 🩹(backend) fix oidc token storage configuration
Disable OIDC_STORE_ACCESS_TOKEN & OIDC_STORE_REFRESH_TOKEN as default
to prevent authentication issues when the Find service is not used.

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-12-16 22:10:03 +01:00
Fabre Florian
c24f46067b (backend) adapt to Find new search pagination
Use nb_results instead of page/page_size argument for /search API.

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-12-16 22:10:03 +01:00
Fabre Florian
f5a9ef2643 🩹(backend) fix empty indexation batch
As we filter the empty documents from the batch during indexing some batches
can be empty and cause an error. Now they are ignored.
Add --batch-size argument to the index command.

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-12-16 22:10:03 +01:00
Fabre Florian
780bcb360a (backend) use batches in indexing task
Reduce the number of Find API calls by grouping all the latest changes
for indexation : send all the documents updated or deleted since the
triggering of the task.

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-12-16 22:10:03 +01:00
Fabre Florian
65d572ccd6 📝(backend) add fulltext search documentation
Add documentation for env & Find+Docs configuration in dev mode

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-12-16 22:10:03 +01:00
Fabre Florian
4644bb4f47 🔧(compose) disable indexer in default configuration
Set SEARCH_INDEXER_CLASS=None as default configuration for dev.
Rename docker network 'lasuite-net' as 'lasuite' to match with Drive
configuration.

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-12-16 22:10:03 +01:00
Fabre Florian
de3dfbb0c7 (backend) keep ordering from fulltext search in results
Keep ordering by score from Find API on search/ results and
fallback search still uses "-update_at" ordering as default

Refactor pagination to work with a list instead of a queryset

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-12-16 22:10:03 +01:00
Fabre Florian
b0e7a511cb (backend) throttle indexation tasks instead of debounce (simplier)
Replace indexer_debounce_lock|release functions by indexer_throttle_acquire()
Instead of mutex-like mechanism, simply set a flag in cache for an amount of
time that prevents any other task creation.

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-12-16 22:10:03 +01:00
Fabre Florian
044c1495a9 (backend) some refactor of indexer classes & modules
Rename FindDocumentIndexer as SearchIndexer
Rename FindDocumentSerializer as SearchDocumentSerializer
Rename package core.tasks.find as core.task.search
Remove logs on http errors in SearchIndexer
Factorise some code in search API view.

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-12-16 22:10:03 +01:00
Fabre Florian
6f282ec5d6 🔧(backend) setup Docs app dockers to work with Find
Add nginx with 'nginx' alias to the 'lasuite-net' network (keycloak calls)
Add celery-dev to the 'lasuite-net' network (Find API calls in jobs)
Set app-dev alias as 'impress' in the 'lasuite-net' network
Add indexer configuration in common settings

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-12-16 22:10:03 +01:00
Fabre Florian
580d25b79f 🔧(backend) tool for valid fernet key used in OIDC token storage
Add bin/fernetkey that generates a key for the OIDC_STORE_REFRESH_TOKEN_KEY
setting.

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

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

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-12-16 22:10:03 +01:00
Fabre Florian
bf978b5376 (backend) refactor indexation signals and fix circular import issues
Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-12-16 22:10:03 +01:00
Fabre Florian
24460ffc3a (backend) improve search indexer service configuration
New SEARCH_INDEXER_CLASS setting to define the indexer service class.
Raise ImpoperlyConfigured errors instead of RuntimeError in index service.

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-12-16 21:48:23 +01:00
Fabre Florian
d721b97f68 (backend) add document search view
New API view that calls the indexed documents search view
(resource server) of app "Find".

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-12-16 21:48:23 +01:00
Fabre Florian
3228f65092 (backend) add unit test for the 'index' command
Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-12-16 21:48:23 +01:00
Fabre Florian
6ba473f858 🔧(compose) Add some ignore for docker-compose local overrides
Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-12-16 21:48:23 +01:00
Samuel Paccoud - DINUM
72238c1ab6 (backend) add async triggers to enable document indexation with find
On document content or permission changes, start a celery job that will call the
indexation API of the app "Find".

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-12-16 21:48:23 +01:00
Samuel Paccoud - DINUM
1d9c2a8118 (backend) add document search indexer
Add indexer that loops across documents in the database, formats them
as json objects and indexes them in the remote "Find" mico-service.
2025-12-16 21:48:23 +01:00
Samuel Paccoud - DINUM
f4bdde7e59 (backend) add dummy content to demo documents
We need to content in our demo documents so that we can test
indexing.
2025-12-16 21:48:23 +01:00
Samuel Paccoud - DINUM
4dc3322b0d 🔧(compose) configure external network for communication with search
Search in Docs relies on an external project like "La Suite Find".
We need to declare a common external network in order to connect to
the search app and index our documents.
2025-12-16 21:48:23 +01:00
Anthony LC
23216d549e 🛂(backend) stop throttling collaboration servers
We observe some throttling pick here and there.
We observed that when the collaboration has a
problem, it is retrying to connect, leading to more
requests to the django backend. At one point, the
throttling is reached and the user would not
be able to use the application anymore.
Now when the request comes from a collaboration
server, we do not throttle it anymore.
2025-12-16 14:13:30 +01:00
Anthony LC
2f612dbc2f ♻️(frontend) improve accessibility CalloutBlock
The recent update of Blocknote brokes a test
because a element was not easily accessible anymore.
We improved the CalloutBlock to be able to
be closed when "escape" is pressed, we improve
the positionning of the EmojiPicker too.
2025-12-16 10:48:41 +01:00
Anthony LC
bbf834fb6e ♻️(frontend) isConnected when authenticated
WebSocketStatus.Connected does not mean
we are totally connected because authentication
can still be in progress and failed.
So we will use the event onAuthenticated to assert
that we are fully connected.
2025-12-16 10:48:41 +01:00
Anthony LC
4cf0e15406 ⬆️(dependencies) bump blocknote to 0.44.2
We bump the blocknote dependencies to version
0.44.2 to incorporate the latest features
and bug fixes.
It seems to fix an issue with Titap, when the text
was selected and the user clicked
on the Go Back button of the browser, the
application was crashing.
"[tiptap error]: The editor view is not available.
Cannot access view['dom']. The editor may not be
mounted yet."
2025-12-16 10:48:41 +01:00
Anthony LC
31bd475418 🐛(frontend) paste content with comments from another document
When pasting comments, the data-bn-thread-id
attribute is present in the clipboard data.
This indicates that the pasted content contains comments.
But if the content with comments comes from another
document, it will create orphaned comments that
are not linked to this document and create errors.
To avoid this, we refresh the threads to ensure
that only comments relevant to the current document
are displayed.
2025-12-15 17:17:21 +01:00
Sylvain Boissel
08fb191e6b 🌐(backend) internationalize demo
This allows the demo to generate user and filenames with other locales
than English, for all languages defined in the project's settings.
In particular, it allows the generation of accented names, which were
previously missing.
2025-12-15 12:43:57 +01:00
Sylvain Boissel
a49f3b6b32 📝(changelog) move entry in unreleased section
The changelog line was at the wrong place after rebase
2025-12-15 12:02:00 +01:00
Manuel Raynaud
bd9a3334db 🔧(helm) add user name in oidc scopes
The given_name and usual_name is not configured in the oidc scopes. When
a user connect to docs with the dev and feature configuration, we don't
have this informations.
2025-12-15 11:22:26 +01:00
Sylvain Boissel
96299f4b7f 🚸(backend) use unaccented full name for user search
We have the user full name through OIDC in the database, but the search only
used the email field.
This change allows to search for a user by their first and/or
last name (fix #929).
Given that user names are more likely than emails to include diacritics, it
unaccents both the query and the database entry for search (fix #1091).
It also unaccents for email so that internationalized domain names are
managed whether or not the accent is included in the search.
An unaccented gin index is added on users full_name an email fields.
Using a manual migration because a wrapper around unaccent is necessary
to make it IMMUTABLE (cf.
https://stackoverflow.com/questions/9063402/ )
2025-12-15 11:22:26 +01:00
renovate[bot]
52bd31c0d5 ⬆️(dependencies) update next to v15.5.9 [SECURITY] 2025-12-12 20:47:48 +00:00
renovate[bot]
35be4be158 ⬆️(dependencies) update next to v15.5.8 [SECURITY] 2025-12-12 04:07:18 +00:00
Anthony LC
05aa225aed ⬆️(dependencies) Bump mdast-util-to-hast
Bumps mdast-util-to-hast from 13.2.0 to 13.2.1.
- [Release notes](https://github.com/syntax-tree/mdast-util-to-hast/releases)

---
updated-dependencies:
- dependency-name: mdast-util-to-hast
  dependency-version: 13.2.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-11 12:09:03 +01:00
Manuel Raynaud
d65d0d1450 📝(changelog) move entry in unreleased section
The commit f5425f5 was created before release 4.1.0 but merged after and
the corresponding entry has not been move in the unreleased section.
2025-12-10 17:23:26 +01:00
Manuel Raynaud
b11d3acd01 📝(kubernetes) update doc with our dev-backend helm chart
We removed the usage of bitnami charts and created our own dev-backend
helm chart. We need to update the kubernetes document accordingly.
2025-12-10 17:23:13 +01:00
Manuel Raynaud
8091cbca23 (backend) allow to create a new user in a marketing system
We want to create a new user in a marketing system to create a dedicated
onboarding for each of them. The marketing service is implemented in the
django-lasuite library and it is possible to pick the backend we want
or implement a new one following the documentation on this library.
2025-12-10 16:30:00 +01:00
Anthony LC
12cc79b640 🔖(minor) release 4.1.0
Added:
- ️(frontend) export html

Changed:
- (frontend) improve accessibility:
  - (frontend) add skip to content button for keyboard
    accessibility
  - (frontend) fix toggle panel button a11y labels
- 🔒️(frontend) remove dangerouslySetInnerHTML from codebase
- ️(frontend) improve Comments feature

Fixed:
- 🐛(nginx) fix / location to handle new static pages
2025-12-10 11:50:14 +01:00
Anthony LC
af15e77713 🐛(frontend) keep editor mounted when resize window
When resizing the window and crossing the desktop
breakpoint, the editor was unmounted. It could
lead to loss of data if there were unsaved changes,
and tiptap crash if the toolbar was used while the
editor was unmounted.
It was caused by the ResizableLeftPanel component
which was rerendering the editor.
We now keep the editor mounted when resizing
the window, by keeping the ResizableLeftPanel
component rendered but setting its size to 0
and disabling the resize handle.
2025-12-10 11:50:14 +01:00
Anthony LC
99131dc917 🐛(frontend) check tiptap editor in dom
When zooming in and out quickly, the editor
instance may not be fully mounted, leading to
errors when accessing its document. This commit
adds checks to ensure the editor and its view
are mounted before attempting to access the
document, preventing potential runtime errors.
2025-12-10 11:50:14 +01:00
AntoLC
90651a8ea6 🌐(i18n) update translated strings
Update translated files with new translations
2025-12-09 12:20:34 +01:00
Anthony LC
9c575e397c 🔒️(frontend) remove dangerouslySetInnerHTML from codebase
dangerouslySetInnerHTML were introduced to quickly
render translated strings containing HTML,
but they can lead to security vulnerabilities
if not handled properly.
Better to use React components to ensure safety.
2025-12-09 11:34:25 +01:00
Anthony LC
a6b472aa51 ️(frontend) improve Comments feature
Improve the comments feature to reduce annoyance:
- gives focus on input when opening comment threads
- hide comment button when mobile view
- improve contrast of overline commented text
- remove thread if last comment deleted
- scroll to bottom thread when adding new comment
2025-12-09 11:17:42 +01:00
Anthony LC
9fcc221b33 💄(export) add style file to html export
We now include a CSS style file in the exported
ZIP archive. This file contains styles that
enhance the appearance of the exported HTML
document when viewed in a web browser to look
more like the original document.
2025-12-09 10:52:36 +01:00
Cyril
acdde81a3d (frontend) fix toggle panel button a11y with dynamic label
improves screen sr by updating label and state indication dynamically

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-09 09:57:12 +01:00
Cyril
9b03754f88 (e2e) add test for accessible html export from export modal
checks generated zip contains html and embedded media files

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-09 09:24:14 +01:00
Cyril
0805216cc6 (frontend) added accessible html export and moved download option
replaced “copy as html” with export modal option and full media zip export

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-09 09:24:10 +01:00
Cyril
5e398e8e79 (frontend) move html option to downloads section
makes the option less visible as it's not useful to most users

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-09 09:22:30 +01:00
renovate[bot]
00ae7fdd60 ⬆️(dependencies) update django to v5.2.9 [SECURITY]
upgrade to django 5.2.9
2025-12-08 14:36:21 +00:00
renovate[bot]
8036f16cc3 ⬆️(dependencies) update next to v15.5.7 [SECURITY] 2025-12-03 21:49:27 +00:00
Manuel Raynaud
54fe70d662 🐛(nginx) fix / location to handle new static pages
The / location is not trying the $uri/index.html file. We should try
this instad of $uri/ because when a new static page is added, we always
have this pattern.
2025-12-03 12:14:19 +01:00
Cyril
1e37007be9 (a11y) add skip to content button for keyboard accessibility
add SkipToContent component to meet RGAA skiplink requirement

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

(frontend) add e2e test for skiplink and fix broken accessibility test

ensures skiplink behavior is tested and stabilizes a failing accessibility test

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-02 13:37:58 +01:00
Anthony LC
77df9783b7 🔖(major) release 4.0.0
Added:
-  Add comments feature to the editor
- (backend) Comments on text editor
- (frontend) link to create new doc

Changed:
- ️(sw) stop to cache external resources likes videos
- 💥(frontend) upgrade to ui-kit v2
- ️(frontend) improve perf on upload and table of contents
- (frontend) improve accessibility:
  - (frontend) improve share modal button accessibility
  - (frontend) improve screen reader support in DocShare modal

Fixed:
- 🐛(frontend) fix toolbar not activated when reader
- 🐛(frontend) preserve left panel width on window resize
- 🐛(frontend) prevent duplicate as first character in title
2025-12-02 10:01:48 +01:00
Anthony LC
350fe17918 🐛(sw) keep incremental versioning for IndexedDB
IndexDB need a integer versioning when upgrading
the database, it has to be incremental.
Before the fix, version 4.0.0 would give 400, when
3.10.0 would give 3100. That would cause an error
and the database would be destroyed then recreated.
We improve the way we compute the version number
to ensure it is always incremental, avoiding such
issues.
2025-12-02 10:01:48 +01:00
Anthony LC
a0ddc6ba0c 🗑️(CI) free up space in the GitHub Actions runners
On the release pull request workflows, we sometimes
face issues with disk space. We clean up some space
before starting Docker services or after finishing
to build images to avoid these issues.
2025-12-02 10:01:48 +01:00
Anthony LC
92d3f634cb 💄(frontend) fix some minor color and style issues
When migrating to the new design system, some
colors and style issues were identified.
This commit addresses these issues by updating
the styles to align with the new design guidelines,
ensuring a consistent and visually appealing
user experience.
2025-12-01 22:48:55 +01:00
Anthony LC
c06bc6fd21 🐛(frontend) fix TOC display without headings
The table of contents was displayed even when there
were no headings in the document. It was
not the expected behavior.
We now ensure that the TOC is only shown
when there are headings present, we added a test
to verify this behavior.
2025-12-01 22:48:55 +01:00
AntoLC
80ee409da4 🌐(i18n) update translated strings
Update translated files with new translations
2025-12-01 22:48:55 +01:00
Anthony LC
7475b7c3bc (frontend) link to create new doc
We create a special URL to create a new doc,
we can set the doc with the URL param to set
the visibility, the permission and the title.
2025-12-01 15:08:25 +01:00
Anthony LC
c13f0e97bb (frontend) fix flaky e2e on duplicate test
Fix a flaky issue in the e2e test for
duplicating a document.
2025-12-01 14:23:41 +01:00
Anthony LC
f11543094a 💄(frontend) fix background action on panel pages
Background actions were transparent,
making them hard to see.
2025-12-01 14:22:26 +01:00
Anthony LC
b1fb400d70 📌(dependencies) clean resolutions dependencies
Some compatibility issues were causing yarn to add
resolutions, they are no longer necessary, we can
remove them.
We pin as well to Next 15, passing to Next 16 will
require significant code changes, let's do that
in a dedicated PR.
2025-12-01 11:08:01 +01:00
renovate[bot]
50848b3410 ⬆️(dependencies) update js dependencies 2025-12-01 11:08:01 +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
507 changed files with 39853 additions and 16982 deletions

View File

@@ -0,0 +1,24 @@
name: 'Free Disk Space'
description: 'Free up disk space by removing large preinstalled items and cleaning up Docker'
runs:
using: "composite"
steps:
- name: Free disk space (Linux only)
if: runner.os == 'Linux'
shell: bash
run: |
echo "Disk usage before cleanup:"
df -h
# Remove large preinstalled items that are not used on GitHub-hosted runners
sudo rm -rf /usr/share/dotnet || true
sudo rm -rf /opt/ghc || true
sudo rm -rf /usr/local/lib/android || true
# Clean up Docker
docker system prune -af || true
docker volume prune -f || true
echo "Disk usage after cleanup:"
df -h

View File

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

View File

@@ -31,8 +31,11 @@ jobs:
images: lasuite/impress-backend
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
@@ -46,9 +49,15 @@ jobs:
context: .
target: backend-production
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 }}
labels: ${{ steps.meta.outputs.labels }}
-
name: Cleanup Docker after build
if: always()
run: |
docker system prune -af
docker volume prune -f
build-and-push-frontend:
runs-on: ubuntu-latest
@@ -64,8 +73,11 @@ jobs:
images: lasuite/impress-frontend
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
@@ -82,9 +94,15 @@ jobs:
build-args: |
DOCKER_USER=${{ env.DOCKER_USER }}:-1000
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 }}
labels: ${{ steps.meta.outputs.labels }}
-
name: Cleanup Docker after build
if: always()
run: |
docker system prune -af
docker volume prune -f
build-and-push-y-provider:
runs-on: ubuntu-latest
@@ -100,7 +118,7 @@ jobs:
images: lasuite/impress-y-provider
-
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
-
name: Run trivy scan
@@ -116,16 +134,22 @@ jobs:
file: ./src/frontend/servers/y-provider/Dockerfile
target: y-provider
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 }}
labels: ${{ steps.meta.outputs.labels }}
-
name: Cleanup Docker after build
if: always()
run: |
docker system prune -af
docker volume prune -f
notify-argocd:
needs:
- build-and-push-frontend
- build-and-push-backend
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:
- uses: numerique-gouv/action-argocd-webhook-notification@main
id: notify

View File

@@ -21,10 +21,10 @@ jobs:
shell: bash
run: |
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")
for env in $environments; do
echo "################### $env lint ###################"
helmfile -e $env -f $HELMFILE lint || exit 1
helmfile -e $env lint -f $HELMFILE || exit 1
echo -e "\n"
done
done

View File

@@ -85,6 +85,9 @@ jobs:
- name: Install Playwright Browsers
run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright chromium
- name: Free disk space before Docker
uses: ./.github/actions/free-disk-space
- name: Start Docker services
run: make bootstrap-e2e FLUSH_ARGS='--no-input'
@@ -101,7 +104,7 @@ jobs:
test-e2e-other-browser:
runs-on: ubuntu-latest
needs: test-e2e-chromium
timeout-minutes: 20
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -124,6 +127,9 @@ jobs:
- name: Install Playwright Browsers
run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright firefox webkit chromium
- name: Free disk space before Docker
uses: ./.github/actions/free-disk-space
- name: Start Docker services
run: make bootstrap-e2e FLUSH_ARGS='--no-input'
@@ -136,3 +142,54 @@ jobs:
name: playwright-other-report
path: src/frontend/apps/e2e/report/
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
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: show
run: git log
- name: Enforce absence of print statements in code
if: always()
run: |
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/impress.yml' | grep "print("
- name: Check absence of fixup commits
if: always()
run: |
! git log | grep 'fixup!'
- name: Install gitlint
if: always()
run: pip install --user requests gitlint
- name: Lint commit messages added to main
if: always()
run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
check-changelog:
@@ -42,7 +46,7 @@ jobs:
github.event_name == 'pull_request'
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
fetch-depth: 50
- name: Check that the CHANGELOG has been modified in the current branch
@@ -52,7 +56,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Check CHANGELOG max line length
run: |
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
@@ -66,7 +70,7 @@ jobs:
if: github.event_name == 'pull_request'
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Install codespell
run: pip install --user codespell
- name: Check for typos
@@ -75,6 +79,7 @@ jobs:
--check-filenames \
--ignore-words-list "Dokument,afterAll,excpt,statics" \
--skip "./git/" \
--skip "**/*.pdf" \
--skip "**/*.po" \
--skip "**/*.pot" \
--skip "**/*.json" \
@@ -87,11 +92,12 @@ jobs:
working-directory: src/backend
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Install Python
uses: actions/setup-python@v3
uses: actions/setup-python@v5
with:
python-version: "3.13.3"
cache: "pip"
- name: Upgrade pip and setuptools
run: pip install --upgrade pip setuptools
- name: Install development dependencies
@@ -184,9 +190,10 @@ jobs:
mc version enable impress/impress-media-storage"
- name: Install Python
uses: actions/setup-python@v3
uses: actions/setup-python@v5
with:
python-version: "3.13.3"
cache: "pip"
- name: Install development dependencies
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

7
.gitignore vendored
View File

@@ -43,6 +43,10 @@ venv.bak/
env.d/development/*.local
env.d/terraform
# Docker
compose.override.yml
docker/auth/*.local
# npm
node_modules
@@ -75,3 +79,6 @@ db.sqlite3
.vscode/
*.iml
.devcontainer
# Cursor rules
.cursorrules

View File

@@ -1,5 +1,3 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0),
@@ -8,21 +6,291 @@ and this project adheres to
## [Unreleased]
### Added
- ✨(helm) redirecting system #1697
- 📱(frontend) add comments for smaller device #1737
- ✨(project) add custom js support via config #1759
### Changed
- ⚡️(frontend) improve accessibility:
- #1248
- #1235
- #1255
- #1262
- #1244
- #1270
- 🥅(frontend) intercept 401 error on GET threads #1754
- 🦺(frontend) check content type pdf on PdfBlock #1756
- ✈️(frontend) pause Posthog when offline #1755
### Fixed
- 🐛(makefile) Windows compatibility fix for Docker volume mounting #1264
- 🐛(minio) fix user permission error with Minio and Windows #1264
- 🐛(frontend) fix tables deletion #1752
- 🐛(frontend) fix children not display when first resize #1753
- 📝(doc) fix publiccode.yml syntax #1770
## [4.2.0] - 2025-12-17
### Added
- ✨(backend) allow to create a new user in a marketing system #1707
- ✨(backend) add async indexation of documents on save (or access save) #1276
- ✨(backend) add debounce mechanism to limit indexation jobs #1276
- ✨(api) add API route to search for indexed documents in Find #1276
- 🥅(frontend) add boundary error page #1728
### Changed
- 🛂(backend) stop throttling collaboration servers #1730
- 🚸(backend) use unaccented full name for user search #1637
- 🌐(backend) internationalize demo #1644
- ♿(frontend) improve accessibility:
-Improve keyboard accessibility for the document tree #1681
### Fixed
- 🐛(frontend) paste content with comments from another document #1732
- 🐛(frontend) Select text + Go back one page crash the app #1733
- 🐛(frontend) fix versioning conflict #1742
## [4.1.0] - 2025-12-09
### Added
- ⚡️(frontend) export html #1669
### Changed
- ♿(frontend) improve accessibility:
- ♿(frontend) add skip to content button for keyboard accessibility #1624
- ♿(frontend) fix toggle panel button a11y labels #1634
- 🔒️(frontend) remove dangerouslySetInnerHTML from codebase #1712
- ⚡️(frontend) improve Comments feature #1687
### Fixed
- 🐛(nginx) fix / location to handle new static pages #1682
- 🐛(frontend) rerendering during resize window #1715
## [4.0.0] - 2025-12-01
### Added
- ✨ Add comments feature to the editor #1330
- ✨(backend) Comments on text editor #1330
- ✨(frontend) link to create new doc #1574
### Changed
- ⚡️(sw) stop to cache external resources likes videos #1655
- 💥(frontend) upgrade to ui-kit v2 #1605
- ⚡️(frontend) improve perf on upload and table of contents #1662
- ♿(frontend) improve accessibility:
- ♿(frontend) improve share modal button accessibility #1626
- ♿(frontend) improve screen reader support in DocShare modal #1628
### Fixed
- 🐛(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
### 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
@@ -34,7 +302,6 @@ and this project adheres to
- ✨(frontend) add duplicate action to doc tree #1175
- ✨(frontend) Interlinking doc #904
- ✨(frontend) add multi columns support for editor #1219
- ✨(api) add API route to fetch document content #1206
### Changed
@@ -695,7 +962,17 @@ and this project adheres to
- ✨(frontend) Coming Soon page (#67)
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/suitenumerique/docs/compare/v3.5.0...main
[unreleased]: https://github.com/suitenumerique/docs/compare/v4.2.0...main
[v4.2.0]: https://github.com/suitenumerique/docs/releases/v4.2.0
[v4.1.0]: https://github.com/suitenumerique/docs/releases/v4.1.0
[v4.0.0]: https://github.com/suitenumerique/docs/releases/v4.0.0
[v3.10.0]: https://github.com/suitenumerique/docs/releases/v3.10.0
[v3.9.0]: https://github.com/suitenumerique/docs/releases/v3.9.0
[v3.8.2]: https://github.com/suitenumerique/docs/releases/v3.8.2
[v3.8.1]: https://github.com/suitenumerique/docs/releases/v3.8.1
[v3.8.0]: https://github.com/suitenumerique/docs/releases/v3.8.0
[v3.7.0]: https://github.com/suitenumerique/docs/releases/v3.7.0
[v3.6.0]: https://github.com/suitenumerique/docs/releases/v3.6.0
[v3.5.0]: https://github.com/suitenumerique/docs/releases/v3.5.0
[v3.4.2]: https://github.com/suitenumerique/docs/releases/v3.4.2
[v3.4.1]: https://github.com/suitenumerique/docs/releases/v3.4.1

View File

@@ -94,6 +94,14 @@ RUN chmod g=u /etc/passwd
# Copy installed python dependencies
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 ./src/backend /app/

View File

@@ -93,13 +93,77 @@ post-bootstrap: \
mails-build
.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: \
pre-beautiful-bootstrap \
pre-bootstrap \
build \
post-bootstrap \
run
run \
post-beautiful-bootstrap
.PHONY: bootstrap
bootstrap-e2e: ## Prepare Docker production images to be used for e2e tests
@@ -183,6 +247,10 @@ demo: ## flush db then create a demo for load testing purpose
@$(MANAGE) create_demo
.PHONY: demo
index: ## index all documents to remote search
@$(MANAGE) index
.PHONY: index
# Nota bene: Black should come after isort just in case they don't agree...
lint: ## lint back-end python sources
lint: \
@@ -342,6 +410,10 @@ run-frontend-development: ## Run the frontend in development mode
cd $(PATH_FRONT_IMPRESS) && yarn dev
.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
cd $(PATH_FRONT) && yarn i18n:extract
.PHONY: frontend-i18n-extract
@@ -372,6 +444,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/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/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)
.PHONY: bump-packages-version

View File

@@ -1,2 +0,0 @@
web: bin/buildpack_start.sh
postdeploy: python manage.py migrate

View File

@@ -49,13 +49,24 @@ Docs is a collaborative text editor designed to address common challenges in kno
* 📚 Turn your team's collaborative work into organized knowledge with Subpages.
### 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 🔧
@@ -130,6 +141,12 @@ To start all the services, except the frontend container, you can use the follow
$ make run-backend
```
To execute frontend tests & linting only
```shellscript
$ make frontend-test
$ make frontend-lint
```
**Adding content**
You can create a basic demo site by running this command:

View File

@@ -16,6 +16,29 @@ the following command inside your docker container:
## [Unreleased]
## [4.0.0] - 2025-11-26
- ⚠️ We updated `@gouvfr-lasuite/ui-kit` to `0.18.0`, so if you are customizing Docs with a css layer or with a custom template, you need to update your customization to follow the new design system structure.
More information about the changes in the design system can be found here:
- https://suitenumerique.github.io/cunningham/storybook/?path=/docs/migrating-from-v3-to-v4--docs
- https://github.com/suitenumerique/docs/pull/1605
- https://github.com/suitenumerique/docs/blob/main/docs/theming.md
- If you were using the `THEME_CUSTOMIZATION_FILE_PATH` and have overridden the header logo, you need to update your customization file to follow the new structure of the header, it is now:
```json
{
...,
"header": {
"icon": {
"src": "your_logo_src",
"width": "your_logo_width",
"height": "your_logo_height"
}
}
}
```
## [3.3.0] - 2025-05-22
⚠️ 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.

View File

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

View File

@@ -1,15 +0,0 @@
#!/bin/bash
set -o errexit # always exit on error
set -o pipefail # don't ignore exit codes when piping output
echo "-----> Running post-compile script"
rm -rf docker docs env.d gitlint src/frontend/apps/e2e
rm -rf src/frontend/apps
rm -rf src/frontend/packages
# Remove some of the larger packages required by the frontend only
rm -rf src/frontend/node_modules/@next src/frontend/node_modules/next src/frontend/node_modules/react-icons src/frontend/node_modules/@gouvfr-lasuite
# du -ch | sort -rh | head -n 100

View File

@@ -1,15 +0,0 @@
#!/bin/bash
set -o errexit # always exit on error
set -o pipefail # don't ignore exit codes when piping output
echo "-----> Running post-frontend script"
# Move the frontend build to the nginx root and clean up
mkdir -p build/
mv src/frontend/apps/impress/out build/frontend-out
mv src/backend/* ./
mv src/nginx/* ./
echo "3.13" > .python-version

View File

@@ -1,18 +0,0 @@
#!/bin/bash
# Start the Django backend server
gunicorn -b :8000 impress.wsgi:application --log-file - &
# Start the Y provider service
cd src/frontend/servers/y-provider && PORT=4444 ../../.scalingo/node/bin/node dist/start-server.js &
# Start the Nginx server
bin/run &
# if the current shell is killed, also terminate all its children
trap "pkill SIGTERM -P $$" SIGTERM
# wait for a single child to finish,
wait -n
# then kill all the other tasks
pkill -P $$

6
bin/fernetkey Executable file
View File

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

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ flowchart TD
Back --> DB("Database (PostgreSQL)")
Back <--> Celery --> DB
Back ----> S3("Minio (S3)")
Back -- REST API --> Find
```
### Architecture decision records

View File

@@ -6,103 +6,113 @@ Here we describe all environment variables that can be set for the docs applicat
These are the environment variables you can set for the `impress-backend` container.
| Option | Description | default |
|-------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------|
| AI_ALLOW_REACH_FROM | Users that can use AI must be this level. options are "public", "authenticated", "restricted" | authenticated |
| AI_API_KEY | AI key to be used for AI Base url | |
| AI_BASE_URL | OpenAI compatible AI base url | |
| AI_FEATURE_ENABLED | Enable AI options | false |
| AI_MODEL | AI Model to use | |
| ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true |
| API_USERS_LIST_LIMIT | Limit on API users | 5 |
| API_USERS_LIST_THROTTLE_RATE_BURST | Throttle rate for api on burst | 30/minute |
| API_USERS_LIST_THROTTLE_RATE_SUSTAINED | Throttle rate for api | 180/hour |
| AWS_S3_ACCESS_KEY_ID | Access id for s3 endpoint | |
| AWS_S3_ENDPOINT_URL | S3 endpoint | |
| AWS_S3_REGION_NAME | Region name for s3 endpoint | |
| AWS_S3_SECRET_ACCESS_KEY | Access key for s3 endpoint | |
| AWS_STORAGE_BUCKET_NAME | Bucket name for s3 endpoint | impress-media-storage |
| CACHES_DEFAULT_TIMEOUT | Cache default timeout | 30 |
| CACHES_KEY_PREFIX | The prefix used to every cache keys. | docs |
| COLLABORATION_API_URL | Collaboration api host | |
| COLLABORATION_SERVER_SECRET | Collaboration api secret | |
| COLLABORATION_WS_NOT_CONNECTED_READY_ONLY | Users not connected to the collaboration server cannot edit | false |
| COLLABORATION_WS_URL | Collaboration websocket url | |
| CONVERSION_API_CONTENT_FIELD | Conversion api content field | content |
| CONVERSION_API_ENDPOINT | Conversion API endpoint | convert |
| CONVERSION_API_SECURE | Require secure conversion api | false |
| CONVERSION_API_TIMEOUT | Conversion api timeout | 30 |
| CRISP_WEBSITE_ID | Crisp website id for support | |
| DB_ENGINE | Engine to use for database connections | django.db.backends.postgresql_psycopg2 |
| DB_HOST | Host of the database | localhost |
| DB_NAME | Name of the database | impress |
| DB_PASSWORD | Password to authenticate with | pass |
| DB_PORT | Port of the database | 5432 |
| DB_USER | User to authenticate with | dinum |
| DJANGO_ALLOWED_HOSTS | Allowed hosts | [] |
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker transport options | {} |
| DJANGO_CELERY_BROKER_URL | Celery broker url | redis://redis:6379/0 |
| DJANGO_CORS_ALLOW_ALL_ORIGINS | Allow all CORS origins | false |
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | List of origins allowed for CORS using regulair expressions | [] |
| DJANGO_CORS_ALLOWED_ORIGINS | List of origins allowed for CORS | [] |
| DJANGO_CSRF_TRUSTED_ORIGINS | CSRF trusted origins | [] |
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
| DJANGO_EMAIL_BRAND_NAME | Brand name for email | |
| DJANGO_EMAIL_FROM | Email address used as sender | from@example.com |
| DJANGO_EMAIL_HOST | Hostname of email | |
| DJANGO_EMAIL_HOST_PASSWORD | Password to authenticate with on the email host | |
| DJANGO_EMAIL_HOST_USER | User to authenticate with on the email host | |
| DJANGO_EMAIL_LOGO_IMG | Logo for the email | |
| DJANGO_EMAIL_PORT | Port used to connect to email host | |
| DJANGO_EMAIL_USE_SSL | Use ssl for email host connection | false |
| DJANGO_EMAIL_USE_TLS | Use tls for email host connection | false |
| DJANGO_SECRET_KEY | Secret key | |
| DJANGO_SERVER_TO_SERVER_API_TOKENS | | [] |
| DOCUMENT_IMAGE_MAX_SIZE | Maximum size of document in bytes | 10485760 |
| FRONTEND_CSS_URL | To add a external css file to the app | |
| FRONTEND_HOMEPAGE_FEATURE_ENABLED | Frontend feature flag to display the homepage | false |
| FRONTEND_THEME | Frontend theme to use | |
| LANGUAGE_CODE | Default language | en-us |
| LOGGING_LEVEL_LOGGERS_APP | Application logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
| LOGGING_LEVEL_LOGGERS_ROOT | Default logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
| LOGIN_REDIRECT_URL | Login redirect url | |
| LOGIN_REDIRECT_URL_FAILURE | Login redirect url on failure | |
| LOGOUT_REDIRECT_URL | Logout redirect url | |
| MALWARE_DETECTION_BACKEND | The malware detection backend use from the django-lasuite package | lasuite.malware_detection.backends.dummy.DummyBackend |
| MALWARE_DETECTION_PARAMETERS | A dict containing all the parameters to initiate the malware detection backend | {"callback_path": "core.malware_detection.malware_detection_callback",} |
| MEDIA_BASE_URL | | |
| NO_WEBSOCKET_CACHE_TIMEOUT | Cache used to store current editor session key when only users without websocket are editing a document | 120 |
| OIDC_ALLOW_DUPLICATE_EMAILS | Allow duplicate emails | false |
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth parameters | {} |
| OIDC_CREATE_USER | Create used on OIDC | false |
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | true |
| OIDC_OP_AUTHORIZATION_ENDPOINT | Authorization endpoint for OIDC | |
| OIDC_OP_JWKS_ENDPOINT | JWKS endpoint for OIDC | |
| OIDC_OP_LOGOUT_ENDPOINT | Logout endpoint for OIDC | |
| OIDC_OP_TOKEN_ENDPOINT | Token endpoint for OIDC | |
| OIDC_OP_USER_ENDPOINT | User endpoint for OIDC | |
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed hosts for OIDC redirect url | [] |
| OIDC_REDIRECT_REQUIRE_HTTPS | Require https for OIDC redirect url | false |
| OIDC_RP_CLIENT_ID | Client id used for OIDC | impress |
| OIDC_RP_CLIENT_SECRET | Client secret used for OIDC | |
| OIDC_RP_SCOPES | Scopes requested for OIDC | openid email |
| OIDC_RP_SIGN_ALGO | verification algorithm used OIDC tokens | RS256 |
| OIDC_STORE_ID_TOKEN | Store OIDC token | true |
| OIDC_USE_NONCE | Use nonce for OIDC | true |
| OIDC_USERINFO_FULLNAME_FIELDS | OIDC token claims to create full name | ["first_name", "last_name"] |
| OIDC_USERINFO_SHORTNAME_FIELD | OIDC token claims to create shortname | first_name |
| POSTHOG_KEY | Posthog key for analytics | |
| REDIS_URL | Cache url | redis://redis:6379/1 |
| SENTRY_DSN | Sentry host | |
| SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 |
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | | false |
| STORAGES_STATICFILES_BACKEND | | whitenoise.storage.CompressedManifestStaticFilesStorage |
| THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 |
| THEME_CUSTOMIZATION_FILE_PATH | Full path to the file customizing the theme. An example is provided in src/backend/impress/configuration/theme/default.json | BASE_DIR/impress/configuration/theme/default.json |
| TRASHBIN_CUTOFF_DAYS | Trashbin cutoff | 30 |
| USER_OIDC_ESSENTIAL_CLAIMS | Essential claims in OIDC token | [] |
| Y_PROVIDER_API_BASE_URL | Y Provider url | |
| Y_PROVIDER_API_KEY | Y provider API key | |
| Option | Description | default |
|-------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------|
| AI_ALLOW_REACH_FROM | Users that can use AI must be this level. options are "public", "authenticated", "restricted" | authenticated |
| AI_API_KEY | AI key to be used for AI Base url | |
| AI_BASE_URL | OpenAI compatible AI base url | |
| AI_FEATURE_ENABLED | Enable AI options | false |
| AI_MODEL | AI Model to use | |
| ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true |
| API_USERS_LIST_LIMIT | Limit on API users | 5 |
| API_USERS_LIST_THROTTLE_RATE_BURST | Throttle rate for api on burst | 30/minute |
| API_USERS_LIST_THROTTLE_RATE_SUSTAINED | Throttle rate for api | 180/hour |
| AWS_S3_ACCESS_KEY_ID | Access id for s3 endpoint | |
| AWS_S3_ENDPOINT_URL | S3 endpoint | |
| AWS_S3_REGION_NAME | Region name for s3 endpoint | |
| AWS_S3_SECRET_ACCESS_KEY | Access key for s3 endpoint | |
| AWS_STORAGE_BUCKET_NAME | Bucket name for s3 endpoint | impress-media-storage |
| CACHES_DEFAULT_TIMEOUT | Cache default timeout | 30 |
| CACHES_KEY_PREFIX | The prefix used to every cache keys. | docs |
| COLLABORATION_API_URL | Collaboration api host | |
| COLLABORATION_SERVER_SECRET | Collaboration api secret | |
| COLLABORATION_WS_NOT_CONNECTED_READY_ONLY | Users not connected to the collaboration server cannot edit | false |
| COLLABORATION_WS_URL | Collaboration websocket url | |
| CONVERSION_API_CONTENT_FIELD | Conversion api content field | content |
| CONVERSION_API_ENDPOINT | Conversion API endpoint | convert |
| CONVERSION_API_SECURE | Require secure conversion api | false |
| CONVERSION_API_TIMEOUT | Conversion api timeout | 30 |
| CRISP_WEBSITE_ID | Crisp website id for support | |
| DB_ENGINE | Engine to use for database connections | django.db.backends.postgresql_psycopg2 |
| DB_HOST | Host of the database | localhost |
| DB_NAME | Name of the database | impress |
| DB_PASSWORD | Password to authenticate with | pass |
| DB_PORT | Port of the database | 5432 |
| DB_USER | User to authenticate with | dinum |
| DJANGO_ALLOWED_HOSTS | Allowed hosts | [] |
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker transport options | {} |
| DJANGO_CELERY_BROKER_URL | Celery broker url | redis://redis:6379/0 |
| DJANGO_CORS_ALLOWED_ORIGINS | List of origins allowed for CORS | [] |
| 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_CSRF_TRUSTED_ORIGINS | CSRF trusted origins | [] |
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
| DJANGO_EMAIL_BRAND_NAME | Brand name for email | |
| DJANGO_EMAIL_FROM | Email address used as sender | from@example.com |
| DJANGO_EMAIL_HOST | Hostname of email | |
| DJANGO_EMAIL_HOST_PASSWORD | Password to authenticate with on the email host | |
| DJANGO_EMAIL_HOST_USER | User to authenticate with on the email host | |
| DJANGO_EMAIL_LOGO_IMG | Logo for the email | |
| DJANGO_EMAIL_PORT | Port used to connect to email host | |
| DJANGO_EMAIL_USE_SSL | Use ssl for email host connection | false |
| DJANGO_EMAIL_USE_TLS | Use tls for email host connection | false |
| DJANGO_SECRET_KEY | Secret key | |
| DJANGO_SERVER_TO_SERVER_API_TOKENS | | [] |
| DOCUMENT_IMAGE_MAX_SIZE | Maximum size of document in bytes | 10485760 |
| FRONTEND_CSS_URL | To add a external css file to the app | |
| FRONTEND_JS_URL | To add a external js file to the app | |
| FRONTEND_HOMEPAGE_FEATURE_ENABLED | Frontend feature flag to display the homepage | false |
| FRONTEND_THEME | Frontend theme to use | |
| LANGUAGE_CODE | Default language | en-us |
| LASUITE_MARKETING_BACKEND | Backend used when SIGNUP_NEW_USER_TO_MARKETING_EMAIL is True. See https://github.com/suitenumerique/django-lasuite/blob/main/documentation/how-to-use-marketing-backend.md | lasuite.marketing.backends.dummy.DummyBackend |
| LASUITE_MARKETING_PARAMETERS | The parameters to configure LASUITE_MARKETING_BACKEND. See https://github.com/suitenumerique/django-lasuite/blob/main/documentation/how-to-use-marketing-backend.md | {} |
| LOGGING_LEVEL_LOGGERS_APP | Application logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
| LOGGING_LEVEL_LOGGERS_ROOT | Default logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
| LOGIN_REDIRECT_URL | Login redirect url | |
| LOGIN_REDIRECT_URL_FAILURE | Login redirect url on failure | |
| LOGOUT_REDIRECT_URL | Logout redirect url | |
| MALWARE_DETECTION_BACKEND | The malware detection backend use from the django-lasuite package | lasuite.malware_detection.backends.dummy.DummyBackend |
| MALWARE_DETECTION_PARAMETERS | A dict containing all the parameters to initiate the malware detection backend | {"callback_path": "core.malware_detection.malware_detection_callback",} |
| MEDIA_BASE_URL | | |
| NO_WEBSOCKET_CACHE_TIMEOUT | Cache used to store current editor session key when only users without websocket are editing a document | 120 |
| OIDC_ALLOW_DUPLICATE_EMAILS | Allow duplicate emails | false |
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth parameters | {} |
| OIDC_CREATE_USER | Create used on OIDC | false |
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | true |
| OIDC_OP_AUTHORIZATION_ENDPOINT | Authorization endpoint for OIDC | |
| OIDC_OP_JWKS_ENDPOINT | JWKS endpoint for OIDC | |
| OIDC_OP_LOGOUT_ENDPOINT | Logout endpoint for OIDC | |
| OIDC_OP_TOKEN_ENDPOINT | Token endpoint for OIDC | |
| OIDC_OP_USER_ENDPOINT | User endpoint for OIDC | |
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed hosts for OIDC redirect url | [] |
| OIDC_REDIRECT_REQUIRE_HTTPS | Require https for OIDC redirect url | false |
| OIDC_RP_CLIENT_ID | Client id used for OIDC | impress |
| OIDC_RP_CLIENT_SECRET | Client secret used for OIDC | |
| OIDC_RP_SCOPES | Scopes requested for OIDC | openid email |
| OIDC_RP_SIGN_ALGO | verification algorithm used OIDC tokens | RS256 |
| OIDC_STORE_ID_TOKEN | Store OIDC token | true |
| OIDC_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_USE_NONCE | Use nonce for OIDC | true |
| POSTHOG_KEY | Posthog key for analytics | |
| REDIS_URL | Cache url | redis://redis:6379/1 |
| SEARCH_INDEXER_BATCH_SIZE | Size of each batch for indexation of all documents | 100000 |
| SEARCH_INDEXER_CLASS | Class of the backend for document indexation & search | |
| SEARCH_INDEXER_COUNTDOWN | Minimum debounce delay of indexation jobs (in seconds) | 1 |
| SEARCH_INDEXER_QUERY_LIMIT | Maximum number of results expected from search endpoint | 50 |
| SEARCH_INDEXER_SECRET | Token for indexation queries | |
| SEARCH_INDEXER_URL | Find application endpoint for indexation | |
| SENTRY_DSN | Sentry host | |
| SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 |
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | Register new user to the marketing onboarding. If True, see env LASUITE_MARKETING_* system | False |
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | | false |
| STORAGES_STATICFILES_BACKEND | | whitenoise.storage.CompressedManifestStaticFilesStorage |
| THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 |
| THEME_CUSTOMIZATION_FILE_PATH | Full path to the file customizing the theme. An example is provided in src/backend/impress/configuration/theme/default.json | BASE_DIR/impress/configuration/theme/default.json |
| TRASHBIN_CUTOFF_DAYS | Trashbin cutoff | 30 |
| USER_OIDC_ESSENTIAL_CLAIMS | Essential claims in OIDC token | [] |
| Y_PROVIDER_API_BASE_URL | Y Provider url | |
| Y_PROVIDER_API_KEY | Y provider API key | |
## impress-frontend image
@@ -135,9 +145,9 @@ 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 |
Packages with licences incompatible with the MIT licence:
* `xl-docx-exporter`: [AGPL-3.0](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-docx-exporter/LICENSE),
* `xl-pdf-exporter`: [AGPL-3.0](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE),
* `xl-multi-column`: [AGPL-3.0](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-multi-column/LICENSE).
* `xl-docx-exporter`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-docx-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.

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,12 @@
djangoSecretKey: &djangoSecretKey "lkjsdlfkjsldkfjslkdfjslkdjfslkdjf"
djangoSuperUserEmail: admin@example.com
djangoSuperUserPass: admin
aiApiKey: changeme
aiBaseUrl: changeme
oidc:
clientId: impress
clientSecret: ThisIsAnExampleKeyForDevPurposeOnly
image:
repository: lasuite/impress-backend
pullPolicy: Always
@@ -6,86 +15,98 @@ image:
backend:
replicas: 1
envVars:
COLLABORATION_API_URL: https://impress.127.0.0.1.nip.io/collaboration/api/
COLLABORATION_SERVER_SECRET: my-secret
DJANGO_CSRF_TRUSTED_ORIGINS: https://impress.127.0.0.1.nip.io
DJANGO_CSRF_TRUSTED_ORIGINS: https://docs.127.0.0.1.nip.io
DJANGO_CONFIGURATION: Feature
DJANGO_ALLOWED_HOSTS: impress.127.0.0.1.nip.io
DJANGO_ALLOWED_HOSTS: docs.127.0.0.1.nip.io
DJANGO_SERVER_TO_SERVER_API_TOKENS: secret-api-key
DJANGO_SECRET_KEY: AgoodOrAbadKey
DJANGO_SECRET_KEY: *djangoSecretKey
DJANGO_SETTINGS_MODULE: impress.settings
DJANGO_SUPERUSER_PASSWORD: admin
DJANGO_EMAIL_BRAND_NAME: "La Suite Numérique"
DJANGO_EMAIL_HOST: "mailcatcher"
DJANGO_EMAIL_LOGO_IMG: https://impress.127.0.0.1.nip.io/assets/logo-suite-numerique.png
DJANGO_EMAIL_LOGO_IMG: https://docs.127.0.0.1.nip.io/assets/logo-suite-numerique.png
DJANGO_EMAIL_PORT: 1025
DJANGO_EMAIL_USE_SSL: False
LOGGING_LEVEL_HANDLERS_CONSOLE: ERROR
LOGGING_LEVEL_LOGGERS_ROOT: INFO
LOGGING_LEVEL_LOGGERS_APP: INFO
OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/certs
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_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_RP_CLIENT_ID: impress
OIDC_USERINFO_SHORTNAME_FIELD: "given_name"
OIDC_USERINFO_FULLNAME_FIELDS: "given_name,usual_name"
OIDC_OP_JWKS_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/docs/protocol/openid-connect/certs
OIDC_OP_AUTHORIZATION_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/docs/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/docs/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/docs/protocol/openid-connect/userinfo
OIDC_OP_LOGOUT_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/docs/protocol/openid-connect/logout
OIDC_RP_CLIENT_ID: docs
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
OIDC_RP_SIGN_ALGO: RS256
OIDC_RP_SCOPES: "openid email"
OIDC_VERIFY_SSL: False
OIDC_USERINFO_SHORTNAME_FIELD: "given_name"
OIDC_USERINFO_FULLNAME_FIELDS: "given_name,usual_name"
OIDC_REDIRECT_ALLOWED_HOSTS: https://impress.127.0.0.1.nip.io
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
LOGIN_REDIRECT_URL: https://impress.127.0.0.1.nip.io
LOGIN_REDIRECT_URL_FAILURE: https://impress.127.0.0.1.nip.io
LOGOUT_REDIRECT_URL: https://impress.127.0.0.1.nip.io
POSTHOG_KEY: "{'id': 'posthog_key', 'host': 'https://product.impress.127.0.0.1.nip.io'}"
DB_HOST: postgresql
DB_NAME: impress
DB_USER: dinum
DB_PASSWORD: pass
LOGIN_REDIRECT_URL: https://docs.127.0.0.1.nip.io
LOGIN_REDIRECT_URL_FAILURE: https://docs.127.0.0.1.nip.io
LOGOUT_REDIRECT_URL: https://docs.127.0.0.1.nip.io
DB_HOST: postgresql-dev-backend-postgres
DB_NAME:
secretKeyRef:
name: postgresql-dev-backend-postgres
key: database
DB_USER:
secretKeyRef:
name: postgresql-dev-backend-postgres
key: username
DB_PASSWORD:
secretKeyRef:
name: postgresql-dev-backend-postgres
key: password
DB_PORT: 5432
REDIS_URL: redis://default:pass@redis-master:6379/1
AWS_S3_ENDPOINT_URL: http://minio.impress.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: root
REDIS_URL: redis://user:pass@redis-dev-backend-redis:6379/1
DJANGO_CELERY_BROKER_URL: redis://user:pass@redis-dev-backend-redis:6379/1
AWS_S3_ENDPOINT_URL: http://minio-dev-backend-minio.impress.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: dinum
AWS_S3_SECRET_ACCESS_KEY: password
AWS_STORAGE_BUCKET_NAME: impress-media-storage
AWS_STORAGE_BUCKET_NAME: docs-media-storage
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
Y_PROVIDER_API_BASE_URL: http://impress-y-provider:443/api/
Y_PROVIDER_API_KEY: my-secret
CACHES_KEY_PREFIX: "{{ now | unixEpoch }}"
migrate:
command:
- "/bin/sh"
- "-c"
- |
python manage.py migrate --no-input &&
python manage.py create_demo --force
restartPolicy: Never
while ! python manage.py check --database default > /dev/null 2>&1
do
echo "Database not ready"
sleep 2
done
command:
- "gunicorn"
- "-c"
- "/usr/local/etc/gunicorn/impress.py"
- "impress.wsgi:application"
- "--reload"
echo "Database is ready"
python manage.py migrate --no-input
restartPolicy: Never
createsuperuser:
command:
- "/bin/sh"
- "-c"
- |
while ! python manage.py check --database default > /dev/null 2>&1
do
echo "Database not ready"
sleep 2
done
echo "Database is ready"
python manage.py createsuperuser --email admin@example.com --password admin
restartPolicy: Never
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
# Extra volume mounts to manage our local custom CA and avoid to set ssl_verify: false
extraVolumeMounts:
- name: certs
mountPath: /usr/local/lib/python3.13/site-packages/certifi/cacert.pem
mountPath: /cert/cacert.pem
subPath: cacert.pem
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
# Extra volumes to manage our local custom CA and avoid to set ssl_verify: false
extraVolumes:
- name: certs
configMap:
@@ -94,12 +115,7 @@ backend:
- key: cacert.pem
path: cacert.pem
frontend:
envVars:
PORT: 8080
NEXT_PUBLIC_API_ORIGIN: https://impress.127.0.0.1.nip.io
replicas: 1
image:
repository: lasuite/impress-frontend
pullPolicy: Always
@@ -114,60 +130,47 @@ yProvider:
tag: "latest"
envVars:
COLLABORATION_BACKEND_BASE_URL: https://docs.127.0.0.1.nip.io
COLLABORATION_LOGGING: true
COLLABORATION_SERVER_ORIGIN: https://impress.127.0.0.1.nip.io
COLLABORATION_SERVER_ORIGIN: https://docs.127.0.0.1.nip.io
COLLABORATION_SERVER_SECRET: my-secret
Y_PROVIDER_API_KEY: my-secret
COLLABORATION_BACKEND_BASE_URL: https://impress.127.0.0.1.nip.io
NODE_EXTRA_CA_CERTS: /usr/local/share/ca-certificates/cacert.pem
# Mount the certificate so yProvider can establish tls with the backend
extraVolumeMounts:
- name: certs
mountPath: /usr/local/share/ca-certificates/cacert.pem
subPath: cacert.pem
ingress:
enabled: true
host: docs.127.0.0.1.nip.io
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: 100m
extraVolumes:
- name: certs
configMap:
name: certifi
items:
- key: cacert.pem
path: cacert.pem
ingressCollaborationWS:
enabled: true
host: docs.127.0.0.1.nip.io
ingressCollaborationApi:
enabled: true
host: docs.127.0.0.1.nip.io
ingressAdmin:
enabled: true
host: docs.127.0.0.1.nip.io
posthog:
ingress:
enabled: false
ingressAssets:
enabled: false
ingress:
enabled: true
host: impress.127.0.0.1.nip.io
ingressCollaborationWS:
enabled: true
host: impress.127.0.0.1.nip.io
ingressCollaborationApi:
enabled: true
host: impress.127.0.0.1.nip.io
ingressAdmin:
enabled: true
host: impress.127.0.0.1.nip.io
ingressMedia:
enabled: true
host: impress.127.0.0.1.nip.io
host: docs.127.0.0.1.nip.io
annotations:
nginx.ingress.kubernetes.io/auth-url: https://impress.127.0.0.1.nip.io/api/v1.0/documents/media-auth/
nginx.ingress.kubernetes.io/auth-url: https://docs.127.0.0.1.nip.io/api/v1.0/documents/media-auth/
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
nginx.ingress.kubernetes.io/upstream-vhost: minio.impress.svc.cluster.local:9000
nginx.ingress.kubernetes.io/rewrite-target: /impress-media-storage/$1
nginx.ingress.kubernetes.io/upstream-vhost: minio-dev-backend-minio.impress.svc.cluster.local:9000
nginx.ingress.kubernetes.io/rewrite-target: /docs-media-storage/$1
serviceMedia:
host: minio.impress.svc.cluster.local
host: minio-dev-backend-minio.impress.svc.cluster.local
port: 9000

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,24 @@
auth:
rootUser: root
rootPassword: password
provisioning:
minio:
enabled: true
buckets:
- name: impress-media-storage
versioning: true
image: minio/minio
name: minio
# serviceNameOverride: docs-minio
ingress:
enabled: true
hostname: docs-minio.127.0.0.1.nip.io
tls:
enabled: true
secretName: docs-tls
consoleIngress:
enabled: true
hostname: docs-minio-console.127.0.0.1.nip.io
tls:
enabled: true
secretName: docs-tls
api:
port: 80
username: dinum
password: password
bucket: docs-media-storage
versioning: true
size: 1Gi

View File

@@ -1,7 +1,9 @@
auth:
postgres:
enabled: true
name: postgres
#serviceNameOverride: postgres
image: postgres:16-alpine
username: dinum
password: pass
database: impress
tls:
enabled: true
autoGenerated: true
database: dinum
size: 1Gi

View File

@@ -1,4 +1,7 @@
auth:
password: pass
architecture: standalone
redis:
enabled: true
name: redis
#serviceNameOverride: redis
image: redis:8.2-alpine
username: user
password: pass

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

@@ -7,7 +7,7 @@ This document is a step-by-step guide that describes how to install Docs on a k8
- k8s cluster with an nginx-ingress controller
- an OIDC provider (if you don't have one, we provide an example)
- a PostgreSQL server (if you don't have one, we provide an example)
- a Memcached server (if you don't have one, we provide an example)
- a Redis server (if you don't have one, we provide an example)
- a S3 bucket (if you don't have one, we provide an example)
### Test cluster
@@ -100,50 +100,66 @@ When your k8s cluster is ready (the ingress nginx controller is up), you can sta
Please remember that `*.127.0.0.1.nip.io` will always resolve to `127.0.0.1`, except in the k8s cluster where we configure CoreDNS to answer with the ingress-nginx service IP.
The namespace `impress` is already created, you can work in it and configure your kubectl cli to use it by default.
```
$ kubectl config set-context --current --namespace=impress
```
## Preparation
We provide our own helm chart for all development dependencies, it is available here https://github.com/suitenumerique/helm-dev-backend
This provided chart is for development purpose only and is not ready to use in production.
You can install it on your cluster to deploy keycloak, minio, postgresql and redis.
### What do you use to authenticate your users?
Docs uses OIDC, so if you already have an OIDC provider, obtain the necessary information to use it. In the next step, we will see how to configure Django (and thus Docs) to use it. If you do not have a provider, we will show you how to deploy a local Keycloak instance (this is not a production deployment, just a demo).
```
$ kubectl create namespace impress
$ kubectl config set-context --current --namespace=impress
$ helm install keycloak oci://registry-1.docker.io/bitnamicharts/keycloak -f examples/keycloak.values.yaml
$ helm install --repo https://suitenumerique.github.io/helm-dev-backend -f docs/examples/helm/keycloak.values.yaml keycloak dev-backend
$ #wait until
$ kubectl get po
NAME READY STATUS RESTARTS AGE
keycloak-0 1/1 Running 0 6m48s
keycloak-postgresql-0 1/1 Running 0 6m48s
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
keycloak-dev-backend-keycloak-0 1/1 Running 0 20s
keycloak-dev-backend-keycloak-pg-0 1/1 Running 0 20s
```
From here the important information you will need are:
```yaml
OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/certs
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_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_JWKS_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/certs
OIDC_OP_AUTHORIZATION_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/userinfo
OIDC_OP_LOGOUT_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/logout
OIDC_RP_CLIENT_ID: impress
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
OIDC_RP_SIGN_ALGO: RS256
OIDC_RP_SCOPES: "openid email"
```
You can find these values in **examples/keycloak.values.yaml**
You can find these values in **examples/helm/keycloak.values.yaml**
### Find redis server connection values
Docs needs a redis so we start by deploying one:
```
$ helm install redis oci://registry-1.docker.io/bitnamicharts/redis -f examples/redis.values.yaml
$ kubectl get po
NAME READY STATUS RESTARTS AGE
keycloak-0 1/1 Running 0 26m
keycloak-postgresql-0 1/1 Running 0 26m
redis-master-0 1/1 Running 0 35s
$ helm install --repo https://suitenumerique.github.io/helm-dev-backend -f docs/examples/helm/redis.values.yaml redis dev-backend
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
keycloak-dev-backend-keycloak-0 1/1 Running 0 113s
keycloak-dev-backend-keycloak-pg-0 1/1 Running 0 113s
redis-dev-backend-redis-68c9f66786-4dgxj 1/1 Running 0 2s
```
From here the important information you will need are:
```yaml
REDIS_URL: redis://user:pass@redis-dev-backend-redis:6379/1
DJANGO_CELERY_BROKER_URL: redis://user:pass@redis-dev-backend-redis:6379/1
```
### Find postgresql connection values
@@ -151,22 +167,32 @@ redis-master-0 1/1 Running 0 35s
Docs uses a postgresql database as backend, so if you have a provider, obtain the necessary information to use it. If you don't, you can install a postgresql testing environment as follow:
```
$ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/postgresql.values.yaml
$ kubectl get po
NAME READY STATUS RESTARTS AGE
keycloak-0 1/1 Running 0 28m
keycloak-postgresql-0 1/1 Running 0 28m
postgresql-0 1/1 Running 0 14m
redis-master-0 1/1 Running 0 42s
$ helm install --repo https://suitenumerique.github.io/helm-dev-backend -f docs/examples/helm/postgresql.values.yaml postgresql dev-backend
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
keycloak-dev-backend-keycloak-0 1/1 Running 0 3m42s
keycloak-dev-backend-keycloak-pg-0 1/1 Running 0 3m42s
postgresql-dev-backend-postgres-0 1/1 Running 0 13s
redis-dev-backend-redis-68c9f66786-4dgxj 1/1 Running 0 111s
```
From here the important information you will need are:
```yaml
DB_HOST: postgres-postgresql
DB_NAME: impress
DB_USER: dinum
DB_PASSWORD: pass
DB_HOST: postgresql-dev-backend-postgres
DB_NAME:
secretKeyRef:
name: postgresql-dev-backend-postgres
key: database
DB_USER:
secretKeyRef:
name: postgresql-dev-backend-postgres
key: username
DB_PASSWORD:
secretKeyRef:
name: postgresql-dev-backend-postgres
key: password
DB_PORT: 5432
```
@@ -175,15 +201,15 @@ DB_PORT: 5432
Docs uses an s3 bucket to store documents, so if you have a provider obtain the necessary information to use it. If you don't, you can install a local minio testing environment as follow:
```
$ helm install minio oci://registry-1.docker.io/bitnamicharts/minio -f examples/minio.values.yaml
$ kubectl get po
NAME READY STATUS RESTARTS AGE
keycloak-0 1/1 Running 0 38m
keycloak-postgresql-0 1/1 Running 0 38m
minio-84f5c66895-bbhsk 1/1 Running 0 42s
minio-provisioning-2b5sq 0/1 Completed 0 42s
postgresql-0 1/1 Running 0 24m
redis-master-0 1/1 Running 0 10m
$ helm install --repo https://suitenumerique.github.io/helm-dev-backend -f docs/examples/helm/minio.values.yaml minio dev-backend
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
keycloak-dev-backend-keycloak-0 1/1 Running 0 6m12s
keycloak-dev-backend-keycloak-pg-0 1/1 Running 0 6m12s
minio-dev-backend-minio-0 1/1 Running 0 10s
postgresql-dev-backend-postgres-0 1/1 Running 0 2m43s
redis-dev-backend-redis-68c9f66786-4dgxj 1/1 Running 0 4m21s
```
## Deployment
@@ -193,20 +219,18 @@ Now you are ready to deploy Docs without AI. AI requires more dependencies (Open
```
$ helm repo add impress https://suitenumerique.github.io/docs/
$ helm repo update
$ helm install impress impress/docs -f examples/impress.values.yaml
$ helm install impress impress/docs -f docs/examples/helm/impress.values.yaml
$ kubectl get po
NAME READY STATUS RESTARTS AGE
impress-docs-backend-96558758d-xtkbp 0/1 Running 0 79s
impress-docs-backend-createsuperuser-r7ltc 0/1 Completed 0 79s
impress-docs-backend-migrate-c949s 0/1 Completed 0 79s
impress-docs-frontend-6749f644f7-p5s42 1/1 Running 0 79s
impress-docs-y-provider-6947fd8f54-78f2l 1/1 Running 0 79s
keycloak-0 1/1 Running 0 48m
keycloak-postgresql-0 1/1 Running 0 48m
minio-84f5c66895-bbhsk 1/1 Running 0 10m
minio-provisioning-2b5sq 0/1 Completed 0 10m
postgresql-0 1/1 Running 0 34m
redis-master-0 1/1 Running 0 20m
NAME READY STATUS RESTARTS AGE
impress-docs-backend-8494fb797d-8k8wt 1/1 Running 0 6m45s
impress-docs-celery-worker-764b5dd98f-9qd6v 1/1 Running 0 6m45s
impress-docs-frontend-5b69b65cc4-s8pps 1/1 Running 0 6m45s
impress-docs-y-provider-5fc7ccd8cc-6ttrf 1/1 Running 0 6m45s
keycloak-dev-backend-keycloak-0 1/1 Running 0 24m
keycloak-dev-backend-keycloak-pg-0 1/1 Running 0 24m
minio-dev-backend-minio-0 1/1 Running 0 8m24s
postgresql-dev-backend-postgres-0 1/1 Running 0 20m
redis-dev-backend-redis-68c9f66786-4dgxj 1/1 Running 0 22m
```
## Test your deployment
@@ -215,13 +239,15 @@ In order to test your deployment you have to log into your instance. If you excl
```
$ kubectl get ingress
NAME CLASS HOSTS ADDRESS PORTS AGE
impress-docs <none> impress.127.0.0.1.nip.io localhost 80, 443 114s
impress-docs-admin <none> impress.127.0.0.1.nip.io localhost 80, 443 114s
impress-docs-collaboration-api <none> impress.127.0.0.1.nip.io localhost 80, 443 114s
impress-docs-media <none> impress.127.0.0.1.nip.io localhost 80, 443 114s
impress-docs-ws <none> impress.127.0.0.1.nip.io localhost 80, 443 114s
keycloak <none> keycloak.127.0.0.1.nip.io localhost 80 49m
NAME CLASS HOSTS ADDRESS PORTS AGE
impress-docs <none> docs.127.0.0.1.nip.io localhost 80, 443 7m9s
impress-docs-admin <none> docs.127.0.0.1.nip.io localhost 80, 443 7m9s
impress-docs-collaboration-api <none> docs.127.0.0.1.nip.io localhost 80, 443 7m9s
impress-docs-media <none> docs.127.0.0.1.nip.io localhost 80, 443 7m9s
impress-docs-ws <none> docs.127.0.0.1.nip.io localhost 80, 443 7m9s
keycloak-dev-backend-keycloak <none> docs-keycloak.127.0.0.1.nip.io localhost 80, 443 24m
minio-dev-backend-minio-api <none> docs-minio.127.0.0.1.nip.io localhost 80, 443 8m48s
minio-dev-backend-minio-console <none> docs-minio-console.127.0.0.1.nip.io localhost 80, 443 8m48s
```
You can use Docs at https://impress.127.0.0.1.nip.io. The provisionning user in keycloak is impress/impress.
You can use Docs at https://docs.127.0.0.1.nip.io. The provisionning user in keycloak is docs/docs.

41
docs/search.md Normal file
View File

@@ -0,0 +1,41 @@
# Setup the Find search for Impress
This configuration will enable the fulltext search feature for Docs :
- Each save on **core.Document** or **core.DocumentAccess** will trigger the indexer
- The `api/v1.0/documents/search/` will work as a proxy with the Find API for fulltext search.
## Create an index service for Docs
Configure a **Service** for Docs application with these settings
- **Name**: `docs`<br>_request.auth.name of the Docs application._
- **Client id**: `impress`<br>_Name of the token audience or client_id of the Docs application._
See [how-to-use-indexer.md](how-to-use-indexer.md) for details.
## Configure settings of Docs
Add those Django settings the Docs application to enable the feature.
```shell
SEARCH_INDEXER_CLASS="core.services.search_indexers.FindDocumentIndexer"
SEARCH_INDEXER_COUNTDOWN=10 # Debounce delay in seconds for the indexer calls.
# The token from service "docs" of Find application (development).
SEARCH_INDEXER_SECRET="find-api-key-for-docs-with-exactly-50-chars-length"
SEARCH_INDEXER_URL="http://find:8000/api/v1.0/documents/index/"
# Search endpoint. Uses the OIDC token for authentication
SEARCH_INDEXER_QUERY_URL="http://find:8000/api/v1.0/documents/search/"
# Maximum number of results expected from the search endpoint
SEARCH_INDEXER_QUERY_LIMIT=50
```
We also need to enable the **OIDC Token** refresh or the authentication will fail quickly.
```shell
# Store OIDC tokens in the session
OIDC_STORE_ACCESS_TOKEN = True # Store the access token in the session
OIDC_STORE_REFRESH_TOKEN = True # Store the encrypted refresh token in the session
OIDC_STORE_REFRESH_TOKEN_KEY = "your-32-byte-encryption-key==" # Must be a valid Fernet key (32 url-safe base64-encoded bytes)
```

View File

@@ -97,6 +97,17 @@ Production deployments differ significantly from development environments. The t
| 5433 | PostgreSQL (Keycloak) |
| 1081 | MailCatcher |
**With fulltext search service**
| Port | Service |
| --------- | --------------------- |
| 8081 | Find (Django) |
| 9200 | Opensearch |
| 9600 | Opensearch admin |
| 5601 | Opensearch dashboard |
| 25432 | PostgreSQL (Find) |
## 6. Sizing Guidelines
**RAM** start at 8 GB dev / 16 GB staging / 32 GB prod. Postgres and Keycloak are the first to OOM; scale them first.

View File

@@ -8,7 +8,7 @@ To use this feature, simply set the `FRONTEND_CSS_URL` environment variable to t
FRONTEND_CSS_URL=http://anything/custom-style.css
```
Once you've set this variable, our application will load your custom CSS file and apply the styles to our frontend application.
Once you've set this variable, Docs will load your custom CSS file and apply the styles to our frontend application.
### Benefits
@@ -32,6 +32,79 @@ Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom
----
# Runtime JavaScript Injection 🚀
### How to Use
To use this feature, simply set the `FRONTEND_JS_URL` environment variable to the URL of your custom JavaScript file. For example:
```javascript
FRONTEND_JS_URL=http://anything/custom-script.js
```
Once you've set this variable, Docs will load your custom JavaScript file and execute it in the browser, allowing you to modify the application's behavior at runtime.
### Benefits
This feature provides several benefits, including:
* **Dynamic customization** 🔄: With this feature, you can dynamically modify the behavior and appearance of our application without requiring any code changes.
* **Flexibility** 🌈: You can add custom functionality, modify existing features, or integrate third-party services.
* **Runtime injection** ⏱️: This feature allows you to inject JavaScript into the application at runtime, without requiring a restart or recompilation.
### Example Use Case
Let's say you want to add a custom menu to the application header. You can create a custom JavaScript file with the following contents:
```javascript
(function() {
'use strict';
function initCustomMenu() {
// Wait for the page to be fully loaded
const header = document.querySelector('header');
if (!header) return false;
// Create and inject your custom menu
const customMenu = document.createElement('div');
customMenu.innerHTML = '<button>Custom Menu</button>';
header.appendChild(customMenu);
console.log('Custom menu added successfully');
return true;
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initCustomMenu);
} else {
initCustomMenu();
}
})();
```
Then, set the `FRONTEND_JS_URL` environment variable to the URL of your custom JavaScript file. Once you've done this, our application will load your custom JavaScript file and execute it, adding your custom menu to the header.
----
# **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** 📝
The footer is configurable from the theme customization file.

View File

@@ -36,6 +36,7 @@ OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/c
OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8083/realms/impress/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/userinfo
OIDC_OP_INTROSPECTION_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/token/introspect
OIDC_RP_CLIENT_ID=impress
OIDC_RP_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
@@ -49,6 +50,14 @@ LOGOUT_REDIRECT_URL=http://localhost:3000
OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
# Store OIDC tokens in the session. Needed by search/ endpoint.
# OIDC_STORE_ACCESS_TOKEN = True
# OIDC_STORE_REFRESH_TOKEN = True # Store the encrypted refresh token in the session.
# Must be a valid Fernet key (32 url-safe base64-encoded bytes)
# To create one, use the bin/fernetkey command.
# OIDC_STORE_REFRESH_TOKEN_KEY="your-32-byte-encryption-key=="
# AI
AI_FEATURE_ENABLED=true
AI_BASE_URL=https://openaiendpoint.com
@@ -66,3 +75,12 @@ COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/
DJANGO_SERVER_TO_SERVER_API_TOKENS=server-api-token
Y_PROVIDER_API_BASE_URL=http://y-provider-development:4444/api/
Y_PROVIDER_API_KEY=yprovider-api-key
# Theme customization
THEME_CUSTOMIZATION_CACHE_TIMEOUT=15
# Indexer (disabled)
# SEARCH_INDEXER_CLASS="core.services.search_indexers.SearchIndexer"
SEARCH_INDEXER_SECRET=find-api-key-for-docs-with-exactly-50-chars-length # Key generated by create_demo in Find app.
SEARCH_INDEXER_URL="http://find:8000/api/v1.0/documents/index/"
SEARCH_INDEXER_QUERY_URL="http://find:8000/api/v1.0/documents/search/"

View File

@@ -3,3 +3,7 @@ BURST_THROTTLE_RATES="200/minute"
COLLABORATION_API_URL=http://y-provider:4444/collaboration/api/
SUSTAINED_THROTTLE_RATES="200/hour"
Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/
# 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_SIGN_ALGO=RS256
OIDC_RP_SCOPES="openid email"
#USER_OIDC_FIELD_TO_SHORTNAME
#USER_OIDC_FIELDS_TO_FULLNAME
#OIDC_USERINFO_SHORTNAME_FIELD
#OIDC_USERINFO_FULLNAME_FIELDS
LOGIN_REDIRECT_URL=https://${DOCS_HOST}
LOGIN_REDIRECT_URL_FAILURE=https://${DOCS_HOST}

View File

@@ -1,16 +1,18 @@
publiccodeYmlVersion: "2.4.0"
publiccodeYmlVersion: "0.5.0"
name: Docs
url: https://github.com/suitenumerique/docs
landingURL: https://github.com/suitenumerique/docs
creationDate: 2023-12-10
logo: https://raw.githubusercontent.com/suitenumerique/docs/main/docs/assets/docs-logo.png
usedBy:
- Direction interministériel du numérique (DINUM)
- Direction interministérielle du numérique (DINUM)
fundedBy:
- name: Direction interministériel du numérique (DINUM)
url: https://www.numerique.gouv.fr
- name: Direction interministérielle du numérique (DINUM)
uri: https://www.numerique.gouv.fr
roadmap: "https://github.com/orgs/suitenumerique/projects/2/views/1"
softwareType: "standalone/other"
platforms:
- "web"
developmentStatus: "stable"
description:
en:
shortDescription: "The open source document editor where your notes can become knowledge through live collaboration"
@@ -18,10 +20,18 @@ description:
shortDescription: "L'éditeur de documents open source où vos notes peuvent devenir des connaissances grâce à la collaboration en direct."
legal:
license: MIT
localisation:
localisationReady: true
availableLanguages:
- de
- en
- es
- fr
- nl
maintenance:
type: internal
contacts:
- name: "Virgile Deville"
email: "virgile.deville@numerique.gouv.fr"
- name: "samuel.paccoud"
- name: "Samuel Paccoud"
email: "samuel.paccoud@numerique.gouv.fr"

View File

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

View File

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

View File

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

View File

@@ -171,3 +171,19 @@ class ResourceAccessPermission(IsAuthenticated):
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."""
# pylint: disable=too-many-lines
import binascii
import mimetypes
@@ -7,12 +8,13 @@ from base64 import b64decode
from django.conf import settings
from django.db.models import Q
from django.utils.functional import lazy
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
import magic
from rest_framework import serializers
from core import choices, enums, models, utils
from core import choices, enums, models, utils, validators
from core.services.ai_services import AI_ACTIONS
from core.services.converter_services import (
ConversionError,
@@ -23,11 +25,30 @@ from core.services.converter_services import (
class UserSerializer(serializers.ModelSerializer):
"""Serialize users."""
full_name = serializers.SerializerMethodField(read_only=True)
short_name = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.User
fields = ["id", "email", "full_name", "short_name", "language"]
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):
"""Serialize users with limited fields."""
@@ -70,6 +91,7 @@ class ListDocumentSerializer(serializers.ModelSerializer):
nb_accesses_direct = serializers.IntegerField(read_only=True)
user_role = serializers.SerializerMethodField(read_only=True)
abilities = serializers.SerializerMethodField(read_only=True)
deleted_at = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.Document
@@ -82,6 +104,7 @@ class ListDocumentSerializer(serializers.ModelSerializer):
"computed_link_role",
"created_at",
"creator",
"deleted_at",
"depth",
"excerpt",
"is_favorite",
@@ -104,6 +127,7 @@ class ListDocumentSerializer(serializers.ModelSerializer):
"computed_link_role",
"created_at",
"creator",
"deleted_at",
"depth",
"excerpt",
"is_favorite",
@@ -145,6 +169,10 @@ class ListDocumentSerializer(serializers.ModelSerializer):
request = self.context.get("request")
return instance.get_role(request.user) if request else None
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."""
@@ -173,6 +201,7 @@ class DocumentSerializer(ListDocumentSerializer):
"content",
"created_at",
"creator",
"deleted_at",
"depth",
"excerpt",
"is_favorite",
@@ -196,6 +225,7 @@ class DocumentSerializer(ListDocumentSerializer):
"computed_link_role",
"created_at",
"creator",
"deleted_at",
"depth",
"is_favorite",
"link_role",
@@ -402,7 +432,7 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
content = serializers.CharField(required=True)
# User
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)
language = serializers.ChoiceField(
@@ -486,6 +516,10 @@ class LinkDocumentSerializer(serializers.ModelSerializer):
We expose it separately from document in order to simplify and secure access control.
"""
link_reach = serializers.ChoiceField(
choices=models.LinkReachChoices.choices, required=True
)
class Meta:
model = models.Document
fields = [
@@ -493,6 +527,58 @@ class LinkDocumentSerializer(serializers.ModelSerializer):
"link_reach",
]
def validate(self, attrs):
"""Validate that link_role and link_reach are compatible using get_select_options."""
link_reach = attrs.get("link_reach")
link_role = attrs.get("link_role")
if not link_reach:
raise serializers.ValidationError(
{"link_reach": _("This field is required.")}
)
# Get available options based on ancestors' link definition
available_options = models.LinkReachChoices.get_select_options(
**self.instance.ancestors_link_definition
)
# Validate link_reach is allowed
if link_reach not in available_options:
msg = _(
"Link reach '%(link_reach)s' is not allowed based on parent document configuration."
)
raise serializers.ValidationError(
{"link_reach": msg % {"link_reach": link_reach}}
)
# Validate link_role is compatible with link_reach
allowed_roles = available_options[link_reach]
# Restricted reach: link_role must be None
if link_reach == models.LinkReachChoices.RESTRICTED:
if link_role is not None:
raise serializers.ValidationError(
{
"link_role": (
"Cannot set link_role when link_reach is 'restricted'. "
"Link role must be null for restricted reach."
)
}
)
return attrs
# Non-restricted: link_role must be in allowed roles
if link_role not in allowed_roles:
allowed_roles_str = ", ".join(allowed_roles) if allowed_roles else "none"
raise serializers.ValidationError(
{
"link_role": (
f"Link role '{link_role}' is not allowed for link reach '{link_reach}'. "
f"Allowed roles: {allowed_roles_str}"
)
}
)
return attrs
class DocumentDuplicationSerializer(serializers.Serializer):
"""
@@ -664,6 +750,9 @@ class InvitationSerializer(serializers.ModelSerializer):
if self.instance is None:
attrs["issuer"] = user
if attrs.get("email"):
attrs["email"] = attrs["email"].lower()
return attrs
def validate_role(self, role):
@@ -698,7 +787,9 @@ class DocumentAskForAccessCreateSerializer(serializers.Serializer):
"""Serializer for creating a document ask for access."""
role = serializers.ChoiceField(
choices=models.RoleChoices.choices,
choices=[
role for role in choices.RoleChoices if role != models.RoleChoices.OWNER
],
required=False,
default=models.RoleChoices.READER,
)
@@ -722,11 +813,11 @@ class DocumentAskForAccessSerializer(serializers.ModelSerializer):
]
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."""
request = self.context.get("request")
if request:
return invitation.get_abilities(request.user)
return instance.get_abilities(request.user)
return {}
@@ -801,3 +892,134 @@ class MoveDocumentSerializer(serializers.Serializer):
choices=enums.MoveNodePositionChoices.choices,
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 {}
class SearchDocumentSerializer(serializers.Serializer):
"""Serializer for fulltext search requests through Find application"""
q = serializers.CharField(required=True, allow_blank=False, trim_whitespace=True)
page_size = serializers.IntegerField(
required=False, min_value=1, max_value=50, default=20
)
page = serializers.IntegerField(required=False, min_value=1, default=1)

View File

@@ -0,0 +1,51 @@
"""Throttling modules for the API."""
from django.conf import settings
from lasuite.drf.throttling import MonitoredScopedRateThrottle
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"
class DocumentThrottle(MonitoredScopedRateThrottle):
"""
Throttle for document-related endpoints, with an exception for requests from the
collaboration server.
"""
scope = "document"
def allow_request(self, request, view):
"""
Override to skip throttling for requests from the collaboration server.
Verifies the X-Y-Provider-Key header contains a valid Y_PROVIDER_API_KEY.
Using a custom header instead of Authorization to avoid triggering
authentication middleware.
"""
y_provider_header = request.headers.get("X-Y-Provider-Key", "")
# Check if this is a valid y-provider request and exempt from throttling
y_provider_key = getattr(settings, "Y_PROVIDER_API_KEY", None)
if y_provider_key and y_provider_header == y_provider_key:
return True
return super().allow_request(request, view)

View File

@@ -1,4 +1,5 @@
"""API endpoints"""
# pylint: disable=too-many-lines
import base64
@@ -14,12 +15,15 @@ from django.contrib.postgres.search import TrigramSimilarity
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.core.validators import URLValidator
from django.db import connection, transaction
from django.db import models as db
from django.db.models.expressions import RawSQL
from django.db.models.functions import Left, Length
from django.db.models.functions import Greatest, Left, Length
from django.http import Http404, StreamingHttpResponse
from django.urls import reverse
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.utils.functional import cached_property
from django.utils.text import capfirst, slugify
from django.utils.translation import gettext_lazy as _
@@ -30,12 +34,13 @@ from botocore.exceptions import ClientError
from csp.constants import NONE
from csp.decorators import csp_update
from lasuite.malware_detection import malware_detection
from lasuite.oidc_login.decorators import refresh_oidc_access_token
from rest_framework import filters, status, viewsets
from rest_framework import response as drf_response
from rest_framework.permissions import AllowAny
from rest_framework.throttling import UserRateThrottle
from core import authentication, choices, enums, models
from core.api.filters import remove_accents
from core.services.ai_services import AIService
from core.services.collaboration_services import CollaborationService
from core.services.converter_services import (
@@ -47,11 +52,20 @@ from core.services.converter_services import (
from core.services.converter_services import (
YdocConverter,
)
from core.services.search_indexers import (
get_document_indexer,
get_visited_document_ids_of,
)
from core.tasks.mail import send_ask_for_access_mail
from core.utils import extract_attachments, filter_descendants
from . import permissions, serializers, utils
from .filters import DocumentFilter, ListDocumentFilter
from .filters import DocumentFilter, ListDocumentFilter, UserSearchFilter
from .throttling import (
DocumentThrottle,
UserListThrottleBurst,
UserListThrottleSustained,
)
logger = logging.getLogger(__name__)
@@ -145,18 +159,6 @@ class Pagination(drf.pagination.PageNumberPagination):
page_size_query_param = "page_size"
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"
class UserViewSet(
drf.mixins.UpdateModelMixin, viewsets.GenericViewSet, drf.mixins.ListModelMixin
):
@@ -187,18 +189,26 @@ class UserViewSet(
if self.action != "list":
return queryset
filterset = UserSearchFilter(
self.request.GET, queryset=queryset, request=self.request
)
if not filterset.is_valid():
raise drf.exceptions.ValidationError(filterset.errors)
# Exclude all users already in the given document
if document_id := self.request.query_params.get("document_id", ""):
queryset = queryset.exclude(documentaccess__document_id=document_id)
if not (query := self.request.query_params.get("q", "")) or len(query) < 5:
return queryset.none()
filter_data = filterset.form.cleaned_data
query = remove_accents(filter_data["q"])
# For emails, match emails by Levenstein distance to prevent typing errors
if "@" in query:
return (
queryset.annotate(
distance=RawSQL("levenshtein(email::text, %s::text)", (query,))
distance=RawSQL(
"levenshtein(unaccent(email::text), %s::text)", (query,)
)
)
.filter(distance__lte=3)
.order_by("distance", "email")[: settings.API_USERS_LIST_LIMIT]
@@ -207,11 +217,15 @@ class UserViewSet(
# Use trigram similarity for non-email-like queries
# For performance reasons we filter first by similarity, which relies on an
# index, then only calculate precise similarity scores for sorting purposes
return (
queryset.filter(email__trigram_word_similar=query)
.annotate(similarity=TrigramSimilarity("email", query))
queryset.annotate(
sim_email=TrigramSimilarity("email", query),
sim_name=TrigramSimilarity("full_name", query),
)
.annotate(similarity=Greatest("sim_email", "sim_name"))
.filter(similarity__gt=0.2)
.order_by("-similarity", "email")[: settings.API_USERS_LIST_LIMIT]
.order_by("-similarity")[: settings.API_USERS_LIST_LIMIT]
)
@drf.decorators.action(
@@ -369,7 +383,9 @@ class DocumentViewSet(
permission_classes = [
permissions.DocumentPermission,
]
queryset = models.Document.objects.all()
throttle_classes = [DocumentThrottle]
throttle_scope = "document"
queryset = models.Document.objects.select_related("creator").all()
serializer_class = serializers.DocumentSerializer
ai_translate_serializer_class = serializers.AITranslateSerializer
children_serializer_class = serializers.ListDocumentSerializer
@@ -377,6 +393,7 @@ class DocumentViewSet(
list_serializer_class = serializers.ListDocumentSerializer
trashbin_serializer_class = serializers.ListDocumentSerializer
tree_serializer_class = serializers.ListDocumentSerializer
search_serializer_class = serializers.ListDocumentSerializer
def get_queryset(self):
"""Get queryset performing all annotation and filtering on the document tree structure."""
@@ -627,12 +644,32 @@ class DocumentViewSet(
The selected documents are those deleted within the cutoff period defined in the
settings (see TRASHBIN_CUTOFF_DAYS), before they are considered permanently deleted.
"""
if not request.user.is_authenticated:
return self.get_response_for_queryset(self.queryset.none())
access_documents_paths = (
models.DocumentAccess.objects.select_related("document")
.filter(
db.Q(user=self.request.user) | db.Q(team__in=self.request.user.teams),
role=models.RoleChoices.OWNER,
)
.values_list("document__path", flat=True)
)
if not access_documents_paths:
return self.get_response_for_queryset(self.queryset.none())
children_clause = db.Q()
for path in access_documents_paths:
children_clause |= db.Q(path__startswith=path)
queryset = self.queryset.filter(
children_clause,
deleted_at__isnull=False,
deleted_at__gte=models.get_trashbin_cutoff(),
)
queryset = queryset.annotate_user_roles(self.request.user)
queryset = queryset.filter(user_roles__contains=[models.RoleChoices.OWNER])
return self.get_response_for_queryset(queryset)
@@ -796,7 +833,11 @@ class DocumentViewSet(
)
# GET: List children
queryset = document.get_children().filter(ancestors_deleted_at__isnull=True)
queryset = (
document.get_children()
.select_related("creator")
.filter(ancestors_deleted_at__isnull=True)
)
queryset = self.filter_queryset(queryset)
filterset = DocumentFilter(request.GET, queryset=queryset)
@@ -850,26 +891,48 @@ class DocumentViewSet(
user = self.request.user
try:
current_document = self.queryset.only("depth", "path").get(pk=pk)
current_document = (
self.queryset.select_related(None)
.only("depth", "path", "ancestors_deleted_at")
.get(pk=pk)
)
except models.Document.DoesNotExist as excpt:
raise drf.exceptions.NotFound() from excpt
ancestors = (
(current_document.get_ancestors() | self.queryset.filter(pk=pk))
.filter(ancestors_deleted_at__isnull=True)
.order_by("path")
)
is_deleted = current_document.ancestors_deleted_at is not None
# Get the highest readable ancestor
highest_readable = (
ancestors.readable_per_se(request.user).only("depth", "path").first()
)
if highest_readable is None:
raise (
drf.exceptions.PermissionDenied()
if request.user.is_authenticated
else drf.exceptions.NotAuthenticated()
if is_deleted:
if current_document.get_role(user) != models.RoleChoices.OWNER:
raise (
drf.exceptions.PermissionDenied()
if request.user.is_authenticated
else drf.exceptions.NotAuthenticated()
)
highest_readable = current_document
ancestors = self.queryset.select_related(None).filter(pk=pk)
else:
ancestors = (
(
current_document.get_ancestors()
| self.queryset.select_related(None).filter(pk=pk)
)
.filter(ancestors_deleted_at__isnull=True)
.order_by("path")
)
# Get the highest readable ancestor
highest_readable = (
ancestors.select_related(None)
.readable_per_se(request.user)
.only("depth", "path")
.first()
)
if highest_readable is None:
raise (
drf.exceptions.PermissionDenied()
if request.user.is_authenticated
else drf.exceptions.NotAuthenticated()
)
paths_links_mapping = {}
ancestors_links = []
children_clause = db.Q()
@@ -890,7 +953,12 @@ class DocumentViewSet(
children = self.queryset.filter(children_clause, deleted_at__isnull=True)
queryset = ancestors.filter(depth__gte=highest_readable.depth) | children
queryset = (
ancestors.select_related("creator").filter(
depth__gte=highest_readable.depth
)
| children
)
queryset = queryset.order_by("path")
queryset = queryset.annotate_user_roles(user)
queryset = queryset.annotate_is_favorite(user)
@@ -928,37 +996,64 @@ class DocumentViewSet(
in the payload.
"""
# Get document while checking permissions
document = self.get_object()
document_to_duplicate = self.get_object()
serializer = serializers.DocumentDuplicationSerializer(
data=request.data, partial=True
)
serializer.is_valid(raise_exception=True)
with_accesses = serializer.validated_data.get("with_accesses", False)
is_owner_or_admin = document.get_role(request.user) in models.PRIVILEGED_ROLES
user_role = document_to_duplicate.get_role(request.user)
is_owner_or_admin = user_role in models.PRIVILEGED_ROLES
base64_yjs_content = document.content
base64_yjs_content = document_to_duplicate.content
# Duplicate the document instance
link_kwargs = (
{"link_reach": document.link_reach, "link_role": document.link_role}
{
"link_reach": document_to_duplicate.link_reach,
"link_role": document_to_duplicate.link_role,
}
if with_accesses
else {}
)
extracted_attachments = set(extract_attachments(document.content))
attachments = list(extracted_attachments & set(document.attachments))
duplicated_document = document.add_sibling(
extracted_attachments = set(extract_attachments(document_to_duplicate.content))
attachments = list(
extracted_attachments & set(document_to_duplicate.attachments)
)
title = capfirst(_("copy of {title}").format(title=document_to_duplicate.title))
if not document_to_duplicate.is_root() and choices.RoleChoices.get_priority(
user_role
) < choices.RoleChoices.get_priority(models.RoleChoices.EDITOR):
duplicated_document = models.Document.add_root(
creator=self.request.user,
title=title,
content=base64_yjs_content,
attachments=attachments,
duplicated_from=document_to_duplicate,
**link_kwargs,
)
models.DocumentAccess.objects.create(
document=duplicated_document,
user=self.request.user,
role=models.RoleChoices.OWNER,
)
return drf_response.Response(
{"id": str(duplicated_document.id)}, status=status.HTTP_201_CREATED
)
duplicated_document = document_to_duplicate.add_sibling(
"right",
title=capfirst(_("copy of {title}").format(title=document.title)),
title=title,
content=base64_yjs_content,
attachments=attachments,
duplicated_from=document,
duplicated_from=document_to_duplicate,
creator=request.user,
**link_kwargs,
)
# Always add the logged-in user as OWNER for root documents
if document.is_root():
if document_to_duplicate.is_root():
accesses_to_create = [
models.DocumentAccess(
document=duplicated_document,
@@ -970,7 +1065,7 @@ class DocumentViewSet(
# If accesses should be duplicated, add other users' accesses as per original document
if with_accesses and is_owner_or_admin:
original_accesses = models.DocumentAccess.objects.filter(
document=document
document=document_to_duplicate
).exclude(user=request.user)
accesses_to_create.extend(
@@ -990,6 +1085,83 @@ class DocumentViewSet(
{"id": str(duplicated_document.id)}, status=status.HTTP_201_CREATED
)
def _search_simple(self, request, text):
"""
Returns a queryset filtered by the content of the document title
"""
# As the 'list' view we get a prefiltered queryset (deleted docs are excluded)
queryset = self.get_queryset()
filterset = DocumentFilter({"title": text}, queryset=queryset)
if not filterset.is_valid():
raise drf.exceptions.ValidationError(filterset.errors)
queryset = filterset.filter_queryset(queryset)
return self.get_response_for_queryset(
queryset.order_by("-updated_at"),
context={
"request": request,
},
)
def _search_fulltext(self, indexer, request, params):
"""
Returns a queryset from the results the fulltext search of Find
"""
access_token = request.session.get("oidc_access_token")
user = request.user
text = params.validated_data["q"]
queryset = models.Document.objects.all()
# Retrieve the documents ids from Find.
results = indexer.search(
text=text,
token=access_token,
visited=get_visited_document_ids_of(queryset, user),
)
docs_by_uuid = {str(d.pk): d for d in queryset.filter(pk__in=results)}
ordered_docs = [docs_by_uuid[id] for id in results]
page = self.paginate_queryset(ordered_docs)
serializer = self.get_serializer(
page if page else ordered_docs,
many=True,
context={
"request": request,
},
)
return self.get_paginated_response(serializer.data)
@drf.decorators.action(detail=False, methods=["get"], url_path="search")
@method_decorator(refresh_oidc_access_token)
def search(self, request, *args, **kwargs):
"""
Returns a DRF response containing the filtered, annotated and ordered document list.
Applies filtering based on request parameter 'q' from `SearchDocumentSerializer`.
Depending of the configuration it can be:
- A fulltext search through the opensearch indexation app "find" if the backend is
enabled (see SEARCH_INDEXER_CLASS)
- A filtering by the model field 'title'.
The ordering is always by the most recent first.
"""
params = serializers.SearchDocumentSerializer(data=request.query_params)
params.is_valid(raise_exception=True)
indexer = get_document_indexer()
if indexer:
return self._search_fulltext(indexer, request, params=params)
# The indexer is not configured, we fallback on a simple icontains filter by the
# model field 'title'.
return self._search_simple(request, text=params.validated_data["q"])
@drf.decorators.action(detail=True, methods=["get"], url_path="versions")
def versions_list(self, request, *args, **kwargs):
"""
@@ -1031,7 +1203,7 @@ class DocumentViewSet(
@drf.decorators.action(
detail=True,
methods=["get", "delete"],
url_path="versions/(?P<version_id>[0-9a-z-]+)",
url_path=r"versions/(?P<version_id>[A-Za-z0-9._+\-=~]{1,1024})",
)
# pylint: disable=unused-argument
def versions_detail(self, request, pk, version_id, *args, **kwargs):
@@ -1292,7 +1464,8 @@ class DocumentViewSet(
)
attachments_documents = (
self.queryset.filter(attachments__contains=[key])
self.queryset.select_related(None)
.filter(attachments__contains=[key])
.only("path")
.order_by("path")
)
@@ -1451,6 +1624,15 @@ class DocumentViewSet(
url = unquote(url)
url_validator = URLValidator(schemes=["http", "https"])
try:
url_validator(url)
except drf.exceptions.ValidationError as e:
return drf.response.Response(
{"detail": str(e)},
status=drf.status.HTTP_400_BAD_REQUEST,
)
try:
response = requests.get(
url,
@@ -1481,10 +1663,10 @@ class DocumentViewSet(
return proxy_response
except requests.RequestException as e:
logger.error("Proxy request failed: %s", str(e))
return drf_response.Response(
{"error": f"Failed to fetch resource: {e!s}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
logger.exception(e)
return drf.response.Response(
{"error": f"Failed to fetch resource from {url}"},
status=status.HTTP_400_BAD_REQUEST,
)
@drf.decorators.action(
@@ -1600,6 +1782,7 @@ class DocumentAccessViewSet(
"document__depth",
)
resource_field_name = "document"
throttle_scope = "document_access"
@cached_property
def document(self):
@@ -1746,10 +1929,7 @@ class DocumentAccessViewSet(
class TemplateViewSet(
drf.mixins.CreateModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""Template ViewSet"""
@@ -1759,6 +1939,7 @@ class TemplateViewSet(
permissions.IsAuthenticatedOrSafe,
permissions.ResourceWithAccessPermission,
]
throttle_scope = "template"
ordering = ["-created_at"]
ordering_fields = ["created_at", "updated_at", "title"]
serializer_class = serializers.TemplateSerializer
@@ -1804,99 +1985,6 @@ class TemplateViewSet(
serializer = self.get_serializer(queryset, many=True)
return drf.response.Response(serializer.data)
@transaction.atomic
def perform_create(self, serializer):
"""Set the current user as owner of the newly created object."""
obj = serializer.save()
models.TemplateAccess.objects.create(
template=obj,
user=self.request.user,
role=models.RoleChoices.OWNER,
)
class TemplateAccessViewSet(
ResourceAccessViewsetMixin,
drf.mixins.CreateModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""
API ViewSet for all interactions with template accesses.
GET /api/v1.0/templates/<template_id>/accesses/:<template_access_id>
Return list of all template accesses related to the logged-in user or one
template access if an id is provided.
POST /api/v1.0/templates/<template_id>/accesses/ with expected data:
- user: str
- role: str [administrator|editor|reader]
Return newly created template access
PUT /api/v1.0/templates/<template_id>/accesses/<template_access_id>/ with expected data:
- role: str [owner|admin|editor|reader]
Return updated template access
PATCH /api/v1.0/templates/<template_id>/accesses/<template_access_id>/ with expected data:
- role: str [owner|admin|editor|reader]
Return partially updated template access
DELETE /api/v1.0/templates/<template_id>/accesses/<template_access_id>/
Delete targeted template access
"""
lookup_field = "pk"
permission_classes = [permissions.ResourceAccessPermission]
queryset = models.TemplateAccess.objects.select_related("user").all()
resource_field_name = "template"
serializer_class = serializers.TemplateAccessSerializer
@cached_property
def template(self):
"""Get related template from resource ID in url."""
try:
return models.Template.objects.get(pk=self.kwargs["resource_id"])
except models.Template.DoesNotExist as excpt:
raise drf.exceptions.NotFound() from excpt
def list(self, request, *args, **kwargs):
"""Restrict templates returned by the list endpoint"""
user = self.request.user
teams = user.teams
queryset = self.filter_queryset(self.get_queryset())
# Limit to resource access instances related to a resource THAT also has
# a resource access instance for the logged-in user (we don't want to list
# only the resource access instances pointing to the logged-in user)
queryset = queryset.filter(
db.Q(template__accesses__user=user)
| db.Q(template__accesses__team__in=teams),
).distinct()
serializer = self.get_serializer(queryset, many=True)
return drf.response.Response(serializer.data)
def perform_create(self, serializer):
"""
Actually create the new template access:
- Ensures the `template_id` is explicitly set from the URL.
- If the assigned role is `OWNER`, checks that the requesting user is an owner
of the document. This is the only permission check deferred until this step;
all other access checks are handled earlier in the permission lifecycle.
"""
role = serializer.validated_data.get("role")
if (
role == choices.RoleChoices.OWNER
and self.template.get_role(self.request.user) != choices.RoleChoices.OWNER
):
raise drf.exceptions.PermissionDenied(
"Only owners of a template can assign other users as owners."
)
serializer.save(template_id=self.kwargs["resource_id"])
class InvitationViewset(
drf.mixins.CreateModelMixin,
@@ -1931,6 +2019,7 @@ class InvitationViewset(
permissions.CanCreateInvitationPermission,
permissions.ResourceWithAccessPermission,
]
throttle_scope = "invitation"
queryset = (
models.Invitation.objects.all()
.select_related("document")
@@ -2009,6 +2098,7 @@ class DocumentAskForAccessViewSet(
permissions.IsAuthenticated,
permissions.ResourceWithAccessPermission,
]
throttle_scope = "document_ask_for_access"
queryset = models.DocumentAskForAccess.objects.all()
serializer_class = serializers.DocumentAskForAccessSerializer
_document = None
@@ -2073,7 +2163,18 @@ class DocumentAskForAccessViewSet(
serializer = serializers.RoleSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
document_ask_for_access.accept(role=serializer.validated_data.get("role"))
target_role = serializer.validated_data.get(
"role", document_ask_for_access.role
)
abilities = document_ask_for_access.get_abilities(request.user)
if target_role not in abilities["set_role_to"]:
return drf.response.Response(
{"detail": "You cannot accept a role higher than your own."},
status=drf.status.HTTP_400_BAD_REQUEST,
)
document_ask_for_access.accept(role=target_role)
return drf.response.Response(status=drf.status.HTTP_204_NO_CONTENT)
@@ -2081,6 +2182,7 @@ class ConfigView(drf.views.APIView):
"""API ViewSet for sharing some public settings."""
permission_classes = [AllowAny]
throttle_scope = "config"
def get(self, request):
"""
@@ -2095,12 +2197,14 @@ class ConfigView(drf.views.APIView):
"ENVIRONMENT",
"FRONTEND_CSS_URL",
"FRONTEND_HOMEPAGE_FEATURE_ENABLED",
"FRONTEND_JS_URL",
"FRONTEND_THEME",
"MEDIA_BASE_URL",
"POSTHOG_KEY",
"LANGUAGES",
"LANGUAGE_CODE",
"SENTRY_DSN",
"TRASHBIN_CUTOFF_DAYS",
]
dict_settings = {}
for setting in array_settings:
@@ -2145,3 +2249,132 @@ class ConfigView(drf.views.APIView):
)
return theme_customization
class CommentViewSetMixin:
"""Comment ViewSet Mixin."""
_document = None
def get_document_or_404(self):
"""Get the document related to the viewset or raise a 404 error."""
if self._document is None:
try:
self._document = models.Document.objects.get(
pk=self.kwargs["resource_id"],
)
except models.Document.DoesNotExist as e:
raise drf.exceptions.NotFound("Document not found.") from e
return self._document
class ThreadViewSet(
ResourceAccessViewsetMixin,
CommentViewSetMixin,
drf.mixins.CreateModelMixin,
drf.mixins.ListModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
"""Thread API: list/create threads and nested comment operations."""
permission_classes = [permissions.CommentPermission]
pagination_class = Pagination
serializer_class = serializers.ThreadSerializer
queryset = models.Thread.objects.select_related("creator", "document").filter(
resolved=False
)
resource_field_name = "document"
def perform_create(self, serializer):
"""Create the first comment of the thread."""
body = serializer.validated_data["body"]
del serializer.validated_data["body"]
thread = serializer.save()
models.Comment.objects.create(
thread=thread,
user=self.request.user if self.request.user.is_authenticated else None,
body=body,
)
@drf.decorators.action(detail=True, methods=["post"], url_path="resolve")
def resolve(self, request, *args, **kwargs):
"""Resolve a thread."""
thread = self.get_object()
if not thread.resolved:
thread.resolved = True
thread.resolved_at = timezone.now()
thread.resolved_by = request.user
thread.save(update_fields=["resolved", "resolved_at", "resolved_by"])
return drf.response.Response(status=status.HTTP_204_NO_CONTENT)
class CommentViewSet(
CommentViewSetMixin,
viewsets.ModelViewSet,
):
"""Comment API: list/create comments and nested reaction operations."""
permission_classes = [permissions.CommentPermission]
pagination_class = Pagination
serializer_class = serializers.CommentSerializer
queryset = models.Comment.objects.select_related("user").all()
def get_queryset(self):
"""Override to filter on related resource."""
return (
super()
.get_queryset()
.filter(
thread=self.kwargs["thread_id"],
thread__document=self.kwargs["resource_id"],
)
)
def get_serializer_context(self):
"""Extra context provided to the serializer class."""
context = super().get_serializer_context()
context["document_id"] = self.kwargs["resource_id"]
context["thread_id"] = self.kwargs["thread_id"]
return context
@drf.decorators.action(
detail=True,
methods=["post", "delete"],
)
def reactions(self, request, *args, **kwargs):
"""POST: add reaction; DELETE: remove reaction.
Emoji is expected in request.data['emoji'] for both operations.
"""
comment = self.get_object()
serializer = serializers.ReactionSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
if request.method == "POST":
reaction, created = models.Reaction.objects.get_or_create(
comment=comment,
emoji=serializer.validated_data["emoji"],
)
if not created and reaction.users.filter(id=request.user.id).exists():
return drf.response.Response(
{"user_already_reacted": True}, status=status.HTTP_400_BAD_REQUEST
)
reaction.users.add(request.user)
return drf.response.Response(status=status.HTTP_201_CREATED)
# DELETE
try:
reaction = models.Reaction.objects.get(
comment=comment,
emoji=serializer.validated_data["emoji"],
users__in=[request.user],
)
except models.Reaction.DoesNotExist as e:
raise drf.exceptions.NotFound("Reaction not found.") from e
reaction.users.remove(request.user)
if not reaction.users.exists():
reaction.delete()
return drf.response.Response(status=status.HTTP_204_NO_CONTENT)

View File

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

View File

@@ -6,6 +6,7 @@ import os
from django.conf import settings
from django.core.exceptions import SuspiciousOperation
from lasuite.marketing.tasks import create_or_update_contact
from lasuite.oidc_login.backends import (
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
)
@@ -57,3 +58,22 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
return self.UserModel.objects.get_user_by_sub_or_email(sub, email)
except DuplicateEmailError as err:
raise SuspiciousOperation(err.message) from err
def post_get_or_create_user(self, user, claims, is_new_user):
"""
Post-processing after user creation or retrieval.
Args:
user (User): The user instance.
claims (dict): The claims dictionary.
is_new_user (bool): Indicates if the user was newly created.
Returns:
- None
"""
if is_new_user and settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL:
create_or_update_contact.delay(
email=user.email, attributes={"DOCS_SOURCE": ["SIGNIN"]}
)

View File

@@ -33,6 +33,7 @@ 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
@@ -40,6 +41,7 @@ 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")

View File

@@ -256,3 +256,49 @@ class InvitationFactory(factory.django.DjangoModelFactory):
document = factory.SubFactory(DocumentFactory)
role = factory.fuzzy.FuzzyChoice([role[0] for role in models.RoleChoices.choices])
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,52 @@
"""
Handle search setup that needs to be done at bootstrap time.
"""
import logging
import time
from django.core.management.base import BaseCommand, CommandError
from core.services.search_indexers import get_document_indexer
logger = logging.getLogger("docs.search.bootstrap_search")
class Command(BaseCommand):
"""Index all documents to remote search service"""
help = __doc__
def add_arguments(self, parser):
"""Add argument to require forcing execution when not in debug mode."""
parser.add_argument(
"--batch-size",
action="store",
dest="batch_size",
type=int,
default=50,
help="Indexation query batch size",
)
def handle(self, *args, **options):
"""Launch and log search index generation."""
indexer = get_document_indexer()
if not indexer:
raise CommandError("The indexer is not enabled or properly configured.")
logger.info("Starting to regenerate Find index...")
start = time.perf_counter()
batch_size = options["batch_size"]
try:
count = indexer.index(batch_size=batch_size)
except Exception as err:
raise CommandError("Unable to regenerate index") from err
duration = time.perf_counter() - start
logger.info(
"Search index regenerated from %d document(s) in %.2f seconds.",
count,
duration,
)

View File

@@ -2,6 +2,8 @@
from django.db import migrations, models
import core.validators
class Migration(migrations.Migration):
dependencies = [
@@ -33,4 +35,17 @@ class Migration(migrations.Migration):
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

@@ -0,0 +1,37 @@
# Generated by Django 5.2.8 on 2025-11-20 09:56
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("core", "0026_comments"),
]
operations = [
migrations.RunSQL(
sql="""
CREATE OR REPLACE FUNCTION public.immutable_unaccent(regdictionary, text)
RETURNS text
LANGUAGE c IMMUTABLE PARALLEL SAFE STRICT AS
'$libdir/unaccent', 'unaccent_dict';
CREATE OR REPLACE FUNCTION public.f_unaccent(text)
RETURNS text
LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT
RETURN public.immutable_unaccent(regdictionary 'public.unaccent', $1);
CREATE INDEX IF NOT EXISTS user_email_unaccent_trgm_idx
ON impress_user
USING gin (f_unaccent(email) gin_trgm_ops);
CREATE INDEX IF NOT EXISTS user_full_name_unaccent_trgm_idx
ON impress_user
USING gin (f_unaccent(full_name) gin_trgm_ops);
""",
reverse_sql="""
DROP INDEX IF EXISTS user_email_unaccent_trgm_idx;
DROP INDEX IF EXISTS user_full_name_unaccent_trgm_idx;
""",
),
]

View File

@@ -14,7 +14,7 @@ from django.contrib.auth import models as auth_models
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.postgres.fields import ArrayField
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.files.base import ContentFile
from django.core.files.storage import default_storage
@@ -39,6 +39,7 @@ from .choices import (
RoleChoices,
get_equivalent_link_definition,
)
from .validators import sub_validator
logger = getLogger(__name__)
@@ -136,28 +137,20 @@ class UserManager(auth_models.UserManager):
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"""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"),
help_text=_(
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
),
help_text=_("Required. 255 characters or fewer. ASCII characters only."),
max_length=255,
unique=True,
validators=[sub_validator],
unique=True,
blank=True,
null=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)
@@ -230,7 +223,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
Expired invitations are ignored.
"""
valid_invitations = Invitation.objects.filter(
email=self.email,
email__iexact=self.email,
created_at__gte=(
timezone.now()
- timedelta(seconds=settings.INVITATION_VALIDITY_DURATION)
@@ -439,32 +432,35 @@ class Document(MP_Node, BaseModel):
def save(self, *args, **kwargs):
"""Write content to object storage only if _content has changed."""
super().save(*args, **kwargs)
if self._content:
file_key = self.file_key
bytes_content = self._content.encode("utf-8")
self.save_content(self._content)
# Attempt to directly check if the object exists using the storage client.
try:
response = default_storage.connection.meta.client.head_object(
Bucket=default_storage.bucket_name, Key=file_key
)
except ClientError as excpt:
# If the error is a 404, the object doesn't exist, so we should create it.
if excpt.response["Error"]["Code"] == "404":
has_changed = True
else:
raise
def save_content(self, content):
"""Save content to object storage."""
file_key = self.file_key
bytes_content = content.encode("utf-8")
# Attempt to directly check if the object exists using the storage client.
try:
response = default_storage.connection.meta.client.head_object(
Bucket=default_storage.bucket_name, Key=file_key
)
except ClientError as excpt:
# If the error is a 404, the object doesn't exist, so we should create it.
if excpt.response["Error"]["Code"] == "404":
has_changed = True
else:
# Compare the existing ETag with the MD5 hash of the new content.
has_changed = (
response["ETag"].strip('"')
!= hashlib.md5(bytes_content).hexdigest() # noqa: S324
)
raise
else:
# Compare the existing ETag with the MD5 hash of the new content.
has_changed = (
response["ETag"].strip('"') != hashlib.md5(bytes_content).hexdigest() # noqa: S324
)
if has_changed:
content_file = ContentFile(bytes_content)
default_storage.save(file_key, content_file)
if has_changed:
content_file = ContentFile(bytes_content)
default_storage.save(file_key, content_file)
def is_leaf(self):
"""
@@ -730,7 +726,7 @@ class Document(MP_Node, BaseModel):
# Characteristics that are based only on specific access
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 role == RoleChoices.ADMIN) and not is_deleted
# Compute access roles before adding link roles because we don't
@@ -759,9 +755,17 @@ class Document(MP_Node, BaseModel):
role = RoleChoices.max(role, link_definition["link_role"])
can_get = bool(role) and not is_deleted
retrieve = can_get or is_owner
can_update = (
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
ai_allow_reach_from = settings.AI_ALLOW_REACH_FROM
ai_access = any(
@@ -784,24 +788,25 @@ class Document(MP_Node, BaseModel):
"media_check": can_get,
"can_edit": can_update,
"children_list": can_get,
"children_create": can_update and user.is_authenticated,
"children_create": can_create_children,
"collaboration_auth": can_get,
"comment": can_comment,
"content": can_get,
"cors_proxy": can_get,
"descendants": can_get,
"destroy": is_owner,
"destroy": can_destroy,
"duplicate": can_get and user.is_authenticated,
"favorite": can_get and user.is_authenticated,
"link_configuration": is_owner_or_admin,
"invite_owner": is_owner,
"invite_owner": is_owner and not is_deleted,
"mask": can_get and user.is_authenticated,
"move": is_owner_or_admin and not self.ancestors_deleted_at,
"move": is_owner_or_admin and not is_deleted,
"partial_update": can_update,
"restore": is_owner,
"retrieve": can_get,
"retrieve": retrieve,
"media_auth": can_get,
"link_select_options": link_select_options,
"tree": can_get,
"tree": retrieve,
"update": can_update,
"versions_destroy": is_owner_or_admin,
"versions_list": has_access_role,
@@ -901,7 +906,8 @@ class Document(MP_Node, BaseModel):
# Mark all descendants as soft deleted
self.get_descendants().filter(ancestors_deleted_at__isnull=True).update(
ancestors_deleted_at=self.ancestors_deleted_at
ancestors_deleted_at=self.ancestors_deleted_at,
updated_at=self.updated_at,
)
@transaction.atomic
@@ -1146,7 +1152,12 @@ class DocumentAccess(BaseAccess):
set_role_to = []
if is_owner_or_admin:
set_role_to.extend(
[RoleChoices.READER, RoleChoices.EDITOR, RoleChoices.ADMIN]
[
RoleChoices.READER,
RoleChoices.COMMENTER,
RoleChoices.EDITOR,
RoleChoices.ADMIN,
]
)
if role == RoleChoices.OWNER:
set_role_to.append(RoleChoices.OWNER)
@@ -1205,23 +1216,14 @@ class DocumentAskForAccess(BaseModel):
def get_abilities(self, 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:
teams = user.teams
try:
roles = self.user_roles or []
except AttributeError:
try:
roles = self.document.accesses.filter(
models.Q(user=user) | models.Q(team__in=teams),
).values_list("role", flat=True)
except (self._meta.model.DoesNotExist, IndexError):
roles = []
is_admin_or_owner = bool(
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
set_role_to = [
role
for role in RoleChoices.values
if RoleChoices.get_priority(role) <= RoleChoices.get_priority(user_role)
]
return {
"destroy": is_admin_or_owner,
@@ -1229,6 +1231,7 @@ class DocumentAskForAccess(BaseModel):
"partial_update": is_admin_or_owner,
"retrieve": is_admin_or_owner,
"accept": is_admin_or_owner,
"set_role_to": set_role_to,
}
def accept(self, role=None):
@@ -1278,6 +1281,153 @@ class DocumentAskForAccess(BaseModel):
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):
"""HTML and CSS code used for formatting the print around the MarkDown body."""

View File

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

View File

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

View File

@@ -0,0 +1,95 @@
"""Trigger document indexation using celery task."""
from logging import getLogger
from django.conf import settings
from django.core.cache import cache
from django.db.models import Q
from django_redis.cache import RedisCache
from core import models
from core.services.search_indexers import (
get_document_indexer,
)
from impress.celery_app import app
logger = getLogger(__file__)
@app.task
def document_indexer_task(document_id):
"""Celery Task : Sends indexation query for a document."""
indexer = get_document_indexer()
if indexer:
logger.info("Start document %s indexation", document_id)
indexer.index(models.Document.objects.filter(pk=document_id))
def batch_indexer_throttle_acquire(timeout: int = 0, atomic: bool = True):
"""
Enable the task throttle flag for a delay.
Uses redis locks if available to ensure atomic changes
"""
key = "document-batch-indexer-throttle"
# Redis is used as cache database (not in tests). Use the lock feature here
# to ensure atomicity of changes to the throttle flag.
if isinstance(cache, RedisCache) and atomic:
with cache.locks(key):
return batch_indexer_throttle_acquire(timeout, atomic=False)
# Use add() here :
# - set the flag and returns true if not exist
# - do nothing and return false if exist
return cache.add(key, 1, timeout=timeout)
@app.task
def batch_document_indexer_task(timestamp):
"""Celery Task : Sends indexation query for a batch of documents."""
indexer = get_document_indexer()
if indexer:
queryset = models.Document.objects.filter(
Q(updated_at__gte=timestamp)
| Q(deleted_at__gte=timestamp)
| Q(ancestors_deleted_at__gte=timestamp)
)
count = indexer.index(queryset)
logger.info("Indexed %d documents", count)
def trigger_batch_document_indexer(item):
"""
Trigger indexation task with debounce a delay set by the SEARCH_INDEXER_COUNTDOWN setting.
Args:
document (Document): The document instance.
"""
countdown = int(settings.SEARCH_INDEXER_COUNTDOWN)
# DO NOT create a task if indexation if disabled
if not settings.SEARCH_INDEXER_CLASS:
return
if countdown > 0:
# Each time this method is called during a countdown, we increment the
# counter and each task decrease it, so the index be run only once.
if batch_indexer_throttle_acquire(timeout=countdown):
logger.info(
"Add task for batch document indexation from updated_at=%s in %d seconds",
item.updated_at.isoformat(),
countdown,
)
batch_document_indexer_task.apply_async(
args=[item.updated_at], countdown=countdown
)
else:
logger.info("Skip task for batch document %s indexation", item.pk)
else:
document_indexer_task.apply(args=[item.pk])

View File

@@ -2,6 +2,7 @@
import random
import re
from unittest import mock
from django.core.exceptions import SuspiciousOperation
from django.test.utils import override_settings
@@ -12,7 +13,10 @@ from cryptography.fernet import Fernet
from lasuite.oidc_login.backends import get_oidc_refresh_token
from core import models
from core.authentication.backends import OIDCAuthenticationBackend
from core.authentication.backends import (
OIDCAuthenticationBackend,
create_or_update_contact,
)
from core.factories import UserFactory
pytestmark = pytest.mark.django_db
@@ -57,7 +61,7 @@ def test_authentication_getter_existing_user_via_email(
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(
access_token="test-token", id_token=None, payload=None
)
@@ -214,7 +218,7 @@ def test_authentication_getter_existing_user_change_fields_sub(
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# 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(
access_token="test-token", id_token=None, payload=None
)
@@ -256,7 +260,7 @@ def test_authentication_getter_existing_user_change_fields_email(
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# 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(
access_token="test-token", id_token=None, payload=None
)
@@ -509,3 +513,79 @@ def test_authentication_session_tokens(
assert user is not None
assert request.session["oidc_access_token"] == "test-access-token"
assert get_oidc_refresh_token(request.session) == "test-refresh-token"
def test_authentication_post_get_or_create_user_new_user_to_marketing_email(settings):
"""
New user and SIGNUP_NEW_USER_TO_MARKETING_EMAIL enabled should create a contact
in the marketing backend.
"""
user = UserFactory()
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
klass = OIDCAuthenticationBackend()
with mock.patch.object(
create_or_update_contact, "delay"
) as mock_create_or_update_contact:
klass.post_get_or_create_user(user, {}, True)
mock_create_or_update_contact.assert_called_once_with(
email=user.email, attributes={"DOCS_SOURCE": ["SIGNIN"]}
)
def test_authentication_post_get_or_create_user_new_user_to_marketing_email_disabled(
settings,
):
"""
New user and SIGNUP_NEW_USER_TO_MARKETING_EMAIL disabled should not create a contact
in the marketing backend.
"""
user = UserFactory()
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = False
klass = OIDCAuthenticationBackend()
with mock.patch.object(
create_or_update_contact, "delay"
) as mock_create_or_update_contact:
klass.post_get_or_create_user(user, {}, True)
mock_create_or_update_contact.assert_not_called()
def test_authentication_post_get_or_create_user_existing_user_to_marketing_email(
settings,
):
"""
Existing user and SIGNUP_NEW_USER_TO_MARKETING_EMAIL enabled should not create a contact
in the marketing backend.
"""
user = UserFactory()
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
klass = OIDCAuthenticationBackend()
with mock.patch.object(
create_or_update_contact, "delay"
) as mock_create_or_update_contact:
klass.post_get_or_create_user(user, {}, False)
mock_create_or_update_contact.assert_not_called()
def test_authentication_post_get_or_create_user_existing_user_to_marketing_email_disabled(
settings,
):
"""
Existing user and SIGNUP_NEW_USER_TO_MARKETING_EMAIL disabled should not create a contact
in the marketing backend.
"""
user = UserFactory()
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = False
klass = OIDCAuthenticationBackend()
with mock.patch.object(
create_or_update_contact, "delay"
) as mock_create_or_update_contact:
klass.post_get_or_create_user(user, {}, False)
mock_create_or_update_contact.assert_not_called()

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ Test document accesses API endpoints for users in impress's core app.
# pylint: disable=too-many-lines
import random
from unittest import mock
from uuid import uuid4
import pytest
@@ -292,6 +293,7 @@ def test_api_document_accesses_retrieve_set_role_to_child():
}
assert result_dict[str(document_access_other_user.id)] == [
"reader",
"commenter",
"editor",
"administrator",
"owner",
@@ -300,7 +302,7 @@ def test_api_document_accesses_retrieve_set_role_to_child():
# Add an access for the other user on the parent
parent_access_other_user = factories.UserDocumentAccessFactory(
document=parent, user=other_user, role="editor"
document=parent, user=other_user, role="commenter"
)
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
@@ -313,6 +315,7 @@ def test_api_document_accesses_retrieve_set_role_to_child():
result["id"]: result["abilities"]["set_role_to"] for result in content
}
assert result_dict[str(document_access_other_user.id)] == [
"commenter",
"editor",
"administrator",
"owner",
@@ -320,6 +323,7 @@ def test_api_document_accesses_retrieve_set_role_to_child():
assert result_dict[str(parent_access.id)] == []
assert result_dict[str(parent_access_other_user.id)] == [
"reader",
"commenter",
"editor",
"administrator",
"owner",
@@ -332,28 +336,28 @@ def test_api_document_accesses_retrieve_set_role_to_child():
[
["administrator", "reader", "reader", "reader"],
[
["reader", "editor", "administrator"],
["reader", "commenter", "editor", "administrator"],
[],
[],
["reader", "editor", "administrator"],
["reader", "commenter", "editor", "administrator"],
],
],
[
["owner", "reader", "reader", "reader"],
[
["reader", "editor", "administrator", "owner"],
["reader", "commenter", "editor", "administrator", "owner"],
[],
[],
["reader", "editor", "administrator", "owner"],
["reader", "commenter", "editor", "administrator", "owner"],
],
],
[
["owner", "reader", "reader", "owner"],
[
["reader", "editor", "administrator", "owner"],
["reader", "commenter", "editor", "administrator", "owner"],
[],
[],
["reader", "editor", "administrator", "owner"],
["reader", "commenter", "editor", "administrator", "owner"],
],
],
],
@@ -414,44 +418,44 @@ def test_api_document_accesses_list_authenticated_related_same_user(roles, resul
[
["administrator", "reader", "reader", "reader"],
[
["reader", "editor", "administrator"],
["reader", "commenter", "editor", "administrator"],
[],
[],
["reader", "editor", "administrator"],
["reader", "commenter", "editor", "administrator"],
],
],
[
["owner", "reader", "reader", "reader"],
[
["reader", "editor", "administrator", "owner"],
["reader", "commenter", "editor", "administrator", "owner"],
[],
[],
["reader", "editor", "administrator", "owner"],
["reader", "commenter", "editor", "administrator", "owner"],
],
],
[
["owner", "reader", "reader", "owner"],
[
["reader", "editor", "administrator", "owner"],
["reader", "commenter", "editor", "administrator", "owner"],
[],
[],
["reader", "editor", "administrator", "owner"],
["reader", "commenter", "editor", "administrator", "owner"],
],
],
[
["reader", "reader", "reader", "owner"],
[
["reader", "editor", "administrator", "owner"],
["reader", "commenter", "editor", "administrator", "owner"],
[],
[],
["reader", "editor", "administrator", "owner"],
["reader", "commenter", "editor", "administrator", "owner"],
],
],
[
["reader", "administrator", "reader", "editor"],
[
["reader", "editor", "administrator"],
["reader", "editor", "administrator"],
["reader", "commenter", "editor", "administrator"],
["reader", "commenter", "editor", "administrator"],
[],
[],
],
@@ -459,7 +463,7 @@ def test_api_document_accesses_list_authenticated_related_same_user(roles, resul
[
["editor", "editor", "administrator", "editor"],
[
["reader", "editor", "administrator"],
["reader", "commenter", "editor", "administrator"],
[],
["editor", "administrator"],
[],
@@ -1344,3 +1348,24 @@ def test_api_document_accesses_delete_owners_last_owner_child_team(
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

@@ -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
@@ -743,6 +769,37 @@ def test_api_document_invitations_update_authenticated_unprivileged(
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
@@ -824,3 +881,29 @@ def test_api_document_invitations_delete_readers_or_editors(via, role, mock_user
response.json()["detail"]
== "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

@@ -1,6 +1,7 @@
"""Test API for document ask for access."""
import uuid
from unittest import mock
from django.core import mail
@@ -114,7 +115,10 @@ def test_api_documents_ask_for_access_create_authenticated_non_root_document():
assert response.status_code == 404
def test_api_documents_ask_for_access_create_authenticated_specific_role():
@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.
"""
@@ -126,17 +130,35 @@ def test_api_documents_ask_for_access_create_authenticated_specific_role():
response = client.post(
f"/api/v1.0/documents/{document.id}/ask-for-access/",
data={"role": RoleChoices.EDITOR},
data={"role": role},
)
assert response.status_code == 201
assert DocumentAskForAccess.objects.filter(
document=document,
user=user,
role=RoleChoices.EDITOR,
role=role,
).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():
"""Authenticated users with existing access can ask for access with a different role."""
user = UserFactory()
@@ -265,6 +287,7 @@ def test_api_documents_ask_for_access_list_authenticated_own_request():
"update": False,
"partial_update": False,
"retrieve": False,
"set_role_to": [],
},
}
],
@@ -334,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/")
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() == {
"count": 3,
"next": None,
@@ -353,6 +386,7 @@ def test_api_documents_ask_for_access_list_owner_or_admin(role):
"update": True,
"partial_update": True,
"retrieve": True,
"set_role_to": expected_set_role_to,
},
}
for document_ask_for_access in document_ask_for_accesses
@@ -445,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}/"
)
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() == {
"id": str(document_ask_for_access.id),
"document": str(document.id),
@@ -459,6 +501,7 @@ def test_api_documents_ask_for_access_retrieve_owner_or_admin(role):
"update": True,
"partial_update": True,
"retrieve": True,
"set_role_to": expected_set_role_to,
},
}
@@ -748,6 +791,53 @@ def test_api_documents_ask_for_access_accept_authenticated_owner_or_admin_update
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):
"""
@@ -768,3 +858,35 @@ def test_api_documents_ask_for_access_accept_authenticated_non_root_document(rol
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

@@ -41,6 +41,7 @@ def test_api_documents_children_list_anonymous_public_standalone(
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child1.excerpt,
"id": str(child1.id),
@@ -63,6 +64,7 @@ def test_api_documents_children_list_anonymous_public_standalone(
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child2.excerpt,
"id": str(child2.id),
@@ -115,6 +117,7 @@ def test_api_documents_children_list_anonymous_public_parent(django_assert_num_q
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"deleted_at": None,
"depth": 4,
"excerpt": child1.excerpt,
"id": str(child1.id),
@@ -137,6 +140,7 @@ def test_api_documents_children_list_anonymous_public_parent(django_assert_num_q
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"deleted_at": None,
"depth": 4,
"excerpt": child2.excerpt,
"id": str(child2.id),
@@ -208,6 +212,7 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child1.excerpt,
"id": str(child1.id),
@@ -230,6 +235,7 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child2.excerpt,
"id": str(child2.id),
@@ -287,6 +293,7 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"deleted_at": None,
"depth": 4,
"excerpt": child1.excerpt,
"id": str(child1.id),
@@ -309,6 +316,7 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"deleted_at": None,
"depth": 4,
"excerpt": child2.excerpt,
"id": str(child2.id),
@@ -393,6 +401,7 @@ def test_api_documents_children_list_authenticated_related_direct(
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child1.excerpt,
"id": str(child1.id),
@@ -415,6 +424,7 @@ def test_api_documents_children_list_authenticated_related_direct(
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child2.excerpt,
"id": str(child2.id),
@@ -475,6 +485,7 @@ def test_api_documents_children_list_authenticated_related_parent(
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"deleted_at": None,
"depth": 4,
"excerpt": child1.excerpt,
"id": str(child1.id),
@@ -497,6 +508,7 @@ def test_api_documents_children_list_authenticated_related_parent(
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"deleted_at": None,
"depth": 4,
"excerpt": child2.excerpt,
"id": str(child2.id),
@@ -609,6 +621,7 @@ def test_api_documents_children_list_authenticated_related_team_members(
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child1.excerpt,
"id": str(child1.id),
@@ -631,6 +644,7 @@ def test_api_documents_children_list_authenticated_related_team_members(
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child2.excerpt,
"id": str(child2.id),

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

@@ -2,6 +2,7 @@
import pytest
import responses
from requests.exceptions import RequestException
from rest_framework.test import APIClient
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}"
)
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

@@ -148,7 +148,7 @@ def test_api_documents_create_for_owner_invalid_sub():
data = {
"title": "My Document",
"content": "Document content",
"sub": "123!!",
"sub": "invalid süb",
"email": "john.doe@example.com",
}
@@ -163,10 +163,7 @@ def test_api_documents_create_for_owner_invalid_sub():
assert not Document.objects.exists()
assert response.json() == {
"sub": [
"Enter a valid sub. This value may contain only letters, "
"numbers, and @/./+/-/_/: characters."
]
"sub": ["Enter a valid sub. This value should be ASCII only."]
}

View File

@@ -38,6 +38,7 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child1.excerpt,
"id": str(child1.id),
@@ -62,6 +63,7 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id),
"deleted_at": None,
"depth": 3,
"excerpt": grand_child.excerpt,
"id": str(grand_child.id),
@@ -84,6 +86,7 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child2.excerpt,
"id": str(child2.id),
@@ -135,6 +138,7 @@ def test_api_documents_descendants_list_anonymous_public_parent():
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"deleted_at": None,
"depth": 4,
"excerpt": child1.excerpt,
"id": str(child1.id),
@@ -157,6 +161,7 @@ def test_api_documents_descendants_list_anonymous_public_parent():
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id),
"deleted_at": None,
"depth": 5,
"excerpt": grand_child.excerpt,
"id": str(grand_child.id),
@@ -179,6 +184,7 @@ def test_api_documents_descendants_list_anonymous_public_parent():
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"deleted_at": None,
"depth": 4,
"excerpt": child2.excerpt,
"id": str(child2.id),
@@ -251,6 +257,7 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child1.excerpt,
"id": str(child1.id),
@@ -273,6 +280,7 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id),
"deleted_at": None,
"depth": 3,
"excerpt": grand_child.excerpt,
"id": str(grand_child.id),
@@ -295,6 +303,7 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child2.excerpt,
"id": str(child2.id),
@@ -352,6 +361,7 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"deleted_at": None,
"depth": 4,
"excerpt": child1.excerpt,
"id": str(child1.id),
@@ -374,6 +384,7 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id),
"deleted_at": None,
"depth": 5,
"excerpt": grand_child.excerpt,
"id": str(grand_child.id),
@@ -396,6 +407,7 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"deleted_at": None,
"depth": 4,
"excerpt": child2.excerpt,
"id": str(child2.id),
@@ -474,6 +486,7 @@ def test_api_documents_descendants_list_authenticated_related_direct():
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child1.excerpt,
"id": str(child1.id),
@@ -496,6 +509,7 @@ def test_api_documents_descendants_list_authenticated_related_direct():
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id),
"deleted_at": None,
"depth": 3,
"excerpt": grand_child.excerpt,
"id": str(grand_child.id),
@@ -518,6 +532,7 @@ def test_api_documents_descendants_list_authenticated_related_direct():
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child2.excerpt,
"id": str(child2.id),
@@ -576,6 +591,7 @@ def test_api_documents_descendants_list_authenticated_related_parent():
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"deleted_at": None,
"depth": 4,
"excerpt": child1.excerpt,
"id": str(child1.id),
@@ -598,6 +614,7 @@ def test_api_documents_descendants_list_authenticated_related_parent():
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id),
"deleted_at": None,
"depth": 5,
"excerpt": grand_child.excerpt,
"id": str(grand_child.id),
@@ -620,6 +637,7 @@ def test_api_documents_descendants_list_authenticated_related_parent():
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"deleted_at": None,
"depth": 4,
"excerpt": child2.excerpt,
"id": str(child2.id),
@@ -724,6 +742,7 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child1.excerpt,
"id": str(child1.id),
@@ -746,6 +765,7 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id),
"deleted_at": None,
"depth": 3,
"excerpt": grand_child.excerpt,
"id": str(grand_child.id),
@@ -768,6 +788,7 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"deleted_at": None,
"depth": 2,
"excerpt": child2.excerpt,
"id": str(child2.id),

View File

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

View File

@@ -65,6 +65,7 @@ def test_api_document_favorite_list_authenticated_with_favorite():
"computed_link_role": document.computed_link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"deleted_at": None,
"content": document.content,
"depth": document.depth,
"excerpt": document.excerpt,

View File

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

View File

@@ -69,6 +69,7 @@ def test_api_documents_list_format():
"computed_link_role": document.computed_link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"deleted_at": None,
"depth": 1,
"excerpt": document.excerpt,
"is_favorite": True,
@@ -427,3 +428,20 @@ def test_api_documents_list_favorites_no_extra_queries(django_assert_num_queries
assert result["is_favorite"] is True
else:
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

@@ -36,6 +36,7 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"comment": document.link_role in ["commenter", "editor"],
"cors_proxy": True,
"content": True,
"descendants": True,
@@ -46,8 +47,8 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"restricted": None,
},
"mask": False,
@@ -70,6 +71,7 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"deleted_at": None,
"depth": 1,
"excerpt": document.excerpt,
"is_favorite": False,
@@ -112,6 +114,7 @@ def test_api_documents_retrieve_anonymous_public_parent():
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"comment": grand_parent.link_role in ["commenter", "editor"],
"descendants": True,
"cors_proxy": True,
"content": True,
@@ -144,6 +147,7 @@ def test_api_documents_retrieve_anonymous_public_parent():
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"deleted_at": None,
"depth": 3,
"excerpt": document.excerpt,
"is_favorite": False,
@@ -218,6 +222,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"children_create": document.link_role == "editor",
"children_list": True,
"collaboration_auth": True,
"comment": document.link_role in ["commenter", "editor"],
"descendants": True,
"cors_proxy": True,
"content": True,
@@ -227,8 +232,8 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"restricted": None,
},
"mask": True,
@@ -252,6 +257,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 1,
"deleted_at": None,
"excerpt": document.excerpt,
"is_favorite": False,
"link_reach": reach,
@@ -301,6 +307,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
"children_create": grand_parent.link_role == "editor",
"children_list": True,
"collaboration_auth": True,
"comment": grand_parent.link_role in ["commenter", "editor"],
"descendants": True,
"cors_proxy": True,
"content": True,
@@ -333,6 +340,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 3,
"deleted_at": None,
"excerpt": document.excerpt,
"is_favorite": False,
"link_reach": document.link_reach,
@@ -446,6 +454,7 @@ def test_api_documents_retrieve_authenticated_related_direct():
"content": document.content,
"creator": str(document.creator.id),
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"deleted_at": None,
"depth": 1,
"excerpt": document.excerpt,
"is_favorite": False,
@@ -489,17 +498,18 @@ def test_api_documents_retrieve_authenticated_related_parent():
"abilities": {
"accesses_manage": access.role in ["administrator", "owner"],
"accesses_view": True,
"ai_transform": access.role != "reader",
"ai_translate": access.role != "reader",
"attachment_upload": access.role != "reader",
"can_edit": access.role != "reader",
"children_create": access.role != "reader",
"ai_transform": access.role not in ["reader", "commenter"],
"ai_translate": access.role not in ["reader", "commenter"],
"attachment_upload": access.role not in ["reader", "commenter"],
"can_edit": access.role not in ["reader", "commenter"],
"children_create": access.role not in ["reader", "commenter"],
"children_list": True,
"collaboration_auth": True,
"comment": access.role != "reader",
"descendants": True,
"cors_proxy": True,
"content": True,
"destroy": access.role == "owner",
"destroy": access.role in ["administrator", "owner"],
"duplicate": True,
"favorite": True,
"invite_owner": access.role == "owner",
@@ -511,11 +521,11 @@ def test_api_documents_retrieve_authenticated_related_parent():
"media_auth": True,
"media_check": True,
"move": access.role in ["administrator", "owner"],
"partial_update": access.role != "reader",
"partial_update": access.role not in ["reader", "commenter"],
"restore": access.role == "owner",
"retrieve": True,
"tree": True,
"update": access.role != "reader",
"update": access.role not in ["reader", "commenter"],
"versions_destroy": access.role in ["administrator", "owner"],
"versions_list": True,
"versions_retrieve": True,
@@ -528,6 +538,7 @@ def test_api_documents_retrieve_authenticated_related_parent():
"creator": str(document.creator.id),
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"depth": 3,
"deleted_at": None,
"excerpt": document.excerpt,
"is_favorite": False,
"link_reach": "restricted",
@@ -683,6 +694,7 @@ def test_api_documents_retrieve_authenticated_related_team_members(
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"deleted_at": None,
"depth": 1,
"excerpt": document.excerpt,
"is_favorite": False,
@@ -749,6 +761,7 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"deleted_at": None,
"depth": 1,
"excerpt": document.excerpt,
"is_favorite": False,
@@ -815,6 +828,7 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"deleted_at": None,
"depth": 1,
"excerpt": document.excerpt,
"is_favorite": False,

View File

@@ -0,0 +1,425 @@
"""
Tests for Documents API endpoint in impress's core app: list
"""
import random
from json import loads as json_loads
from django.test import RequestFactory
import pytest
import responses
from faker import Faker
from rest_framework.test import APIClient
from core import factories, models
from core.services.search_indexers import get_document_indexer
fake = Faker()
pytestmark = pytest.mark.django_db
def build_search_url(**kwargs):
"""Build absolute uri for search endpoint with ORDERED query arguments"""
return (
RequestFactory()
.get("/api/v1.0/documents/search/", dict(sorted(kwargs.items())))
.build_absolute_uri()
)
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
@responses.activate
def test_api_documents_search_anonymous(reach, role, indexer_settings):
"""
Anonymous users should not be allowed to search documents whatever the
link reach and link role
"""
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
factories.DocumentFactory(link_reach=reach, link_role=role)
# Find response
responses.add(
responses.POST,
"http://find/api/v1.0/search",
json=[],
status=200,
)
response = APIClient().get("/api/v1.0/documents/search/", data={"q": "alpha"})
assert response.status_code == 200
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
def test_api_documents_search_endpoint_is_none(indexer_settings):
"""
Missing SEARCH_INDEXER_QUERY_URL, so the indexer is not properly configured.
Should fallback on title filter
"""
indexer_settings.SEARCH_INDEXER_QUERY_URL = None
assert get_document_indexer() is None
user = factories.UserFactory()
document = factories.DocumentFactory(title="alpha")
access = factories.UserDocumentAccessFactory(document=document, user=user)
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/documents/search/", data={"q": "alpha"})
assert response.status_code == 200
content = response.json()
results = content.pop("results")
assert content == {
"count": 1,
"next": None,
"previous": None,
}
assert len(results) == 1
assert results[0] == {
"id": str(document.id),
"abilities": document.get_abilities(user),
"ancestors_link_reach": None,
"ancestors_link_role": None,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 1,
"excerpt": document.excerpt,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses_ancestors": 1,
"nb_accesses_direct": 1,
"numchild": 0,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"deleted_at": None,
"user_role": access.role,
}
@responses.activate
def test_api_documents_search_invalid_params(indexer_settings):
"""Validate the format of documents as returned by the search view."""
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/documents/search/")
assert response.status_code == 400
assert response.json() == {"q": ["This field is required."]}
response = client.get("/api/v1.0/documents/search/", data={"q": " "})
assert response.status_code == 400
assert response.json() == {"q": ["This field may not be blank."]}
response = client.get(
"/api/v1.0/documents/search/", data={"q": "any", "page": "NaN"}
)
assert response.status_code == 400
assert response.json() == {"page": ["A valid integer is required."]}
@responses.activate
def test_api_documents_search_format(indexer_settings):
"""Validate the format of documents as returned by the search view."""
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
assert get_document_indexer() is not None
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
user_a, user_b, user_c = factories.UserFactory.create_batch(3)
document = factories.DocumentFactory(
title="alpha",
users=(user_a, user_c),
link_traces=(user, user_b),
)
access = factories.UserDocumentAccessFactory(document=document, user=user)
# Find response
responses.add(
responses.POST,
"http://find/api/v1.0/search",
json=[
{"_id": str(document.pk)},
],
status=200,
)
response = client.get("/api/v1.0/documents/search/", data={"q": "alpha"})
assert response.status_code == 200
content = response.json()
results = content.pop("results")
assert content == {
"count": 1,
"next": None,
"previous": None,
}
assert len(results) == 1
assert results[0] == {
"id": str(document.id),
"abilities": document.get_abilities(user),
"ancestors_link_reach": None,
"ancestors_link_role": None,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 1,
"excerpt": document.excerpt,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses_ancestors": 3,
"nb_accesses_direct": 3,
"numchild": 0,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"deleted_at": None,
"user_role": access.role,
}
@responses.activate
@pytest.mark.parametrize(
"pagination, status, expected",
(
(
{"page": 1, "page_size": 10},
200,
{
"count": 10,
"previous": None,
"next": None,
"range": (0, None),
},
),
(
{},
200,
{
"count": 10,
"previous": None,
"next": None,
"range": (0, None),
"api_page_size": 21, # default page_size is 20
},
),
(
{"page": 2, "page_size": 10},
404,
{},
),
(
{"page": 1, "page_size": 5},
200,
{
"count": 10,
"previous": None,
"next": {"page": 2, "page_size": 5},
"range": (0, 5),
},
),
(
{"page": 2, "page_size": 5},
200,
{
"count": 10,
"previous": {"page_size": 5},
"next": None,
"range": (5, None),
},
),
({"page": 3, "page_size": 5}, 404, {}),
),
)
def test_api_documents_search_pagination(
indexer_settings, pagination, status, expected
):
"""Documents should be ordered by descending "score" by default"""
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
assert get_document_indexer() is not None
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
docs = factories.DocumentFactory.create_batch(10, title="alpha", users=[user])
docs_by_uuid = {str(doc.pk): doc for doc in docs}
api_results = [{"_id": id} for id in docs_by_uuid.keys()]
# reorder randomly to simulate score ordering
random.shuffle(api_results)
# Find response
# pylint: disable-next=assignment-from-none
api_search = responses.add(
responses.POST,
"http://find/api/v1.0/search",
json=api_results,
status=200,
)
response = client.get(
"/api/v1.0/documents/search/",
data={
"q": "alpha",
**pagination,
},
)
assert response.status_code == status
if response.status_code < 300:
previous_url = (
build_search_url(q="alpha", **expected["previous"])
if expected["previous"]
else None
)
next_url = (
build_search_url(q="alpha", **expected["next"])
if expected["next"]
else None
)
start, end = expected["range"]
content = response.json()
assert content["count"] == expected["count"]
assert content["previous"] == previous_url
assert content["next"] == next_url
results = content.pop("results")
# The find api results ordering by score is kept
assert [r["id"] for r in results] == [r["_id"] for r in api_results[start:end]]
# Check the query parameters.
assert api_search.call_count == 1
assert api_search.calls[0].response.status_code == 200
assert json_loads(api_search.calls[0].request.body) == {
"q": "alpha",
"visited": [],
"services": ["docs"],
"nb_results": 50,
"order_by": "updated_at",
"order_direction": "desc",
}
@responses.activate
@pytest.mark.parametrize(
"pagination, status, expected",
(
(
{"page": 1, "page_size": 10},
200,
{"count": 10, "previous": None, "next": None, "range": (0, None)},
),
(
{},
200,
{"count": 10, "previous": None, "next": None, "range": (0, None)},
),
(
{"page": 2, "page_size": 10},
404,
{},
),
(
{"page": 1, "page_size": 5},
200,
{
"count": 10,
"previous": None,
"next": {"page": 2, "page_size": 5},
"range": (0, 5),
},
),
(
{"page": 2, "page_size": 5},
200,
{
"count": 10,
"previous": {"page_size": 5},
"next": None,
"range": (5, None),
},
),
({"page": 3, "page_size": 5}, 404, {}),
),
)
def test_api_documents_search_pagination_endpoint_is_none(
indexer_settings, pagination, status, expected
):
"""Documents should be ordered by descending "-updated_at" by default"""
indexer_settings.SEARCH_INDEXER_QUERY_URL = None
assert get_document_indexer() is None
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(10, title="alpha", users=[user])
response = client.get(
"/api/v1.0/documents/search/",
data={
"q": "alpha",
**pagination,
},
)
assert response.status_code == status
if response.status_code < 300:
previous_url = (
build_search_url(q="alpha", **expected["previous"])
if expected["previous"]
else None
)
next_url = (
build_search_url(q="alpha", **expected["next"])
if expected["next"]
else None
)
queryset = models.Document.objects.order_by("-updated_at")
start, end = expected["range"]
expected_results = [str(d.pk) for d in queryset[start:end]]
content = response.json()
assert content["count"] == expected["count"]
assert content["previous"] == previous_url
assert content["next"] == next_url
results = content.pop("results")
assert [r["id"] for r in results] == expected_results

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)
document = factories.DocumentFactory(
deleted_at=timezone.now(),
users=factories.UserFactory.create_batch(2),
favorited_by=[user, *other_users],
link_traces=other_users,
)
document.soft_delete()
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
response = client.get("/api/v1.0/documents/trashbin/")
@@ -70,40 +70,41 @@ def test_api_documents_trashbin_format():
assert results[0] == {
"id": str(document.id),
"abilities": {
"accesses_manage": True,
"accesses_view": True,
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"can_edit": True,
"children_create": True,
"children_list": True,
"collaboration_auth": True,
"descendants": True,
"cors_proxy": True,
"content": True,
"destroy": True,
"duplicate": True,
"favorite": True,
"invite_owner": True,
"link_configuration": True,
"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,
"descendants": False,
"cors_proxy": False,
"comment": False,
"content": False,
"destroy": False,
"duplicate": False,
"favorite": False,
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"restricted": None,
},
"mask": True,
"media_auth": True,
"media_check": True,
"mask": False,
"media_auth": False,
"media_check": False,
"move": False, # Can't move a deleted document
"partial_update": True,
"partial_update": False,
"restore": True,
"retrieve": True,
"tree": True,
"update": True,
"versions_destroy": True,
"versions_list": True,
"versions_retrieve": True,
"update": False,
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
},
"ancestors_link_reach": None,
"ancestors_link_role": None,
@@ -113,6 +114,7 @@ def test_api_documents_trashbin_format():
"creator": str(document.creator.id),
"depth": 1,
"excerpt": document.excerpt,
"deleted_at": document.ancestors_deleted_at.isoformat().replace("+00:00", "Z"),
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses_ancestors": 0,
@@ -165,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)}
with django_assert_num_queries(10):
with django_assert_num_queries(11):
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/")
assert response.status_code == 200
@@ -207,10 +209,10 @@ def test_api_documents_trashbin_authenticated_via_team(
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/")
with django_assert_num_queries(3):
with django_assert_num_queries(4):
response = client.get("/api/v1.0/documents/trashbin/")
assert response.status_code == 200
@@ -292,3 +294,29 @@ def test_api_documents_trashbin_distinct():
content = response.json()
assert len(content["results"]) == 1
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

@@ -50,6 +50,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
),
"creator": str(child.creator.id),
"depth": 3,
"deleted_at": None,
"excerpt": child.excerpt,
"id": str(child.id),
"is_favorite": False,
@@ -73,6 +74,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 2,
"deleted_at": None,
"excerpt": document.excerpt,
"id": str(document.id),
"is_favorite": False,
@@ -96,6 +98,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
"created_at": sibling1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(sibling1.creator.id),
"depth": 2,
"deleted_at": None,
"excerpt": sibling1.excerpt,
"id": str(sibling1.id),
"is_favorite": False,
@@ -119,6 +122,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
"created_at": sibling2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(sibling2.creator.id),
"depth": 2,
"deleted_at": None,
"excerpt": sibling2.excerpt,
"id": str(sibling2.id),
"is_favorite": False,
@@ -138,6 +142,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(parent.creator.id),
"depth": 1,
"deleted_at": None,
"excerpt": parent.excerpt,
"id": str(parent.id),
"is_favorite": False,
@@ -210,6 +215,7 @@ def test_api_documents_tree_list_anonymous_public_parent():
),
"creator": str(child.creator.id),
"depth": 5,
"deleted_at": None,
"excerpt": child.excerpt,
"id": str(child.id),
"is_favorite": False,
@@ -233,6 +239,7 @@ def test_api_documents_tree_list_anonymous_public_parent():
),
"creator": str(document.creator.id),
"depth": 4,
"deleted_at": None,
"excerpt": document.excerpt,
"id": str(document.id),
"is_favorite": False,
@@ -260,6 +267,7 @@ def test_api_documents_tree_list_anonymous_public_parent():
),
"creator": str(document_sibling.creator.id),
"depth": 4,
"deleted_at": None,
"excerpt": document_sibling.excerpt,
"id": str(document_sibling.id),
"is_favorite": False,
@@ -281,6 +289,7 @@ def test_api_documents_tree_list_anonymous_public_parent():
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(parent.creator.id),
"depth": 3,
"deleted_at": None,
"excerpt": parent.excerpt,
"id": str(parent.id),
"is_favorite": False,
@@ -306,6 +315,7 @@ def test_api_documents_tree_list_anonymous_public_parent():
),
"creator": str(parent_sibling.creator.id),
"depth": 3,
"deleted_at": None,
"excerpt": parent_sibling.excerpt,
"id": str(parent_sibling.id),
"is_favorite": False,
@@ -327,6 +337,7 @@ def test_api_documents_tree_list_anonymous_public_parent():
"created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_parent.creator.id),
"depth": 2,
"deleted_at": None,
"excerpt": grand_parent.excerpt,
"id": str(grand_parent.id),
"is_favorite": False,
@@ -406,6 +417,7 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
),
"creator": str(child.creator.id),
"depth": 3,
"deleted_at": None,
"excerpt": child.excerpt,
"id": str(child.id),
"is_favorite": False,
@@ -427,6 +439,7 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 2,
"deleted_at": None,
"excerpt": document.excerpt,
"id": str(document.id),
"is_favorite": False,
@@ -450,6 +463,7 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
"created_at": sibling.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(sibling.creator.id),
"depth": 2,
"deleted_at": None,
"excerpt": sibling.excerpt,
"id": str(sibling.id),
"is_favorite": False,
@@ -469,6 +483,7 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(parent.creator.id),
"depth": 1,
"deleted_at": None,
"excerpt": parent.excerpt,
"id": str(parent.id),
"is_favorite": False,
@@ -546,6 +561,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
),
"creator": str(child.creator.id),
"depth": 5,
"deleted_at": None,
"excerpt": child.excerpt,
"id": str(child.id),
"is_favorite": False,
@@ -569,6 +585,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
),
"creator": str(document.creator.id),
"depth": 4,
"deleted_at": None,
"excerpt": document.excerpt,
"id": str(document.id),
"is_favorite": False,
@@ -596,6 +613,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
),
"creator": str(document_sibling.creator.id),
"depth": 4,
"deleted_at": None,
"excerpt": document_sibling.excerpt,
"id": str(document_sibling.id),
"is_favorite": False,
@@ -617,6 +635,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(parent.creator.id),
"depth": 3,
"deleted_at": None,
"excerpt": parent.excerpt,
"id": str(parent.id),
"is_favorite": False,
@@ -642,6 +661,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
),
"creator": str(parent_sibling.creator.id),
"depth": 3,
"deleted_at": None,
"excerpt": parent_sibling.excerpt,
"id": str(parent_sibling.id),
"is_favorite": False,
@@ -663,6 +683,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
"created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_parent.creator.id),
"depth": 2,
"deleted_at": None,
"excerpt": grand_parent.excerpt,
"id": str(grand_parent.id),
"is_favorite": False,
@@ -744,6 +765,7 @@ def test_api_documents_tree_list_authenticated_related_direct():
),
"creator": str(child.creator.id),
"depth": 3,
"deleted_at": None,
"excerpt": child.excerpt,
"id": str(child.id),
"is_favorite": False,
@@ -765,6 +787,7 @@ def test_api_documents_tree_list_authenticated_related_direct():
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 2,
"deleted_at": None,
"excerpt": document.excerpt,
"id": str(document.id),
"is_favorite": False,
@@ -788,6 +811,7 @@ def test_api_documents_tree_list_authenticated_related_direct():
"created_at": sibling.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(sibling.creator.id),
"depth": 2,
"deleted_at": None,
"excerpt": sibling.excerpt,
"id": str(sibling.id),
"is_favorite": False,
@@ -807,6 +831,7 @@ def test_api_documents_tree_list_authenticated_related_direct():
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(parent.creator.id),
"depth": 1,
"deleted_at": None,
"excerpt": parent.excerpt,
"id": str(parent.id),
"is_favorite": False,
@@ -888,6 +913,7 @@ def test_api_documents_tree_list_authenticated_related_parent():
),
"creator": str(child.creator.id),
"depth": 5,
"deleted_at": None,
"excerpt": child.excerpt,
"id": str(child.id),
"is_favorite": False,
@@ -911,6 +937,7 @@ def test_api_documents_tree_list_authenticated_related_parent():
),
"creator": str(document.creator.id),
"depth": 4,
"deleted_at": None,
"excerpt": document.excerpt,
"id": str(document.id),
"is_favorite": False,
@@ -938,6 +965,7 @@ def test_api_documents_tree_list_authenticated_related_parent():
),
"creator": str(document_sibling.creator.id),
"depth": 4,
"deleted_at": None,
"excerpt": document_sibling.excerpt,
"id": str(document_sibling.id),
"is_favorite": False,
@@ -959,6 +987,7 @@ def test_api_documents_tree_list_authenticated_related_parent():
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(parent.creator.id),
"depth": 3,
"deleted_at": None,
"excerpt": parent.excerpt,
"id": str(parent.id),
"is_favorite": False,
@@ -984,6 +1013,7 @@ def test_api_documents_tree_list_authenticated_related_parent():
),
"creator": str(parent_sibling.creator.id),
"depth": 3,
"deleted_at": None,
"excerpt": parent_sibling.excerpt,
"id": str(parent_sibling.id),
"is_favorite": False,
@@ -1005,6 +1035,7 @@ def test_api_documents_tree_list_authenticated_related_parent():
"created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_parent.creator.id),
"depth": 2,
"deleted_at": None,
"excerpt": grand_parent.excerpt,
"id": str(grand_parent.id),
"is_favorite": False,
@@ -1094,6 +1125,7 @@ def test_api_documents_tree_list_authenticated_related_team_members(
),
"creator": str(child.creator.id),
"depth": 3,
"deleted_at": None,
"excerpt": child.excerpt,
"id": str(child.id),
"is_favorite": False,
@@ -1115,6 +1147,7 @@ def test_api_documents_tree_list_authenticated_related_team_members(
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 2,
"deleted_at": None,
"excerpt": document.excerpt,
"id": str(document.id),
"is_favorite": False,
@@ -1138,6 +1171,7 @@ def test_api_documents_tree_list_authenticated_related_team_members(
"created_at": sibling.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(sibling.creator.id),
"depth": 2,
"deleted_at": None,
"excerpt": sibling.excerpt,
"id": str(sibling.id),
"is_favorite": False,
@@ -1157,6 +1191,7 @@ def test_api_documents_tree_list_authenticated_related_team_members(
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(parent.creator.id),
"depth": 1,
"deleted_at": None,
"excerpt": parent.excerpt,
"id": str(parent.id),
"is_favorite": False,
@@ -1170,3 +1205,56 @@ def test_api_documents_tree_list_authenticated_related_team_members(
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
"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

@@ -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,775 +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() == []
@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) == 3
assert sorted(content, 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 template 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",
)
assert response.status_code == 201
template = Template.objects.get()
assert template.title == "my template"
assert template.accesses.filter(role="owner", user=user).exists()
assert response.status_code == 405
assert not Template.objects.exists()

View File

@@ -8,7 +8,6 @@ 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
@@ -25,7 +24,7 @@ def test_api_templates_delete_anonymous():
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
related.
@@ -36,72 +35,11 @@ def test_api_templates_delete_authenticated_unrelated():
client.force_login(user)
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(
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
@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, (
"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
"""
import random
import pytest
from rest_framework.test import APIClient
from core import factories
from core.api import serializers
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
@@ -17,7 +14,6 @@ pytestmark = pytest.mark.django_db
def test_api_templates_update_anonymous():
"""Anonymous users should not be allowed to update a template."""
template = factories.TemplateFactory()
old_template_values = serializers.TemplateSerializer(instance=template).data
new_template_values = serializers.TemplateSerializer(
instance=factories.TemplateFactory()
@@ -28,145 +24,18 @@ def test_api_templates_update_anonymous():
format="json",
)
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()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory(is_public=False)
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
template = factories.TemplateFactory(users=[(user, "owner")])
new_template_values = serializers.TemplateSerializer(
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"
)
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]
assert response.status_code == 405
@pytest.mark.parametrize("via", VIA)
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",
response = client.patch(
f"/api/v1.0/templates/{template.id!s}/", new_template_values, format="json"
)
assert response.status_code == 403 if is_public else 404
template.refresh_from_db()
template_values = serializers.TemplateSerializer(instance=template).data
assert template_values == old_template_values
assert response.status_code == 405

View File

@@ -3,6 +3,7 @@ Test config API endpoints in the Impress core app.
"""
import json
from unittest.mock import patch
from django.test import override_settings
@@ -23,6 +24,7 @@ pytestmark = pytest.mark.django_db
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY=True,
CRISP_WEBSITE_ID="123",
FRONTEND_CSS_URL="http://testcss/",
FRONTEND_JS_URL="http://testjs/",
FRONTEND_THEME="test-theme",
MEDIA_BASE_URL="http://testserver/",
POSTHOG_KEY={"id": "132456", "host": "https://eu.i.posthog-test.com"},
@@ -41,12 +43,14 @@ def test_api_config(is_authenticated):
response = client.get("/api/v1.0/config/")
assert response.status_code == HTTP_200_OK
assert response.json() == {
"AI_FEATURE_ENABLED": False,
"COLLABORATION_WS_URL": "http://testcollab/",
"COLLABORATION_WS_NOT_CONNECTED_READY_ONLY": True,
"CRISP_WEBSITE_ID": "123",
"ENVIRONMENT": "test",
"FRONTEND_CSS_URL": "http://testcss/",
"FRONTEND_HOMEPAGE_FEATURE_ENABLED": True,
"FRONTEND_JS_URL": "http://testjs/",
"FRONTEND_THEME": "test-theme",
"LANGUAGES": [
["en-us", "English"],
@@ -59,7 +63,7 @@ def test_api_config(is_authenticated):
"MEDIA_BASE_URL": "http://testserver/",
"POSTHOG_KEY": {"id": "132456", "host": "https://eu.i.posthog-test.com"},
"SENTRY_DSN": "https://sentry.test/123",
"AI_FEATURE_ENABLED": False,
"TRASHBIN_CUTOFF_DAYS": 30,
"theme_customization": {},
}
policy_list = sorted(response.headers["Content-Security-Policy"].split("; "))
@@ -174,3 +178,20 @@ def test_api_config_with_original_theme_customization(is_authenticated, settings
theme_customization = json.load(f)
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

@@ -0,0 +1,107 @@
"""
Test DocumentThrottle for regular throttling and y-provider bypass.
"""
import pytest
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
def test_api_throttling_document_throttle_regular_requests(settings):
"""Test that regular requests are throttled normally."""
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"]
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"] = "3/minute"
settings.Y_PROVIDER_API_KEY = "test-y-provider-key"
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=document, user=user)
# Make 3 requests without the y-provider key
for _i in range(3):
response = client.get(
f"/api/v1.0/documents/{document.id!s}/",
)
assert response.status_code == 200
# 4th request should be throttled
response = client.get(
f"/api/v1.0/documents/{document.id!s}/",
)
assert response.status_code == 429
# A request with the y-provider key should NOT be throttled
response = client.get(
f"/api/v1.0/documents/{document.id!s}/",
HTTP_X_Y_PROVIDER_KEY="test-y-provider-key",
)
assert response.status_code == 200
# Restore original rate
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"] = current_rate
def test_api_throttling_document_throttle_y_provider_exempted(settings):
"""Test that y-provider requests are exempted from throttling."""
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"]
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"] = "3/minute"
settings.Y_PROVIDER_API_KEY = "test-y-provider-key"
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=document, user=user)
# Make many requests with the y-provider API key
for _i in range(10):
response = client.get(
f"/api/v1.0/documents/{document.id!s}/",
HTTP_X_Y_PROVIDER_KEY="test-y-provider-key",
)
assert response.status_code == 200
# Restore original rate
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"] = current_rate
def test_api_throttling_document_throttle_invalid_token(settings):
"""Test that requests with invalid tokens are throttled."""
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"]
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"] = "3/minute"
settings.Y_PROVIDER_API_KEY = "test-y-provider-key"
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=document, user=user)
# Make 3 requests with an invalid token
for _i in range(3):
response = client.get(
f"/api/v1.0/documents/{document.id!s}/",
HTTP_X_Y_PROVIDER_KEY="invalid-token",
)
assert response.status_code == 200
# 4th request should be throttled
response = client.get(
f"/api/v1.0/documents/{document.id!s}/",
HTTP_X_Y_PROVIDER_KEY="invalid-token",
)
assert response.status_code == 429
# Restore original rate
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"] = current_rate

View File

@@ -76,6 +76,131 @@ def test_api_users_list_query_email():
assert user_ids == []
def test_api_users_list_query_email_with_internationalized_domain_names():
"""
Authenticated users should be able to list users and filter by email.
It should work even if the email address contains an internationalized domain name.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
jean = factories.UserFactory(email="jean.martin@éducation.fr")
marie = factories.UserFactory(email="marie.durand@education.fr")
kurokawa = factories.UserFactory(email="contact@黒川.日本")
response = client.get("/api/v1.0/users/?q=jean.martin@education.fr")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(jean.id)]
response = client.get("/api/v1.0/users/?q=jean.martin@éducation.fr")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(jean.id)]
response = client.get("/api/v1.0/users/?q=marie.durand@education.fr")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(marie.id)]
response = client.get("/api/v1.0/users/?q=marie.durand@éducation.fr")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(marie.id)]
response = client.get("/api/v1.0/users/?q=contact@黒川.日本")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(kurokawa.id)]
def test_api_users_list_query_full_name():
"""
Authenticated users should be able to list users and filter by full name.
Only results with a Trigram similarity greater than 0.2 with the query should be returned.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
dave = factories.UserFactory(email="contact@work.com", full_name="David Bowman")
response = client.get(
"/api/v1.0/users/?q=David",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(dave.id)]
response = client.get("/api/v1.0/users/?q=Bowman")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(dave.id)]
response = client.get("/api/v1.0/users/?q=bowman")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(dave.id)]
response = client.get("/api/v1.0/users/?q=BOWMAN")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(dave.id)]
response = client.get("/api/v1.0/users/?q=BoWmAn")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(dave.id)]
response = client.get("/api/v1.0/users/?q=Bovin")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == []
def test_api_users_list_query_accented_full_name():
"""
Authenticated users should be able to list users and filter by full name with accents.
Only results with a Trigram similarity greater than 0.2 with the query should be returned.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
fred = factories.UserFactory(
email="contact@work.com", full_name="Frédérique Lefèvre"
)
response = client.get("/api/v1.0/users/?q=Frédérique")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(fred.id)]
response = client.get("/api/v1.0/users/?q=Frederique")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(fred.id)]
response = client.get("/api/v1.0/users/?q=Lefèvre")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(fred.id)]
response = client.get("/api/v1.0/users/?q=Lefevre")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()]
assert user_ids == [str(fred.id)]
response = client.get("/api/v1.0/users/?q=François Lorfebvre")
assert response.status_code == 200
users = [user["full_name"] for user in response.json()]
assert users == []
def test_api_users_list_limit(settings):
"""
Authenticated users should be able to list users and the number of results
@@ -194,18 +319,41 @@ def test_api_users_list_query_short_queries():
factories.UserFactory(email="john.lennon@example.com")
response = client.get("/api/v1.0/users/?q=jo")
assert response.status_code == 200
assert response.json() == []
assert response.status_code == 400
assert response.json() == {
"q": ["Ensure this value has at least 5 characters (it has 2)."]
}
response = client.get("/api/v1.0/users/?q=john")
assert response.status_code == 200
assert response.json() == []
assert response.status_code == 400
assert response.json() == {
"q": ["Ensure this value has at least 5 characters (it has 4)."]
}
response = client.get("/api/v1.0/users/?q=john.")
assert response.status_code == 200
assert len(response.json()) == 2
def test_api_users_list_query_long_queries():
"""
Queries longer than 255 characters should return an empty result set.
"""
user = factories.UserFactory(email="paul@example.com")
client = APIClient()
client.force_login(user)
factories.UserFactory(email="john.doe@example.com")
factories.UserFactory(email="john.lennon@example.com")
query = "a" * 244
response = client.get(f"/api/v1.0/users/?q={query}@example.com")
assert response.status_code == 400
assert response.json() == {
"q": ["Ensure this value has at most 254 characters (it has 256)."]
}
def test_api_users_list_query_inactive():
"""Inactive users should not be listed."""
user = factories.UserFactory()
@@ -255,6 +403,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():
"""Anonymous users should not be allowed to retrieve a user."""
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,7 +123,7 @@ def test_models_document_access_get_abilities_for_owner_of_self_allowed():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator", "owner"],
"set_role_to": ["reader", "commenter", "editor", "administrator", "owner"],
}
@@ -166,7 +166,7 @@ def test_models_document_access_get_abilities_for_owner_of_self_last_on_child(
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator", "owner"],
"set_role_to": ["reader", "commenter", "editor", "administrator", "owner"],
}
@@ -183,7 +183,7 @@ def test_models_document_access_get_abilities_for_owner_of_owner():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator", "owner"],
"set_role_to": ["reader", "commenter", "editor", "administrator", "owner"],
}
@@ -200,7 +200,7 @@ def test_models_document_access_get_abilities_for_owner_of_administrator():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator", "owner"],
"set_role_to": ["reader", "commenter", "editor", "administrator", "owner"],
}
@@ -217,7 +217,7 @@ def test_models_document_access_get_abilities_for_owner_of_editor():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator", "owner"],
"set_role_to": ["reader", "commenter", "editor", "administrator", "owner"],
}
@@ -234,7 +234,7 @@ def test_models_document_access_get_abilities_for_owner_of_reader():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator", "owner"],
"set_role_to": ["reader", "commenter", "editor", "administrator", "owner"],
}
@@ -271,7 +271,7 @@ def test_models_document_access_get_abilities_for_administrator_of_administrator
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator"],
"set_role_to": ["reader", "commenter", "editor", "administrator"],
}
@@ -288,7 +288,7 @@ def test_models_document_access_get_abilities_for_administrator_of_editor():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator"],
"set_role_to": ["reader", "commenter", "editor", "administrator"],
}
@@ -305,7 +305,7 @@ def test_models_document_access_get_abilities_for_administrator_of_reader():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator"],
"set_role_to": ["reader", "commenter", "editor", "administrator"],
}

View File

@@ -134,10 +134,13 @@ def test_models_documents_soft_delete(depth):
[
(True, "restricted", "reader"),
(True, "restricted", "editor"),
(True, "restricted", "commenter"),
(False, "restricted", "reader"),
(False, "restricted", "editor"),
(False, "restricted", "commenter"),
(False, "authenticated", "reader"),
(False, "authenticated", "editor"),
(False, "authenticated", "commenter"),
],
)
def test_models_documents_get_abilities_forbidden(
@@ -165,6 +168,7 @@ def test_models_documents_get_abilities_forbidden(
"destroy": False,
"duplicate": False,
"favorite": False,
"comment": False,
"invite_owner": False,
"mask": False,
"media_auth": False,
@@ -172,8 +176,8 @@ def test_models_documents_get_abilities_forbidden(
"move": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"restricted": None,
},
"partial_update": False,
@@ -223,6 +227,7 @@ def test_models_documents_get_abilities_reader(
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"comment": False,
"descendants": True,
"cors_proxy": True,
"content": True,
@@ -232,8 +237,78 @@ def test_models_documents_get_abilities_reader(
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"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"]
)
@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,
@@ -289,6 +364,7 @@ def test_models_documents_get_abilities_editor(
"children_create": is_authenticated,
"children_list": True,
"collaboration_auth": True,
"comment": True,
"descendants": True,
"cors_proxy": True,
"content": True,
@@ -298,8 +374,8 @@ def test_models_documents_get_abilities_editor(
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"restricted": None,
},
"mask": is_authenticated,
@@ -344,6 +420,7 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
"children_create": True,
"children_list": True,
"collaboration_auth": True,
"comment": True,
"descendants": True,
"cors_proxy": True,
"content": True,
@@ -353,8 +430,8 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
"invite_owner": True,
"link_configuration": True,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"restricted": None,
},
"mask": True,
@@ -375,8 +452,43 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
document.soft_delete()
document.refresh_from_db()
expected_abilities["move"] = False
assert document.get_abilities(user) == expected_abilities
assert document.get_abilities(user) == {
"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(
@@ -396,6 +508,7 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
"children_create": True,
"children_list": True,
"collaboration_auth": True,
"comment": True,
"descendants": True,
"cors_proxy": True,
"content": True,
@@ -405,8 +518,8 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
"invite_owner": False,
"link_configuration": True,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"restricted": None,
},
"mask": True,
@@ -451,6 +564,7 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
"children_create": True,
"children_list": True,
"collaboration_auth": True,
"comment": True,
"descendants": True,
"cors_proxy": True,
"content": True,
@@ -460,8 +574,8 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"restricted": None,
},
"mask": True,
@@ -513,6 +627,8 @@ def test_models_documents_get_abilities_reader_user(
"children_create": access_from_link,
"children_list": True,
"collaboration_auth": True,
"comment": document.link_reach != "restricted"
and document.link_role in ["commenter", "editor"],
"descendants": True,
"cors_proxy": True,
"content": True,
@@ -522,8 +638,73 @@ def test_models_documents_get_abilities_reader_user(
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"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"]
)
@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,
@@ -573,6 +754,7 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
"children_create": False,
"children_list": True,
"collaboration_auth": True,
"comment": False,
"descendants": True,
"cors_proxy": True,
"content": True,
@@ -582,8 +764,8 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
"invite_owner": False,
"link_configuration": False,
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
"restricted": None,
},
"mask": True,
@@ -601,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")
@pytest.mark.parametrize(
"is_authenticated,reach",
@@ -1206,7 +1468,14 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
"public",
"reader",
{
"public": ["reader", "editor"],
"public": ["reader", "commenter", "editor"],
},
),
(
"public",
"commenter",
{
"public": ["commenter", "editor"],
},
),
("public", "editor", {"public": ["editor"]}),
@@ -1214,8 +1483,16 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
"authenticated",
"reader",
{
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
},
),
(
"authenticated",
"commenter",
{
"authenticated": ["commenter", "editor"],
"public": ["commenter", "editor"],
},
),
(
@@ -1228,8 +1505,17 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
"reader",
{
"restricted": None,
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"public": ["reader", "commenter", "editor"],
},
),
(
"restricted",
"commenter",
{
"restricted": None,
"authenticated": ["commenter", "editor"],
"public": ["commenter", "editor"],
},
),
(
@@ -1246,15 +1532,15 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
"public",
None,
{
"public": ["reader", "editor"],
"public": ["reader", "commenter", "editor"],
},
),
(
None,
"reader",
{
"public": ["reader", "editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "commenter", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"restricted": None,
},
),
@@ -1262,8 +1548,8 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
None,
None,
{
"public": ["reader", "editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "commenter", "editor"],
"authenticated": ["reader", "commenter", "editor"],
"restricted": None,
},
),

View File

@@ -0,0 +1,441 @@
"""
Unit tests for the Document model
"""
# pylint: disable=too-many-lines
from operator import itemgetter
from unittest import mock
from django.core.cache import cache
from django.db import transaction
import pytest
from core import factories, models
from core.services.search_indexers import SearchIndexer
pytestmark = pytest.mark.django_db
def reset_batch_indexer_throttle():
"""Reset throttle flag"""
cache.delete("document-batch-indexer-throttle")
@pytest.fixture(autouse=True)
def reset_throttle():
"""Reset throttle flag before each test"""
reset_batch_indexer_throttle()
yield
reset_batch_indexer_throttle()
@mock.patch.object(SearchIndexer, "push")
@pytest.mark.usefixtures("indexer_settings")
@pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer(mock_push):
"""Test indexation task on document creation"""
with transaction.atomic():
doc1, doc2, doc3 = factories.DocumentFactory.create_batch(3)
accesses = {}
data = [call.args[0] for call in mock_push.call_args_list]
indexer = SearchIndexer()
assert len(data) == 1
# One call
assert sorted(data[0], key=itemgetter("id")) == sorted(
[
indexer.serialize_document(doc1, accesses),
indexer.serialize_document(doc2, accesses),
indexer.serialize_document(doc3, accesses),
],
key=itemgetter("id"),
)
# The throttle counters should be reset
assert cache.get("document-batch-indexer-throttle") == 1
@pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer_no_batches(indexer_settings):
"""Test indexation task on doculment creation, no throttle"""
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
with mock.patch.object(SearchIndexer, "push") as mock_push:
with transaction.atomic():
doc1, doc2, doc3 = factories.DocumentFactory.create_batch(3)
accesses = {}
data = [call.args[0] for call in mock_push.call_args_list]
indexer = SearchIndexer()
# 3 calls
assert len(data) == 3
# one document per call
assert [len(d) for d in data] == [1] * 3
# all documents are indexed
assert sorted([d[0] for d in data], key=itemgetter("id")) == sorted(
[
indexer.serialize_document(doc1, accesses),
indexer.serialize_document(doc2, accesses),
indexer.serialize_document(doc3, accesses),
],
key=itemgetter("id"),
)
# The throttle counters should be reset
assert cache.get("file-batch-indexer-throttle") is None
@mock.patch.object(SearchIndexer, "push")
@pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer_not_configured(mock_push, indexer_settings):
"""Task should not start an indexation when disabled"""
indexer_settings.SEARCH_INDEXER_CLASS = None
user = factories.UserFactory()
with transaction.atomic():
doc = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=doc, user=user)
assert mock_push.assert_not_called
@mock.patch.object(SearchIndexer, "push")
@pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer_wrongly_configured(
mock_push, indexer_settings
):
"""Task should not start an indexation when disabled"""
indexer_settings.SEARCH_INDEXER_URL = None
user = factories.UserFactory()
with transaction.atomic():
doc = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=doc, user=user)
assert mock_push.assert_not_called
@mock.patch.object(SearchIndexer, "push")
@pytest.mark.usefixtures("indexer_settings")
@pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer_with_accesses(mock_push):
"""Test indexation task on document creation"""
user = factories.UserFactory()
with transaction.atomic():
doc1, doc2, doc3 = factories.DocumentFactory.create_batch(3)
factories.UserDocumentAccessFactory(document=doc1, user=user)
factories.UserDocumentAccessFactory(document=doc2, user=user)
factories.UserDocumentAccessFactory(document=doc3, user=user)
accesses = {
str(doc1.path): {"users": [user.sub]},
str(doc2.path): {"users": [user.sub]},
str(doc3.path): {"users": [user.sub]},
}
data = [call.args[0] for call in mock_push.call_args_list]
indexer = SearchIndexer()
assert len(data) == 1
assert sorted(data[0], key=itemgetter("id")) == sorted(
[
indexer.serialize_document(doc1, accesses),
indexer.serialize_document(doc2, accesses),
indexer.serialize_document(doc3, accesses),
],
key=itemgetter("id"),
)
@mock.patch.object(SearchIndexer, "push")
@pytest.mark.usefixtures("indexer_settings")
@pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer_deleted(mock_push):
"""Indexation task on deleted or ancestor_deleted documents"""
user = factories.UserFactory()
with transaction.atomic():
doc = factories.DocumentFactory(
link_reach=models.LinkReachChoices.AUTHENTICATED
)
main_doc = factories.DocumentFactory(
link_reach=models.LinkReachChoices.AUTHENTICATED
)
child_doc = factories.DocumentFactory(
parent=main_doc,
link_reach=models.LinkReachChoices.AUTHENTICATED,
)
factories.UserDocumentAccessFactory(document=doc, user=user)
factories.UserDocumentAccessFactory(document=main_doc, user=user)
factories.UserDocumentAccessFactory(document=child_doc, user=user)
# Manually reset the throttle flag here or the next indexation will be ignored for 1 second
reset_batch_indexer_throttle()
with transaction.atomic():
main_doc_deleted = models.Document.objects.get(pk=main_doc.pk)
main_doc_deleted.soft_delete()
child_doc_deleted = models.Document.objects.get(pk=child_doc.pk)
main_doc_deleted.refresh_from_db()
child_doc_deleted.refresh_from_db()
assert main_doc_deleted.deleted_at is not None
assert child_doc_deleted.ancestors_deleted_at is not None
assert child_doc_deleted.deleted_at is None
assert child_doc_deleted.ancestors_deleted_at is not None
accesses = {
str(doc.path): {"users": [user.sub]},
str(main_doc_deleted.path): {"users": [user.sub]},
str(child_doc_deleted.path): {"users": [user.sub]},
}
data = [call.args[0] for call in mock_push.call_args_list]
indexer = SearchIndexer()
assert len(data) == 2
# First indexation on document creation
assert sorted(data[0], key=itemgetter("id")) == sorted(
[
indexer.serialize_document(doc, accesses),
indexer.serialize_document(main_doc, accesses),
indexer.serialize_document(child_doc, accesses),
],
key=itemgetter("id"),
)
# Even deleted items are re-indexed : only update their status in the future
assert sorted(data[1], key=itemgetter("id")) == sorted(
[
indexer.serialize_document(main_doc_deleted, accesses), # soft_delete()
indexer.serialize_document(child_doc_deleted, accesses),
],
key=itemgetter("id"),
)
@pytest.mark.django_db(transaction=True)
@pytest.mark.usefixtures("indexer_settings")
def test_models_documents_indexer_hard_deleted():
"""Indexation task on hard deleted document"""
user = factories.UserFactory()
with transaction.atomic():
doc = factories.DocumentFactory(
link_reach=models.LinkReachChoices.AUTHENTICATED
)
factories.UserDocumentAccessFactory(document=doc, user=user)
# Call task on deleted document.
with mock.patch.object(SearchIndexer, "push") as mock_push:
doc.delete()
# Hard delete document are not re-indexed.
assert mock_push.assert_not_called
@mock.patch.object(SearchIndexer, "push")
@pytest.mark.usefixtures("indexer_settings")
@pytest.mark.django_db(transaction=True)
def test_models_documents_post_save_indexer_restored(mock_push):
"""Restart indexation task on restored documents"""
user = factories.UserFactory()
with transaction.atomic():
doc = factories.DocumentFactory(
link_reach=models.LinkReachChoices.AUTHENTICATED
)
doc_deleted = factories.DocumentFactory(
link_reach=models.LinkReachChoices.AUTHENTICATED
)
doc_ancestor_deleted = factories.DocumentFactory(
parent=doc_deleted,
link_reach=models.LinkReachChoices.AUTHENTICATED,
)
factories.UserDocumentAccessFactory(document=doc, user=user)
factories.UserDocumentAccessFactory(document=doc_deleted, user=user)
factories.UserDocumentAccessFactory(document=doc_ancestor_deleted, user=user)
doc_deleted.soft_delete()
doc_deleted.refresh_from_db()
doc_ancestor_deleted.refresh_from_db()
assert doc_deleted.deleted_at is not None
assert doc_deleted.ancestors_deleted_at is not None
assert doc_ancestor_deleted.deleted_at is None
assert doc_ancestor_deleted.ancestors_deleted_at is not None
# Manually reset the throttle flag here or the next indexation will be ignored for 1 second
reset_batch_indexer_throttle()
with transaction.atomic():
doc_restored = models.Document.objects.get(pk=doc_deleted.pk)
doc_restored.restore()
doc_ancestor_restored = models.Document.objects.get(pk=doc_ancestor_deleted.pk)
assert doc_restored.deleted_at is None
assert doc_restored.ancestors_deleted_at is None
assert doc_ancestor_restored.deleted_at is None
assert doc_ancestor_restored.ancestors_deleted_at is None
accesses = {
str(doc.path): {"users": [user.sub]},
str(doc_deleted.path): {"users": [user.sub]},
str(doc_ancestor_deleted.path): {"users": [user.sub]},
}
data = [call.args[0] for call in mock_push.call_args_list]
indexer = SearchIndexer()
# All docs are re-indexed
assert len(data) == 2
# First indexation on items creation & soft delete (in the same transaction)
assert sorted(data[0], key=itemgetter("id")) == sorted(
[
indexer.serialize_document(doc, accesses),
indexer.serialize_document(doc_deleted, accesses),
indexer.serialize_document(doc_ancestor_deleted, accesses),
],
key=itemgetter("id"),
)
# Restored items are re-indexed : only update their status in the future
assert sorted(data[1], key=itemgetter("id")) == sorted(
[
indexer.serialize_document(doc_restored, accesses), # restore()
indexer.serialize_document(doc_ancestor_restored, accesses),
],
key=itemgetter("id"),
)
@pytest.mark.django_db(transaction=True)
@pytest.mark.usefixtures("indexer_settings")
def test_models_documents_post_save_indexer_throttle():
"""Test indexation task skipping on document update"""
indexer = SearchIndexer()
user = factories.UserFactory()
with mock.patch.object(SearchIndexer, "push"):
with transaction.atomic():
docs = factories.DocumentFactory.create_batch(5, users=(user,))
accesses = {str(item.path): {"users": [user.sub]} for item in docs}
with mock.patch.object(SearchIndexer, "push") as mock_push:
# Simulate 1 running task
cache.set("document-batch-indexer-throttle", 1)
# save doc to trigger the indexer, but nothing should be done since
# the flag is up
with transaction.atomic():
docs[0].save()
docs[2].save()
docs[3].save()
assert [call.args[0] for call in mock_push.call_args_list] == []
with mock.patch.object(SearchIndexer, "push") as mock_push:
# No waiting task
cache.delete("document-batch-indexer-throttle")
with transaction.atomic():
docs[0].save()
docs[2].save()
docs[3].save()
data = [call.args[0] for call in mock_push.call_args_list]
# One call
assert len(data) == 1
assert sorted(data[0], key=itemgetter("id")) == sorted(
[
indexer.serialize_document(docs[0], accesses),
indexer.serialize_document(docs[2], accesses),
indexer.serialize_document(docs[3], accesses),
],
key=itemgetter("id"),
)
@pytest.mark.django_db(transaction=True)
@pytest.mark.usefixtures("indexer_settings")
def test_models_documents_access_post_save_indexer():
"""Test indexation task on DocumentAccess update"""
users = factories.UserFactory.create_batch(3)
with mock.patch.object(SearchIndexer, "push"):
with transaction.atomic():
doc = factories.DocumentFactory(users=users)
doc_accesses = models.DocumentAccess.objects.filter(document=doc).order_by(
"user__sub"
)
reset_batch_indexer_throttle()
with mock.patch.object(SearchIndexer, "push") as mock_push:
with transaction.atomic():
for doc_access in doc_accesses:
doc_access.save()
data = [call.args[0] for call in mock_push.call_args_list]
# One call
assert len(data) == 1
assert [d["id"] for d in data[0]] == [str(doc.pk)]
@pytest.mark.django_db(transaction=True)
def test_models_items_access_post_save_indexer_no_throttle(indexer_settings):
"""Test indexation task on ItemAccess update, no throttle"""
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
users = factories.UserFactory.create_batch(3)
with transaction.atomic():
doc = factories.DocumentFactory(users=users)
doc_accesses = models.DocumentAccess.objects.filter(document=doc).order_by(
"user__sub"
)
reset_batch_indexer_throttle()
with mock.patch.object(SearchIndexer, "push") as mock_push:
with transaction.atomic():
for doc_access in doc_accesses:
doc_access.save()
data = [call.args[0] for call in mock_push.call_args_list]
# 3 calls
assert len(data) == 3
# one document per call
assert [len(d) for d in data] == [1] * 3
# the same document is indexed 3 times
assert [d[0]["id"] for d in data] == [str(doc.pk)] * 3

View File

@@ -8,7 +8,7 @@ from django.core.exceptions import ValidationError
import pytest
from core import factories
from core import factories, models
pytestmark = pytest.mark.django_db
@@ -44,3 +44,55 @@ def test_models_users_send_mail_main_missing():
user.email_user("my subject", "my message")
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()

View File

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

View File

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

View File

@@ -26,20 +26,22 @@ document_related_router.register(
viewsets.InvitationViewset,
basename="invitations",
)
document_related_router.register(
"threads",
viewsets.ThreadViewSet,
basename="threads",
)
document_related_router.register(
"ask-for-access",
viewsets.DocumentAskForAccessViewSet,
basename="ask_for_access",
)
# - Routes nested under a template
template_related_router = DefaultRouter()
template_related_router.register(
"accesses",
viewsets.TemplateAccessViewSet,
basename="template_accesses",
thread_related_router = DefaultRouter()
thread_related_router.register(
"comments",
viewsets.CommentViewSet,
basename="comments",
)
@@ -55,8 +57,8 @@ urlpatterns = [
include(document_related_router.urls),
),
re_path(
r"^templates/(?P<resource_id>[0-9a-z-]*)/",
include(template_related_router.urls),
r"^documents/(?P<resource_id>[0-9a-z-]*)/threads/(?P<thread_id>[0-9a-z-]*)/",
include(thread_related_router.urls),
),
]
),

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