Compare commits

...

108 Commits

Author SHA1 Message Date
Anthony LC
7dfc62b2c5 🔖(minor) release 1.9.0
Added:
- (backend) annotate number of accesses
on documents in list view
- (backend) allow users to mark/unmark
documents as favorite

Changed:
- 🔒️(collaboration) increase collaboration access security
- 🔨(frontend) encapsulated title to its own component
- ️(backend) optimize number of queries on
document list view
- ♻️(frontend) stop to use provider with version
- 🚚(collaboration) change the websocket key name

Fixed:
- 🐛(frontend) fix initial content with collaboration
- 🐛(frontend) Fix hidden menu on Firefox
- 🐛(backend) fix sanitize problem IA
2024-12-12 08:37:10 +01:00
Anthony LC
39c4af0a7c 🐛(frontend) hide cursor for authenticated users
When a authenticated user was in read-only mode,
the cursor was still visible.
This commit hides the cursor for authenticated users
in read-only mode.
2024-12-11 21:18:40 +01:00
Anthony LC
57c5c394f5 🐛(backend) improve sanitizer ai_services
The json response of the AI service is badly formatted.
This commit improves the sanitizer to try
to handle the response correctly.
2024-12-11 21:18:40 +01:00
Anthony LC
be6da38a08 🐛(frontend) only owner will make initial content
In some cases, the sync of the initial content
is not being done correctly.
We will let only the owner of the document
to make the initial content.
2024-12-11 21:18:40 +01:00
Anthony LC
fc36ed08f1 🐛(frontend) fix initial content with collaboration
The way the initial content was created was causing
issues with the collaboration server.
As soon a user started typing, the problem was gone.
This commit fixes that by letting Blocknote
managing the initial content, then we update the
Blocknote initial content with our initial content.
2024-12-11 16:08:13 +01:00
Anthony LC
ed90769081 🐛(backend) fix sanitize problem IA
Albert send us back a malformed IA json, the
sanitize function was not able to handle it correctly.
We add a try catch on it, to not use the sanitizer if
the json.loads fails.
2024-12-11 15:21:56 +01:00
Anthony LC
a8310fa0ff ️(frontend) remove debounce on useHeadings
We remove the debounce on useHeadings, it
decreases the user experience and it's not
necessary a big performance improvement.
2024-12-11 14:54:41 +01:00
Anthony LC
a902e31521 🔧(helm) add ingress collaboration api
We need to keep the stickyness between the
collaboration api and the ws server, to do so,
we will use "upstream-hash-by: $arg_room", meaning
that the stickyness will be based on the room query.
We need to ahve 2 ingress to handle the
"collaboration_auth", only the ws routes has to
use the "collaboration_auth" subrequest.
2024-12-11 14:54:41 +01:00
Anthony LC
932ab13d97 📈(collaboration) add sentry
Add sentry to the collaboration server.
It will be used to log errors and exceptions.
2024-12-11 14:54:41 +01:00
Anthony LC
94a1ba7989 (backend) notify collaboration server
When an access is updated or removed, the
collaboration server is notified to reset the
access connection; by being disconnected, the
accesses will automatically reconnect by passing
by the ngnix subrequest, and so get the good
rights.
We do the same system when the document link is
updated, except here we reset every access
connection.
2024-12-11 14:54:41 +01:00
Anthony LC
bfecdbf83a (y-provider) add tests for y-provider server
We add jest tests for the y-provider server.
The CI will be able to run the tests.
2024-12-11 14:54:41 +01:00
Anthony LC
ba1cfc3c27 (y-provider) endpoint POST /collaboration/api/reset-connections
We want to be able to reset the connections of a document.
To do this, we need to be able to send a
request to the collaboration server.
To do so, we added the endpoint
POST "/collaboration/api/reset-connections"
to the collaboration server thanks to "express".
2024-12-11 14:54:41 +01:00
Samuel Paccoud - DINUM
2cba228a67 🧑‍💻(helm) rename minio root user password
Using "impress" as the name of minio's root user in Tilt's
dev environment, was triggering obfuscation of the logs in Tilt's
console each time the word "impress" was used.
This made the logs hard to read.
2024-12-11 14:54:41 +01:00
Samuel Paccoud - DINUM
66553ee236 (backend) add subrequest auth view for collaboration server
We need to improve security on the access to The collaboration server
We can use the same pattern as for media files leveraging the nginx
subrequest feature.
2024-12-11 14:54:41 +01:00
Samuel Paccoud - DINUM
64674b6a73 ♻️(backend) rename, factorize and improve the subrequest media auth view
We want to use the same pattern for the websocket collaboration service
authorization as what we use for media files.

This addition comes in the next commit but doing it efficiently
required factorizing some code with the media auth view.
2024-12-11 14:54:41 +01:00
Anthony LC
a9def8cb18 ♻️(frontend) create useHeadings hook
- We create the useHeadings hook to manage the
headings of the document and staty DRY.
- We use the headings store in IconOpenPanelEditor
and TableContent, to avoid prop drilling.
- We add a debounce on the onEditorContentChange
to improve a bit the performance.
2024-12-06 15:23:16 +01:00
Anthony LC
69186e9a26 🩺(CI) wait for services to be ready
We add a check to be sure all the services are
ready before starting the e2e tests.
2024-12-06 15:23:16 +01:00
Anthony LC
f606826098 ♻️(frontend) stop to use provider with version
Version are not editable, we don't need to activate
the collaboration provider for them.
Simplify the code by removing the provider
from the version.
2024-12-06 15:23:16 +01:00
Anthony LC
aff036d9fb 🚚(collaboration) change the websocket url name
We will have 2 urls targeting the server, better
to improve the naming to avoid confusion.
2024-12-06 15:23:16 +01:00
Anthony LC
57ed08994b 🔊(changelog) add missing logs
Some logs were missing or not at the good place.
This commit replaces them correctly.
2024-12-06 15:23:16 +01:00
rvveber
131eefa1ac 🔨(frontend) encapsulate title component
in order to modularize in the future
the title component is encapsulated.
2024-12-06 14:16:24 +01:00
Anthony LC
b4e639cc24 ♻️(frontend) adapt Blocknote button
Last upgrade of Blocknote changes the editor
method getSelection, the blocks were not being
selected in certain cases.
We updated the methods to select the blocks
correctly.
2024-12-05 23:34:06 +01:00
Samuel Paccoud - DINUM
ba962af914 ⬆️(backend) bump openai library version as it breaks tests
This looks like an instability in the openai library's definition
of dependencies.
2024-12-05 23:34:06 +01:00
Anthony LC
76514a6e2b 🏷️(frontend) adapt typing with recent upgrade
An upgrade to @sentry/nextjs@8.42.0 changed
some typing. It is not from @sentry/types but
from @sentry/core now.
2024-12-05 23:34:06 +01:00
Anthony LC
b69a5342d9 ⬇️(dependency) downgrade workbox-webpack-plugin to 7.1.0
In the 1.8.0 we experienced issues with the service
worker not updating properly. We suspect that the
workbox-webpack-plugin is the cause of this issue.
Better to downgrade to the last version that worked
until we have time to investigate the issue.
We add workbox-webpack-plugin to the renovate.json
file to avoid future updates.
2024-12-05 23:34:06 +01:00
renovate[bot]
c25682f199 ⬆️(dependencies) update js dependencies 2024-12-05 23:34:06 +01:00
Anthony LC
eec8b4d2c3 ♻️(frontend) adapt frontend with new access types
We don't get the accesses anymore from the backeend,
instead we get the number of accesses.
We remove the list of owners in the doc header because
we don't have easily this informations anymore and
we will have to do a bigger refacto.
2024-11-28 16:02:27 +01:00
Samuel Paccoud - DINUM
1af7b797bc (backend) test interference btw documents permissions and filtering
We want to make sure that applying filters on the document view list
does not interfere with permissions.
2024-11-28 16:02:27 +01:00
Samuel Paccoud - DINUM
b5c159bf63 (backend) allow filtering on document titles
This is the minimal and fast search feature, while we are working on
a full text search based on opensearch. For the moment we only search
on the title of the document.
2024-11-28 16:02:27 +01:00
Samuel Paccoud - DINUM
bfbdfb2b5c (backend) allow filtering documents by link reach
We want to be able to limit document list views to only public documents,
or only restricted or authenticated documents.
2024-11-28 16:02:27 +01:00
Samuel Paccoud - DINUM
08bb64ddc1 (backend) allow filtering by documents marked as favorite
We recently allowed authenticated users to mark a document as favorite.
We were lacking the possibility for users to see only the documents
they marked as favorite.
2024-11-28 16:02:27 +01:00
Samuel Paccoud - DINUM
23f90156bf (backend) add creator field on document and allow filtering on it
We want to be able to limit the documents displayed on a logged-in user's
list view by the documents they created or by the documents that other
users created.

This is different from having the "owner" role on a document because this
can be acquired and even lost. What we want here is to be able to
identify documents by the user who created them so we add a new field.
2024-11-28 16:02:27 +01:00
Samuel Paccoud - DINUM
1899cff572 🐛(backend) fix flaky test by clarifying user ordering
On the user search API by similarity, we had a flaky test because
2 users had the same similarity score. Adding a secondary ordering
field makes ordering deterministic between users who share the same
similarity score.
2024-11-28 16:02:27 +01:00
Samuel Paccoud - DINUM
774c2ce248 (backend) annotate number of accesses on documents
The new UI will display the number of accesses on each document.

/!\ Once team accesses will be used, this will not represent the number
    of people with access anymore and will have to be improved by
    computing the number of people in each team.
2024-11-28 16:02:27 +01:00
Samuel Paccoud - DINUM
89d9075850 (backend) allow users to mark/unmark documents as favorite
A user can now mark/unmark documents as favorite.
This is done via a new action of the document API endpoint:
/api/v1.0/documents/{document_id}/favorite
POST to mark as favorite / DELETE to unmark
2024-11-28 16:02:27 +01:00
Samuel Paccoud - DINUM
2c915d53f4 ️(backend) optimize number of queries on document list view
I realized most of the database queries made when getting a document
list view were to include nested accesses. This detailed information
about accesses in only necessary for the document detail view.

I introduced a specific serializer for the document list view with
less fields. For a list of 20 documents with 5 accesses, we go down
from 3x5x20= 300 queries to just 3 queries.
2024-11-28 16:02:27 +01:00
Anthony LC
797d9442ac 🔖(patch) release 1.8.2
Changed:

- ♻️(SW) change strategy html caching
2024-11-28 15:31:03 +01:00
Anthony LC
573d054748 ♻️(SW) change strategy html caching
We will use the network first strategy for the html
files. This will allow us to always have the
latest version of the html files.
2024-11-28 09:30:06 +01:00
Anthony LC
2035a256f5 🔖(patch) release 1.8.1
Fixed:
🐛(frontend) link not clickable and flickering firefox
2024-11-27 17:17:35 +01:00
Anthony LC
c94f26c8b9 ⬇️(SW) workbox-webpack-plugin to 7.1.0
A recent update to the workbox-webpack-plugin
package seems to introduce strange behavior.
Better to downgrade in waiting that it is more stable.
2024-11-27 16:50:11 +01:00
Anthony LC
fc2f14b3f4 🐛(frontend) link not clickable and flickering firefox
The link in the read mode was not clickable anymore,
it was due to a attempt to not display the cursor
of anonymous users.
We changes the way to do it by rendering our own cursor,
when a user is anonymous we don't render the cursor.
By rendering our own cursor we fixed another problem,
the cursor was flickering when the user was typing
at the end of the line on the firefox browser.
2024-11-27 16:50:11 +01:00
Anthony LC
6dd1697915 🐛(frontend) use hook useTranslation
Sentry highlitghted a few errors about the
function "t" not being defined. Better to get
it from the hook useTranslation.
2024-11-27 16:50:11 +01:00
Anthony LC
79e899c301 ♻️(frontend) add hooks useUploadFile
Move upload file logic to hooks useUploadFile.
It will be more readable and easy to reuse.
2024-11-27 16:50:11 +01:00
Anthony LC
2194301716 🔖(minor) release 1.8.0
Added:
- 🌐(backend) add german translation
- 🌐(frontend) Add German translation
- (frontend) Add a broadcast store
- (backend) whitelist pod's IP address
- (backend) config endpoint
- (frontend) config endpoint
- (frontend) add sentry
- (frontend) add crisp chatbot

Changed:
- 🚸(backend) improve users similarity search and
sort results
- ♻️(frontend) simplify stores
- (frontend) update $css Box props type to add
styled components RuleSet
- (CI) trivy continue on error

Fixed:
- 🔧(backend) fix logging for docker and make it
configurable by envar
- 🦺(backend) add comma to sub regex
- 🐛(editor) collaborative user tag hidden when
read only
- 🐛(frontend) users have view access when revoked
- 🐛(frontend) fix placeholder editable when double clicks
2024-11-27 09:47:42 +01:00
Anthony LC
0348894ab8 🐛(frontend) fix rerender title with broadcasting
The title was not rerendering on other clients
when the title was updated by one client.
This commit fixes the issue.
We set a min width for the title as well, it
will fix the issue with strange behavior when
people were double clicking.
2024-11-26 18:15:18 +01:00
Anthony LC
9b17d8bea1 🚨(frontend) remove Crisp warning
Remove the Crisp warning that was being displayed
on the console in our environments.
2024-11-26 18:15:18 +01:00
Anthony LC
69d6b6f934 (CI) trivy continue on error
Trivy is extremly flaky,
we need to continue on error to avoid
blocking the pipeline.
We still keep the check, to see if there are any
vulnerabilities, but we don't want to block
the pipeline.
2024-11-26 11:53:11 +01:00
Anthony LC
6c106374fa (frontend) add crisp chatbot
Integrate Crisp chatbot for immediate user support access.

This enables real-time interaction, enhancing user experience
by providing quick assistance.
2024-11-25 17:06:02 +01:00
Anthony LC
af039d045d 🔧(backend) add CRISP_WEBSITE_ID setting
Add setting CRISP_WEBSITE_ID. This setting is
used to configure the Crisp chat widget.
It will be available to the conf endpoint, to
be used by the frontend.
2024-11-25 17:06:02 +01:00
Anthony LC
4c9caf09ba ⬆️(CI) upgrade upload-artifact@v3 to v4
Upload artifact v3 is deprecated soon, so we need
to upgrade it to v4.
2024-11-25 13:16:06 +01:00
Anthony LC
3fd02adbec 💄(frontend) remove Blocknote fix
A recent upgrade of Blocknote to 0.19.2 fixed
a issue that we were solving. We removed our
fix as it is no longer needed.
2024-11-25 13:16:06 +01:00
Anthony LC
90dac3cd15 🏷️(frontend) update typescript types
We updated typescript to 5.7.2.
Some types were deprecated and we had to update them.
2024-11-25 13:16:06 +01:00
Anthony LC
d0307ee6d9 ⬆️(dependencies) update js dependencies 2024-11-25 13:16:06 +01:00
Anthony LC
09d02b7ced 🚚(frontend) move conf api urls to api folder
Previous refacto let only the api urls in the conf
file, so better to move it to the api folder.
2024-11-25 09:46:14 +01:00
Anthony LC
56a26d9663 🧪(CI) pass trivy security
The trivy security blocked the deploiement.
It says that we have a vulnerability because
we are using the cross-spawn@7.0.3 package, but
we are not, we are using the cross-spawn@7.0.6 package.
We will bypass this security check in the docker-hub.yml
file in waiting for another solution.
2024-11-25 09:46:14 +01:00
Anthony LC
42f809f6d4 ♻️(frontend) get collaboration server url from config endpoint
We centralized the configuration on the backend
side, it is easier to manage and we can change
the configuration without having to rebuild the
frontend.
We now use the config endpoint to get the collaboration
server url, we refacto to remove the frontend env
occurences and to adapt with the new way to get the
collaboration server url.
2024-11-25 09:46:14 +01:00
Anthony LC
7d64c82987 ♻️(frontend) get media url from config endpoint
We centralized the configuration on the backend
side, it is easier to manage and we can change
the configuration without having to rebuild the
frontend.
We now use the config endpoint to get the media url,
we refacto to remove the frontend env occurences
and to adapt with the new way to get the media url.
2024-11-25 09:46:14 +01:00
Anthony LC
6252227bb6 ♻️(frontend) get theme from config endpoint
We centralized the configuration on the backend
side, it is easier to manage and we can change
the configuration without having to rebuild the
frontend.
We now use the config endpoint to get the theme,
we refacto to remove the frontend env occurences
and to adapt with the new way to get the theme.
2024-11-25 09:46:14 +01:00
Anthony LC
e9ac393a8f (frontend) add sentry
In order to monitor the frontend, we are adding
sentry.
2024-11-25 09:46:14 +01:00
Anthony LC
5b1745f991 (frontend) add config provider
Add a ConfigProvider to the frontend to provide
configuration to the app.
The configuration is loaded from the config
endpoint, we will use react-query cache capabilities
to store the configuration.
2024-11-25 09:46:14 +01:00
Anthony LC
0e55bf5c43 🔒️(helm) allow server host and whitelist pod IP for health checks
In a Kubernetes environment, we need to whitelist the pod's IP address
to allow health checks to pass. This ensures that Kubernetes liveness and
readiness probes can access the application to verify its health.
2024-11-22 13:01:55 +01:00
Samuel Paccoud - DINUM
9f66f73501 🔧(backend) fix logging for docker and make it configurable by envar
Logs were not made to the console so it was hard to debug in k8s.
We propose a ready made logging configuration that sends everything
to the console and allow adjusting log levels with environment
variables.
2024-11-20 11:51:20 +01:00
Samuel Paccoud - DINUM
c3da28b07f ️(helm) bring back helm chart
This is a revert of 1da5a removing actual deployments and keeping
only the dev environment in Tilt.

The clean-up was a bit heavy handed. We should keep the Helm
chart to the development repository and move away only the
deployment configuration.
2024-11-20 11:51:20 +01:00
Anthony LC
b035b96dec ⬆️(CI) bump python version in backend test
We were testing the backend with python 3.10.0, but
actually the backend was running with python 3.12.6.
We bump the python version in the backend test to match
the running version of the backend.
2024-11-20 09:51:08 +01:00
Anthony LC
9623ac4141 🩹(backend) get current release from pyproject.toml
"get_release" was returning NA, we fixed it by
getting the version from pyproject.toml, to do so we
use tomllib
Since tomllib is a native library from Python 3.11,
we bump the required version to 3.11 on the pyproject.toml.
2024-11-20 09:51:08 +01:00
Anthony LC
c8edbd285b 🔧(backend) add FRONTEND_THEME setting
The frontend need to know the theme to be used,
so we need to add a new setting to the backend,
in order to expose this value to the frontend.
2024-11-20 09:51:08 +01:00
Anthony LC
016597d5a2 🔧(backend) add COLLABORATION_SERVER_URL setting
The frontend need to know the collab server url,
so we need to add a new setting to the backend,
in order to expose this value to the frontend.
If the setting is not defined, the frontend current
domain will be used as the base url.
In production this setting do not need to be defined
since we have nginx capturing the ws requests,
but in development we need to define it to target
the collaboration server.
2024-11-20 09:51:08 +01:00
Anthony LC
52dea8fa2f 🔧(backend) add MEDIA_BASE_URL setting
The frontend need to know the base url for the
media files, so we need to add a new setting
to the backend, in order to expose this value
to the frontend.
If the setting is not defined, the frontend current
domain will be used as the base url.
In production this setting do not need to be defined
since we have nginx capturing the media requests,
but in development we need to define it to target
the nginx server.
2024-11-20 09:51:08 +01:00
Anthony LC
0a37a8ea6d (backend) add public endpoint /api/v1.0/config/
Add public endpoint /api/v1.0/config/ to
share some public configuration values.
2024-11-20 09:51:08 +01:00
Anthony LC
c1404ef904 ⬆️(dependencies) bump cross-spawn from 7.0.3 to 7.0.6
Bumps cross-spawn from 7.0.3 to 7.0.6.

---
updated-dependencies:
- dependency-name: cross-spawn
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-20 09:24:34 +01:00
renovate[bot]
2c0fce61df ⬆️(dependencies) update js dependencies 2024-11-18 17:25:16 +01:00
Nathan Panchout
bbe9b6b6cf (frontend) add styledCss props to Box component
In order to facilitate DX and not to use a string in the code for the css.
We add the $styledCss props to the Box component.
This object comes from Styled component
2024-11-15 10:33:56 +01:00
Anthony LC
23231563c9 💄(frontend) text color on Blocknote code block options
The options for the code block in the Blocknote
editor was not visible. We changed the text color
to make it visible.
A fix will be made to the code block options in the
next blocknote release.
2024-11-14 17:36:11 +01:00
Anthony LC
d75c8668c5 🚨(frontend) blocknote cast to Dictionnary
The last Blocknote upgrade (0.19.0) gives us
a warning with the dictionnary typing.
We cast it to the correct type to remove the warning.
2024-11-14 17:36:11 +01:00
Anthony LC
f266232b5a ♻️(frontend) use next/router instead of next/navigation
The last upgrade of next.js gives a warning
when we were using next/navigation with the
pages router.
This commit fixes this issue.
2024-11-14 17:36:11 +01:00
Anthony LC
a8362e8e88 ⬆️(dependencies) update js dependencies 2024-11-14 17:36:11 +01:00
Anthony LC
e4dfae1905 ♻️(frontend) simplify useDocStore
We moved the editor store to its own store in the previous
commit. This change allow us to simplify useDocStore.
2024-11-13 15:25:29 +01:00
Anthony LC
a09e740648 ♻️(frontend) move editor store to useEditorStore
Previous changes migrated the editor store to
doc-management, we move it back doc-editor and
simplify it.
2024-11-13 15:25:29 +01:00
Anthony LC
5ee6a43f08 (frontend) add useBroadcastStore
Add the useBroadcastStore.
It will give us the ability to easily
broadcast actions to all connected clients.

In this case, we requery the doc to everyone
when a change relative to the doc rights is made.
2024-11-09 10:21:24 +01:00
Anthony LC
8bd83cbfcd 🚚(frontend) move useDocStore to doc-management
We want to make more accessible the doc store
to every feature, so we move it to the
doc-management folder.
2024-11-09 10:21:24 +01:00
Anthony LC
bc14d1d0f8 🐛(editor) collaborative user tag hidden when read only
When the user was in read-only mode, the user
tag could be displayed when they were touching the
doc. This commit fixes this issue.
We add the full name instead of the email in the
cursor tag.
2024-11-08 12:01:23 +01:00
Anthony LC
526e649f06 🦺(backend) add comma to sub regex
Some sub have comma, the regex was a bit too strict
and didn't allow it, this commit fixes that.
2024-11-08 10:53:53 +01:00
Anthony LC
ac40eb8f7c 🌐(frontend) add German translation
- Add the german translation to Docs
- Add the german language to the frontend
language picker
2024-11-07 15:58:49 +01:00
lindenb1
c750cf10a8 🌐(backend) adding de_DE translation for the backend
This adds German translation to the backend and
adjusts the .po file sequence by priority.

Signed-off-by: lindenb1 <linden@b1-systems.de>
2024-11-07 11:49:41 +01:00
Samuel Paccoud - DINUM
4f4951cdcd 🚸(backend) improve users similarity search and sort results
In some edge cases, the domain part the email addresse is
longer than the name part. Users searches by email similarity
then return a lot of unsorted results.

We can improve this by being more demanding on similarity when
the query looks like an email. Sorting results by the similarity
score is also an obvious improvement.

At the moment, we still think it is good to propose results with
a weak similarity on the name part because we want to avoid
as much as possible creating duplicate users by inviting one of
is many emails, a user who is already in our database.

Fixes 399
2024-11-06 08:27:18 +01:00
Anthony LC
50891afd05 🔖(minor) release 1.7.0
Added:
- 📝Contributing.md
- 🌐(frontend) add localization to editor
- Public and restricted doc editable
- (frontend) Add full name if available
- (backend) Add view accesses ability

Changed:
- ♻️(frontend) avoid documents indexing in search engine
- ♻️(frontend) list accesses if user has abilities
- 👔(backend) doc restricted by default

Fixed:
- 🐛(backend) require right to manage document
  accesses to see invitations
- 🐛(i18n) same frontend and backend language using
  shared cookies
- 🐛(frontend) add default toolbar buttons
- 🐛(frontend) throttle error correctly display

Removed:
- 🔥(helm) remove infra related codes
2024-10-25 14:41:48 +02:00
Anthony LC
cbb6fc740a 👔(backend) doc restricted by default
By default a created document was in "authenticated"
mode, we switch to "restricted" by default.
2024-10-25 14:25:48 +02:00
Anthony LC
31c3dd6119 🛂(frontend) show member list depend ability
We integrate the new ability "accesses_view" that
tells if a user can view the accesses of a document.
2024-10-24 17:31:34 +02:00
Samuel Paccoud - DINUM
15700ddd8d (backend) add new ability on document "accesses_view"
We need this ability in the frontend to know whether we should try
to display the list of users who have document accesses. If this
ability is False (e.g for anonymous users), we should only show
the link reach and link role when clicking on the "Share" button.
2024-10-24 17:31:34 +02:00
Anthony LC
d8673a8cf7 (frontend) display full name if available
We can get the full name from the OIDC, so we should
display it if available.
2024-10-24 10:52:58 +02:00
NathanPanchout
a5af9f0776 🐛(frontend) avoid documents indexing in search engine
Some documents are available publicly (without being logged) and may thus end-up
being indexed by search engine.
2024-10-24 10:43:13 +02:00
Anthony LC
d715e7b3b6 🌐(frontend) translate last features
Translate:
- Mardown Buttons
- doc public editable
2024-10-24 10:15:28 +02:00
Jacques ROUSSEL
1da5a6a411 🗑️(ci) clean old deployment and ci
We move deployment stuff to a new repository. we don't need this
codeanymore
2024-10-24 09:50:18 +02:00
Anthony LC
af5ffc22ac (e2e) fix flaky tests
Fix a flaky tests on the e2e test:
- "it renders correctly when we switch from one doc
to another"
- "it saves the doc when we change pages"
2024-10-23 18:11:08 +02:00
Anthony LC
3434029654 ♻️(frontend) improve handleAIError
To display the throttle error messages,
we are doing a condition on the error message
that we get from the backend.
It is error prone because the backend error
message are internationalized.
This commit fixes this issue.
It DRY the component as well.
2024-10-23 18:11:08 +02:00
virgile-deville
6baa06bd3f 📝(Documentation) add an issue selection section
Added a link to the github project so that contributors know what to prioritize.
2024-10-23 17:08:48 +02:00
Anthony LC
8107d4f531 📝(contributing) add changelog part in contributing
We add a new section in the CONTRIBUTING.md file
to explain how to update the CHANGELOG.md file.
We improve the pull request section as well.
2024-10-23 12:46:49 +02:00
Anthony LC
f8c8044605 🧑‍💻(makefile) add frontend-lint cmd
Add the command frontend-lint to the makefile.
2024-10-23 12:46:49 +02:00
rvveber
a84f4de02c 🔨(i18n) disable key separation for translations
Improves on commit bfde526
2024-10-23 12:35:48 +02:00
rvveber
3c374e3cc7 🐛(i18n) same frontend and backend language using shared cookies
frontend: switch to cookie-based language selection
backend: use cookie for language
2024-10-23 12:35:48 +02:00
Anthony LC
ff364f8b3d (frontend) increase doc visibility options
We now have 3 visibility options for docs:
- public
- restricted
- authenticated

We also have 2 editability options:
- readonly
- editable

The editability options are only available
for public and authenticated docs.
2024-10-23 11:20:33 +02:00
Anthony LC
c0cb12f002 ♻️(frontend) minor components update
- change flex property of Box component
- Forward the ref of Text component
- globalize tooltip padding
2024-10-23 11:20:33 +02:00
Samuel Paccoud - DINUM
0f0f812059 🐛(backend) fix invitations API endpoint access rights
Only users who have the rights to manage accesses on the document should
be allowed to see and manipulate invitations. Other users can see access
rights on the document but only when the corresponding user/team has
actually been granted access.

We added a parameter in document abilities so the frontend knows when
the logged-in user can invite another user with the owner role or not.
2024-10-22 19:39:59 +02:00
NathanPanchout
7fc59ed497 🌐(frontend) add localization to editor
Currently, when you change language the editor does not change. So we add this
functionality
2024-10-22 13:54:20 +02:00
renovate[bot]
60120852f5 ⬆️(dependencies) update js dependencies 2024-10-21 09:55:17 +02:00
Anthony LC
f2c389e2b3 🐛(frontend) add default toolbar buttons
We are overriding the default toolbar to add the
markdown and ai buttons. By doing that we were
missing some default buttons that are useful depend
on the block type. This commit adds the default
buttons to the toolbar.
2024-10-21 09:45:47 +02:00
renovate[bot]
305359ae15 ⬆️(dependencies) update python dependencies 2024-10-21 09:20:33 +02:00
Anthony LC
e35671c450 📝(docs) add CONTRIBUTING.md doc
Add a CONTRIBUTING.md file to the project root
to help new contributors understand how to
contribute to the project.
2024-10-18 09:33:38 +02:00
182 changed files with 11043 additions and 5719 deletions

View File

@@ -1,52 +0,0 @@
name: Deploy
on:
push:
tags:
- 'preprod'
- 'production'
jobs:
notify-argocd:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "impress,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/impress/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Call argocd github webhook
run: |
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}'
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_WEBHOOK_URL
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_PRODUCTION_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_PRODUCTION_WEBHOOK_URL
start-test-on-preprod:
needs:
- notify-argocd
runs-on: ubuntu-latest
if: startsWith(github.event.ref, 'refs/tags/preprod')
steps:
-
name: Debug
run: |
echo "Start test when preprod is ready"

View File

@@ -55,6 +55,7 @@ jobs:
with:
docker-build-args: '--target backend-production -f Dockerfile'
docker-image-name: 'docker.io/lasuite/impress-backend:${{ github.sha }}'
continue-on-error: true
-
name: Build and push
uses: docker/build-push-action@v6
@@ -105,6 +106,7 @@ jobs:
with:
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
continue-on-error: true
-
name: Build and push
uses: docker/build-push-action@v6
@@ -156,6 +158,7 @@ jobs:
with:
docker-build-args: '-f src/frontend/Dockerfile --target y-provider'
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
continue-on-error: true
-
name: Build and push
uses: docker/build-push-action@v6

View File

@@ -19,7 +19,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "18.x"
node-version: "20.x"
- name: Restore the frontend cache
uses: actions/cache@v4
@@ -46,6 +46,11 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20.x"
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
@@ -54,7 +59,7 @@ jobs:
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Test App
run: cd src/frontend/ && yarn app:test
run: cd src/frontend/ && yarn test
lint-front:
runs-on: ubuntu-latest
@@ -96,10 +101,32 @@ jobs:
- name: Install Playwright Browsers
run: cd src/frontend/apps/e2e && yarn install-playwright chromium
# Tool to wait for a service to be ready
- name: Install Dockerize
run: |
curl -sSL https://github.com/jwilder/dockerize/releases/download/v0.8.0/dockerize-linux-amd64-v0.8.0.tar.gz | sudo tar -C /usr/local/bin -xzv
- name: Wait for services to be ready
run: |
printf "Minio check...\n"
dockerize -wait tcp://localhost:9000 -timeout 20s
printf "Keyclock check...\n"
dockerize -wait tcp://localhost:8080 -timeout 20s
printf "Server collaboration check...\n"
dockerize -wait tcp://localhost:4444 -timeout 20s
printf "Ngnix check...\n"
dockerize -wait tcp://localhost:8083 -timeout 20s
printf "DRF check...\n"
dockerize -wait tcp://localhost:8071 -timeout 20s
printf "Postgres Keyclock check...\n"
dockerize -wait tcp://localhost:5433 -timeout 20s
printf "Postgres back check...\n"
dockerize -wait tcp://localhost:15432 -timeout 20s
- name: Run e2e tests
run: cd src/frontend/ && yarn e2e:test --project='chromium'
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-chromium-report
@@ -133,7 +160,7 @@ jobs:
- name: Run e2e tests
run: cd src/frontend/ && yarn e2e:test --project=firefox --project=webkit
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-other-report

View File

@@ -107,7 +107,9 @@ jobs:
- name: Install Python
uses: actions/setup-python@v3
with:
python-version: "3.10"
python-version: "3.12.6"
- name: Upgrade pip and setuptools
run: pip install --upgrade pip setuptools
- name: Install development dependencies
run: pip install --user .[dev]
- name: Check code formatting with ruff
@@ -199,7 +201,7 @@ jobs:
- name: Install Python
uses: actions/setup-python@v3
with:
python-version: "3.10"
python-version: "3.12.6"
- name: Install development dependencies
run: pip install --user .[dev]

View File

@@ -9,6 +9,98 @@ and this project adheres to
## [Unreleased]
## [1.9.0] - 2024-12-11
## Added
- ✨(backend) annotate number of accesses on documents in list view #429
- ✨(backend) allow users to mark/unmark documents as favorite #429
## Changed
- 🔒️(collaboration) increase collaboration access security #472
- 🔨(frontend) encapsulated title to its own component #474
- ⚡️(backend) optimize number of queries on document list view #429
- ♻️(frontend) stop to use provider with version #480
- 🚚(collaboration) change the websocket key name #480
## Fixed
- 🐛(frontend) fix initial content with collaboration #484
- 🐛(frontend) Fix hidden menu on Firefox #468
- 🐛(backend) fix sanitize problem IA #490
## [1.8.2] - 2024-11-28
## Changed
- ♻️(SW) change strategy html caching #460
## [1.8.1] - 2024-11-27
## Fixed
- 🐛(frontend) link not clickable and flickering firefox #457
## [1.8.0] - 2024-11-25
## Added
- 🌐(backend) add German translation #259
- 🌐(frontend) add German translation #255
- ✨(frontend) add a broadcast store #387
- ✨(backend) whitelist pod's IP address #443
- ✨(backend) config endpoint #425
- ✨(frontend) config endpoint #424
- ✨(frontend) add sentry #424
- ✨(frontend) add crisp chatbot #450
## Changed
- 🚸(backend) improve users similarity search and sort results #391
- ♻️(frontend) simplify stores #402
- ✨(frontend) update $css Box props type to add styled components RuleSet #423
- ✅(CI) trivy continue on error #453
## Fixed
- 🔧(backend) fix logging for docker and make it configurable by envar #427
- 🦺(backend) add comma to sub regex #408
- 🐛(editor) collaborative user tag hidden when read only #385
- 🐛(frontend) users have view access when revoked #387
- 🐛(frontend) fix placeholder editable when double clicks #454
## [1.7.0] - 2024-10-24
## Added
- 📝Contributing.md #352
- 🌐(frontend) add localization to editor #368
- ✨Public and restricted doc editable #357
- ✨(frontend) Add full name if available #380
- ✨(backend) Add view accesses ability #376
## Changed
- ♻️(frontend) list accesses if user has abilities #376
- ♻️(frontend) avoid documents indexing in search engine #372
- 👔(backend) doc restricted by default #388
## Fixed
- 🐛(backend) require right to manage document accesses to see invitations #369
- 🐛(i18n) same frontend and backend language using shared cookies #365
- 🐛(frontend) add default toolbar buttons #355
- 🐛(frontend) throttle error correctly display #378
## Removed
- 🔥(helm) remove infra related codes #366
## [1.6.0] - 2024-10-17
@@ -16,11 +108,11 @@ and this project adheres to
- ✨AI to doc editor #250
- ✨(backend) allow uploading more types of attachments #309
- ✨(frontend) add buttons to copy document to clipboard as HTML/Markdown #300
- ✨(frontend) add buttons to copy document to clipboard as HTML/Markdown #318
## Changed
- ♻️(frontend) More multi theme friendly #325
- ♻️(frontend) more multi theme friendly #325
- ♻️ Bootstrap frontend #257
- ♻️ Add username in email #314
@@ -211,7 +303,12 @@ and this project adheres to
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.6.0...main
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.9.0...main
[v1.9.0]: https://github.com/numerique-gouv/impress/releases/v1.9.0
[v1.8.2]: https://github.com/numerique-gouv/impress/releases/v1.8.2
[v1.8.1]: https://github.com/numerique-gouv/impress/releases/v1.8.1
[v1.8.0]: https://github.com/numerique-gouv/impress/releases/v1.8.0
[v1.7.0]: https://github.com/numerique-gouv/impress/releases/v1.7.0
[v1.6.0]: https://github.com/numerique-gouv/impress/releases/v1.6.0
[1.5.1]: https://github.com/numerique-gouv/impress/releases/v1.5.1
[1.5.0]: https://github.com/numerique-gouv/impress/releases/v1.5.0

79
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,79 @@
# Contributing to the Project
Thank you for taking the time to contribute! Please follow these guidelines to ensure a smooth and productive workflow. 🚀🚀🚀
To get started with the project, please refer to the [README.md](https://github.com/numerique-gouv/impress/blob/main/README.md) for detailed instructions.
## Creating an Issue
When creating an issue, please provide the following details:
1. **Title**: A concise and descriptive title for the issue.
2. **Description**: A detailed explanation of the issue, including relevant context or screenshots if applicable.
3. **Steps to Reproduce**: If the issue is a bug, include the steps needed to reproduce the problem.
4. **Expected vs. Actual Behavior**: Describe what you expected to happen and what actually happened.
5. **Labels**: Add appropriate labels to categorize the issue (e.g., bug, feature request, documentation).
## Selecting an issue
We use a [GitHub Project](https://github.com/orgs/numerique-gouv/projects/13) in order to prioritize our workload.
Please check in priority the issues that are in the **todo** column and have a higher priority (P0 -> P2).
## Commit Message Format
All commit messages must adhere to the following format:
`<gitmoji>(type) title description`
* <**gitmoji**>: Use a gitmoji to represent the purpose of the commit. For example, ✨ for adding a new feature or 🔥 for removing something, see the list here: <https://gitmoji.dev/>.
* **(type)**: Describe the type of change. Common types include `backend`, `frontend`, `CI`, `docker` etc...
* **title**: A short, descriptive title for the change, starting with a lowercase character.
* **description**: Include additional details about what was changed and why.
### Example Commit Message
```
✨(frontend) add user authentication logic
Implemented login and signup features, and integrated OAuth2 for social login.
```
## Changelog Update
Please add a line to the changelog describing your development. The changelog entry should include a brief summary of the changes, this helps in tracking changes effectively and keeping everyone informed. We usually include the title of the pull request, followed by the pull request ID to finish the log entry. The changelog line should be less than 80 characters in total.
### Example Changelog Message
```
## [Unreleased]
## Added
- ✨(frontend) add AI to the project #321
```
## Pull Requests
It is nice to add information about the purpose of the pull request to help reviewers understand the context and intent of the changes. If you can, add some pictures or a small video to show the changes.
### Don't forget to:
- check your commits
- check the linting: `make lint && make frontend-lint`
- check the tests: `make test`
- add a changelog entry
Once all the required tests have passed, you can request a review from the project maintainers.
## Code Style
Please maintain consistency in code style. Run any linting tools available to make sure the code is clean and follows the project's conventions.
## Tests
Make sure that all new features or fixes have corresponding tests. Run the test suite before pushing your changes to ensure that nothing is broken.
## Asking for Help
If you need any help while contributing, feel free to open a discussion or ask for guidance in the issue tracker. We are more than happy to assist!
Thank you for your contributions! 👍

View File

@@ -122,8 +122,8 @@ logs: ## display app-dev logs (follow mode)
run: ## start the wsgi (production) and development server
@$(COMPOSE) up --force-recreate -d celery-dev
@$(COMPOSE) up --force-recreate -d nginx
@$(COMPOSE) up --force-recreate -d y-provider
@$(COMPOSE) up --force-recreate -d nginx
@echo "Wait for postgresql to be up..."
@$(WAIT_DB)
.PHONY: run
@@ -314,6 +314,10 @@ frontend-install: ## install the frontend locally
cd $(PATH_FRONT_IMPRESS) && yarn
.PHONY: frontend-install
frontend-lint: ## run the frontend linter
cd $(PATH_FRONT) && yarn lint
.PHONY: frontend-lint
run-frontend-development: ## Run the frontend in development mode
@$(COMPOSE) stop frontend-dev
cd $(PATH_FRONT_IMPRESS) && yarn dev

View File

@@ -118,6 +118,7 @@ services:
depends_on:
- keycloak
- app-dev
- y-provider
frontend-dev:
user: "${DOCKER_USER:-1000}"
@@ -161,6 +162,8 @@ services:
dockerfile: ./src/frontend/Dockerfile
target: y-provider
restart: unless-stopped
env_file:
- env.d/development/common
ports:
- "4444:4444"
volumes:

View File

@@ -4,9 +4,58 @@ server {
server_name localhost;
charset utf-8;
# Proxy auth for collaboration server
location /collaboration/ws/ {
# Collaboration Auth request configuration
auth_request /collaboration-auth;
auth_request_set $authHeader $upstream_http_authorization;
auth_request_set $canEdit $upstream_http_x_can_edit;
auth_request_set $userId $upstream_http_x_user_id;
# Pass specific headers from the auth response
proxy_set_header Authorization $authHeader;
proxy_set_header X-Can-Edit $canEdit;
proxy_set_header X-User-Id $userId;
# Ensure WebSocket upgrade
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
# Collaboration server
proxy_pass http://y-provider:4444;
# Set appropriate timeout for WebSocket
proxy_read_timeout 86400;
proxy_send_timeout 86400;
# Preserve original host and additional headers
proxy_set_header Host $host;
}
location /collaboration-auth {
proxy_pass http://app-dev:8000/api/v1.0/documents/collaboration-auth/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Original-URL $request_uri;
# Prevent the body from being passed
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-Method $request_method;
}
location /collaboration/api/ {
# Collaboration server
proxy_pass http://y-provider:4444;
proxy_set_header Host $host;
}
# Proxy auth for media
location /media/ {
# Auth request configuration
auth_request /auth;
auth_request /media-auth;
auth_request_set $authHeader $upstream_http_authorization;
auth_request_set $authDate $upstream_http_x_amz_date;
auth_request_set $authContentSha256 $upstream_http_x_amz_content_sha256;
@@ -21,8 +70,8 @@ server {
proxy_set_header Host minio:9000;
}
location /auth {
proxy_pass http://app-dev:8000/api/v1.0/documents/retrieve-auth/;
location /media-auth {
proxy_pass http://app-dev:8000/api/v1.0/documents/media-auth/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

View File

@@ -4,6 +4,12 @@ DJANGO_SECRET_KEY=ThisIsAnExampleKeyForDevPurposeOnly
DJANGO_SETTINGS_MODULE=impress.settings
DJANGO_SUPERUSER_PASSWORD=admin
# Logging
# Set to DEBUG level for dev only
LOGGING_LEVEL_HANDLERS_CONSOLE=INFO
LOGGING_LEVEL_LOGGERS_ROOT=INFO
LOGGING_LEVEL_LOGGERS_APP=INFO
# Python
PYTHONPATH=/app
@@ -21,6 +27,7 @@ STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStora
AWS_S3_ENDPOINT_URL=http://minio:9000
AWS_S3_ACCESS_KEY_ID=impress
AWS_S3_SECRET_ACCESS_KEY=password
MEDIA_BASE_URL=http://localhost:8083
# OIDC
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/certs
@@ -44,3 +51,12 @@ OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
AI_BASE_URL=https://openaiendpoint.com
AI_API_KEY=password
AI_MODEL=llama
# Collaboration
COLLABORATION_API_URL=http://nginx:8083/collaboration/api/
COLLABORATION_SERVER_ORIGIN=http://localhost:3000
COLLABORATION_SERVER_SECRET=my-secret
COLLABORATION_WS_URL=ws://localhost:8083/collaboration/ws/
# Frontend
FRONTEND_THEME=dsfr

View File

@@ -13,7 +13,13 @@
"enabled": false,
"groupName": "ignored js dependencies",
"matchManagers": ["npm"],
"matchPackageNames": ["fetch-mock", "node", "node-fetch", "eslint"]
"matchPackageNames": [
"fetch-mock",
"node",
"node-fetch",
"eslint",
"workbox-webpack-plugin"
]
}
]
}

View File

@@ -0,0 +1,69 @@
"""API filters for Impress' core application."""
from django.utils.translation import gettext_lazy as _
import django_filters
from core import models
class DocumentFilter(django_filters.FilterSet):
"""
Custom filter for filtering documents.
"""
is_creator_me = django_filters.BooleanFilter(
method="filter_is_creator_me", label=_("Creator is me")
)
is_favorite = django_filters.BooleanFilter(
method="filter_is_favorite", label=_("Favorite")
)
title = django_filters.CharFilter(
field_name="title", lookup_expr="icontains", label=_("Title")
)
class Meta:
model = models.Document
fields = ["is_creator_me", "is_favorite", "link_reach", "title"]
# pylint: disable=unused-argument
def filter_is_creator_me(self, queryset, name, value):
"""
Filter documents based on the `creator` being the current user.
Example:
- /api/v1.0/documents/?is_creator_me=true
→ Filters documents created by the logged-in user
- /api/v1.0/documents/?is_creator_me=false
→ Filters documents created by other users
"""
user = self.request.user
if not user.is_authenticated:
return queryset
if value:
return queryset.filter(creator=user)
return queryset.exclude(creator=user)
# pylint: disable=unused-argument
def filter_is_favorite(self, queryset, name, value):
"""
Filter documents based on whether they are marked as favorite by the current user.
Example:
- /api/v1.0/documents/?is_favorite=true
→ Filters documents marked as favorite by the logged-in user
- /api/v1.0/documents/?is_favorite=false
→ Filters documents not marked as favorite by the logged-in user
"""
user = self.request.user
if not user.is_authenticated:
return queryset
if value:
return queryset.filter(favorited_by_users__user=user)
return queryset.exclude(favorited_by_users__user=user)

View File

@@ -1,9 +1,12 @@
"""Permission handlers for the impress core app."""
from django.core import exceptions
from django.db.models import Q
from rest_framework import permissions
from core.models import DocumentAccess, RoleChoices
ACTION_FOR_METHOD_TO_PERMISSION = {
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}
}
@@ -59,6 +62,38 @@ class IsOwnedOrPublic(IsAuthenticated):
return False
class CanCreateInvitationPermission(permissions.BasePermission):
"""
Custom permission class to handle permission checks for managing invitations.
"""
def has_permission(self, request, view):
user = request.user
# Ensure the user is authenticated
if not (bool(request.auth) or request.user.is_authenticated):
return False
# Apply permission checks only for creation (POST requests)
if view.action != "create":
return True
# Check if resource_id is passed in the context
try:
document_id = view.kwargs["resource_id"]
except KeyError as exc:
raise exceptions.ValidationError(
"You must set a document ID in kwargs to manage document invitations."
) from exc
# Check if the user has access to manage invitations (Owner/Admin roles)
return DocumentAccess.objects.filter(
Q(user=user) | Q(team__in=user.teams),
document=document_id,
role__in=[RoleChoices.OWNER, RoleChoices.ADMIN],
).exists()
class AccessPermission(permissions.BasePermission):
"""Permission class for access objects."""

View File

@@ -137,32 +137,69 @@ class BaseResourceSerializer(serializers.ModelSerializer):
return {}
class DocumentSerializer(BaseResourceSerializer):
"""Serialize documents."""
class ListDocumentSerializer(BaseResourceSerializer):
"""Serialize documents with limited fields for display in lists."""
content = serializers.CharField(required=False)
accesses = DocumentAccessSerializer(many=True, read_only=True)
is_favorite = serializers.BooleanField(read_only=True)
nb_accesses = serializers.IntegerField(read_only=True)
class Meta:
model = models.Document
fields = [
"id",
"content",
"title",
"accesses",
"abilities",
"content",
"created_at",
"creator",
"is_favorite",
"link_role",
"link_reach",
"created_at",
"nb_accesses",
"title",
"updated_at",
]
read_only_fields = [
"id",
"accesses",
"abilities",
"created_at",
"creator",
"is_favorite",
"link_role",
"link_reach",
"nb_accesses",
"updated_at",
]
class DocumentSerializer(ListDocumentSerializer):
"""Serialize documents with all fields for display in detail views."""
content = serializers.CharField(required=False)
class Meta:
model = models.Document
fields = [
"id",
"abilities",
"content",
"created_at",
"creator",
"is_favorite",
"link_role",
"link_reach",
"nb_accesses",
"title",
"updated_at",
]
read_only_fields = [
"id",
"abilities",
"created_at",
"creator",
"is_avorite",
"link_role",
"link_reach",
"nb_accesses",
"updated_at",
]
@@ -328,48 +365,36 @@ class InvitationSerializer(serializers.ModelSerializer):
return {}
def validate(self, attrs):
"""Validate and restrict invitation to new user based on email."""
"""Validate invitation data."""
request = self.context.get("request")
user = getattr(request, "user", None)
role = attrs.get("role")
try:
document_id = self.context["resource_id"]
except KeyError as exc:
raise exceptions.ValidationError(
"You must set a document ID in kwargs to create a new document invitation."
) from exc
attrs["document_id"] = self.context["resource_id"]
if not user and user.is_authenticated:
raise exceptions.PermissionDenied(
"Anonymous users are not allowed to create invitations."
)
# Only set the issuer if the instance is being created
if self.instance is None:
attrs["issuer"] = user
if not models.DocumentAccess.objects.filter(
Q(user=user) | Q(team__in=user.teams),
document=document_id,
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
).exists():
raise exceptions.PermissionDenied(
"You are not allowed to manage invitations for this document."
)
return attrs
if (
role == models.RoleChoices.OWNER
and not models.DocumentAccess.objects.filter(
def validate_role(self, role):
"""Custom validation for the role field."""
request = self.context.get("request")
user = getattr(request, "user", None)
document_id = self.context["resource_id"]
# If the role is OWNER, check if the user has OWNER access
if role == models.RoleChoices.OWNER:
if not models.DocumentAccess.objects.filter(
Q(user=user) | Q(team__in=user.teams),
document=document_id,
role=models.RoleChoices.OWNER,
).exists()
):
raise exceptions.PermissionDenied(
"Only owners of a document can invite other users as owners."
)
).exists():
raise serializers.ValidationError(
"Only owners of a document can invite other users as owners."
)
attrs["document_id"] = document_id
attrs["issuer"] = user
return attrs
return role
class VersionFilterSerializer(serializers.Serializer):

View File

@@ -1,57 +1,57 @@
"""API endpoints"""
# pylint: disable=too-many-lines
import logging
import re
import uuid
from urllib.parse import urlparse
from django.conf import settings
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.search import TrigramSimilarity
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.db import models as db
from django.db.models import (
Min,
Count,
Exists,
OuterRef,
Q,
Subquery,
Value,
)
from django.http import Http404
import rest_framework as drf
from botocore.exceptions import ClientError
from rest_framework import (
decorators,
exceptions,
filters,
metadata,
mixins,
pagination,
status,
viewsets,
)
from rest_framework import (
response as drf_response,
)
from django_filters import rest_framework as drf_filters
from rest_framework import filters
from rest_framework.permissions import AllowAny
from core import enums, models
from core.services.ai_services import AIService
from core.services.collaboration_services import CollaborationService
from . import permissions, serializers, utils
from .filters import DocumentFilter
logger = logging.getLogger(__name__)
ATTACHMENTS_FOLDER = "attachments"
UUID_REGEX = (
r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"
)
FILE_EXT_REGEX = r"\.[a-zA-Z]{3,4}"
MEDIA_URL_PATTERN = re.compile(
f"{settings.MEDIA_URL:s}({UUID_REGEX:s})/"
f"({ATTACHMENTS_FOLDER:s}/{UUID_REGEX:s}{FILE_EXT_REGEX:s})$"
MEDIA_STORAGE_URL_PATTERN = re.compile(
f"{settings.MEDIA_URL:s}(?P<pk>{UUID_REGEX:s})/"
f"(?P<key>{ATTACHMENTS_FOLDER:s}/{UUID_REGEX:s}{FILE_EXT_REGEX:s})$"
)
COLLABORATION_WS_URL_PATTERN = re.compile(rf"(?:^|&)room=(?P<pk>{UUID_REGEX})(?:&|$)")
# pylint: disable=too-many-ancestors
ATTACHMENTS_FOLDER = "attachments"
class NestedGenericViewSet(viewsets.GenericViewSet):
class NestedGenericViewSet(drf.viewsets.GenericViewSet):
"""
A generic Viewset aims to be used in a nested route context.
e.g: `/api/v1.0/resource_1/<resource_1_pk>/resource_2/<resource_2_pk>/`
@@ -123,7 +123,7 @@ class SerializerPerActionMixin:
return self.serializer_classes.get(self.action, self.default_serializer_class)
class Pagination(pagination.PageNumberPagination):
class Pagination(drf.pagination.PageNumberPagination):
"""Pagination to display no more than 100 objects per page sorted by creation date."""
ordering = "-created_on"
@@ -132,7 +132,7 @@ class Pagination(pagination.PageNumberPagination):
class UserViewSet(
mixins.UpdateModelMixin, viewsets.GenericViewSet, mixins.ListModelMixin
drf.mixins.UpdateModelMixin, drf.viewsets.GenericViewSet, drf.mixins.ListModelMixin
):
"""User ViewSet"""
@@ -156,11 +156,24 @@ class UserViewSet(
# Filter users by email similarity
if query := self.request.GET.get("q", ""):
# For performance reasons we filter first by similarity, which relies on an index,
# then only calculate precise similarity scores for sorting purposes
queryset = queryset.filter(email__trigram_word_similar=query)
queryset = queryset.annotate(
similarity=TrigramSimilarity("email", query)
)
# When the query only is on the name part, we should try to make many proposals
# But when the query looks like an email we should only propose serious matches
threshold = 0.6 if "@" in query else 0.1
queryset = queryset.filter(similarity__gt=threshold).order_by(
"-similarity", "email"
)
return queryset
@decorators.action(
@drf.decorators.action(
detail=False,
methods=["get"],
url_name="me",
@@ -172,47 +185,11 @@ class UserViewSet(
Return information on currently logged user
"""
context = {"request": request}
return drf_response.Response(
return drf.response.Response(
self.serializer_class(request.user, context=context).data
)
class ResourceViewsetMixin:
"""Mixin with methods common to all resource viewsets that are managed with accesses."""
filter_backends = [filters.OrderingFilter]
ordering_fields = ["created_at", "updated_at", "title"]
ordering = ["-created_at"]
def get_queryset(self):
"""Custom queryset to get user related resources."""
queryset = super().get_queryset()
user = self.request.user
if not user.is_authenticated:
return queryset
user_roles_query = (
self.access_model_class.objects.filter(
Q(user=user) | Q(team__in=user.teams),
**{self.resource_field_name: OuterRef("pk")},
)
.values(self.resource_field_name)
.annotate(roles_array=ArrayAgg("role"))
.values("roles_array")
)
return queryset.annotate(user_roles=Subquery(user_roles_query)).distinct()
def perform_create(self, serializer):
"""Set the current user as owner of the newly created object."""
obj = serializer.save()
self.access_model_class.objects.create(
user=self.request.user,
role=models.RoleChoices.OWNER,
**{self.resource_field_name: obj},
)
class ResourceAccessViewsetMixin:
"""Mixin with methods common to all access viewsets."""
@@ -243,7 +220,7 @@ class ResourceAccessViewsetMixin:
teams = user.teams
user_roles_query = (
queryset.filter(
Q(user=user) | Q(team__in=teams),
db.Q(user=user) | db.Q(team__in=teams),
**{self.resource_field_name: self.kwargs["resource_id"]},
)
.values(self.resource_field_name)
@@ -257,11 +234,13 @@ class ResourceAccessViewsetMixin:
# access instances pointing to the logged-in user)
queryset = (
queryset.filter(
Q(**{f"{self.resource_field_name}__accesses__user": user})
| Q(**{f"{self.resource_field_name}__accesses__team__in": teams}),
db.Q(**{f"{self.resource_field_name}__accesses__user": user})
| db.Q(
**{f"{self.resource_field_name}__accesses__team__in": teams}
),
**{self.resource_field_name: self.kwargs["resource_id"]},
)
.annotate(user_roles=Subquery(user_roles_query))
.annotate(user_roles=db.Subquery(user_roles_query))
.distinct()
)
return queryset
@@ -276,9 +255,9 @@ class ResourceAccessViewsetMixin:
instance.role == "owner"
and resource.accesses.filter(role="owner").count() == 1
):
return drf_response.Response(
return drf.response.Response(
{"detail": "Cannot delete the last owner access for the resource."},
status=status.HTTP_403_FORBIDDEN,
status=drf.status.HTTP_403_FORBIDDEN,
)
return super().destroy(request, *args, **kwargs)
@@ -299,12 +278,12 @@ class ResourceAccessViewsetMixin:
and resource.accesses.filter(role=models.RoleChoices.OWNER).count() == 1
):
message = "Cannot change the role to a non-owner role for the last owner access."
raise exceptions.PermissionDenied({"detail": message})
raise drf.exceptions.PermissionDenied({"detail": message})
serializer.save()
class DocumentMetadata(metadata.SimpleMetadata):
class DocumentMetadata(drf.metadata.SimpleMetadata):
"""Custom metadata class to add information"""
def determine_metadata(self, request, view):
@@ -322,35 +301,90 @@ class DocumentMetadata(metadata.SimpleMetadata):
class DocumentViewSet(
ResourceViewsetMixin,
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
drf.mixins.CreateModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.UpdateModelMixin,
drf.viewsets.GenericViewSet,
):
"""Document ViewSet"""
"""
Document ViewSet for managing documents.
Provides endpoints for creating, updating, and deleting documents,
along with filtering options.
Filtering:
- `is_creator_me=true`: Returns documents created by the current user.
- `is_creator_me=false`: Returns documents created by other users.
- `is_favorite=true`: Returns documents marked as favorite by the current user
- `is_favorite=false`: Returns documents not marked as favorite by the current user
- `title=hello`: Returns documents which title contains the "hello" string
Example Usage:
- GET /api/v1.0/documents/?is_creator_me=true&is_favorite=true
- GET /api/v1.0/documents/?is_creator_me=false&title=hello
"""
filter_backends = [drf_filters.DjangoFilterBackend, filters.OrderingFilter]
filterset_class = DocumentFilter
metadata_class = DocumentMetadata
ordering = ["-updated_at"]
ordering_fields = ["created_at", "is_favorite", "updated_at", "title"]
permission_classes = [
permissions.AccessPermission,
]
serializer_class = serializers.DocumentSerializer
access_model_class = models.DocumentAccess
resource_field_name = "document"
queryset = models.Document.objects.all()
ordering = ["-updated_at"]
metadata_class = DocumentMetadata
serializer_class = serializers.DocumentSerializer
def get_serializer_class(self):
"""
Use ListDocumentSerializer for list actions, otherwise use DocumentSerializer.
"""
if self.action == "list":
return serializers.ListDocumentSerializer
return self.serializer_class
def get_queryset(self):
"""Optimize queryset to include favorite status for the current user."""
queryset = super().get_queryset()
user = self.request.user
# Annotate the number of accesses associated with each document
queryset = queryset.annotate(nb_accesses=Count("accesses", distinct=True))
if not user.is_authenticated:
# If the user is not authenticated, annotate `is_favorite` as False
return queryset.annotate(is_favorite=Value(False))
# Annotate the queryset to indicate if the document is favorited by the current user
favorite_exists = models.DocumentFavorite.objects.filter(
document_id=OuterRef("pk"), user=user
)
queryset = queryset.annotate(is_favorite=Exists(favorite_exists))
# Annotate the queryset with the logged-in user roles
user_roles_query = (
models.DocumentAccess.objects.filter(
Q(user=user) | Q(team__in=user.teams),
document_id=OuterRef("pk"),
)
.values("document")
.annotate(roles_array=ArrayAgg("role"))
.values("roles_array")
)
return queryset.annotate(user_roles=Subquery(user_roles_query)).distinct()
def list(self, request, *args, **kwargs):
"""Restrict resources returned by the list endpoint"""
queryset = self.filter_queryset(self.get_queryset())
user = self.request.user
if user.is_authenticated:
queryset = queryset.filter(
Q(accesses__user=user)
| Q(accesses__team__in=user.teams)
db.Q(accesses__user=user)
| db.Q(accesses__team__in=user.teams)
| (
Q(link_traces__user=user)
& ~Q(link_reach=models.LinkReachChoices.RESTRICTED)
db.Q(link_traces__user=user)
& ~db.Q(link_reach=models.LinkReachChoices.RESTRICTED)
)
)
else:
@@ -362,7 +396,7 @@ class DocumentViewSet(
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return drf_response.Response(serializer.data)
return drf.response.Response(serializer.data)
def retrieve(self, request, *args, **kwargs):
"""
@@ -385,9 +419,18 @@ class DocumentViewSet(
# The trace already exists, so we just pass without doing anything
pass
return drf_response.Response(serializer.data)
return drf.response.Response(serializer.data)
@decorators.action(detail=True, methods=["get"], url_path="versions")
def perform_create(self, serializer):
"""Set the current user as creator and owner of the newly created object."""
obj = serializer.save(creator=self.request.user)
models.DocumentAccess.objects.create(
document=obj,
user=self.request.user,
role=models.RoleChoices.OWNER,
)
@drf.decorators.action(detail=True, methods=["get"], url_path="versions")
def versions_list(self, request, *args, **kwargs):
"""
Return the document's versions but only those created after the user got access
@@ -395,7 +438,7 @@ class DocumentViewSet(
"""
user = request.user
if not user.is_authenticated:
raise exceptions.PermissionDenied("Authentication required.")
raise drf.exceptions.PermissionDenied("Authentication required.")
# Validate query parameters using dedicated serializer
serializer = serializers.VersionFilterSerializer(data=request.query_params)
@@ -406,13 +449,13 @@ class DocumentViewSet(
# Users should not see version history dating from before they gained access to the
# document. Filter to get the minimum access date for the logged-in user
access_queryset = document.accesses.filter(
Q(user=user) | Q(team__in=user.teams)
).aggregate(min_date=Min("created_at"))
db.Q(user=user) | db.Q(team__in=user.teams)
).aggregate(min_date=db.Min("created_at"))
# Handle the case where the user has no accesses
min_datetime = access_queryset["min_date"]
if not min_datetime:
return exceptions.PermissionDenied(
return drf.exceptions.PermissionDenied(
"Only users with specific access can see version history"
)
@@ -422,9 +465,9 @@ class DocumentViewSet(
page_size=serializer.validated_data.get("page_size"),
)
return drf_response.Response(versions_data)
return drf.response.Response(versions_data)
@decorators.action(
@drf.decorators.action(
detail=True,
methods=["get", "delete"],
url_path="versions/(?P<version_id>[0-9a-f-]{36})",
@@ -445,7 +488,7 @@ class DocumentViewSet(
min_datetime = min(
access.created_at
for access in document.accesses.filter(
Q(user=user) | Q(team__in=user.teams),
db.Q(user=user) | db.Q(team__in=user.teams),
)
)
if response["LastModified"] < min_datetime:
@@ -453,11 +496,11 @@ class DocumentViewSet(
if request.method == "DELETE":
response = document.delete_version(version_id)
return drf_response.Response(
return drf.response.Response(
status=response["ResponseMetadata"]["HTTPStatusCode"]
)
return drf_response.Response(
return drf.response.Response(
{
"content": response["Body"].read().decode("utf-8"),
"last_modified": response["LastModified"],
@@ -465,7 +508,7 @@ class DocumentViewSet(
}
)
@decorators.action(detail=True, methods=["put"], url_path="link-configuration")
@drf.decorators.action(detail=True, methods=["put"], url_path="link-configuration")
def link_configuration(self, request, *args, **kwargs):
"""Update link configuration with specific rights (cf get_abilities)."""
# Check permissions first
@@ -478,9 +521,50 @@ class DocumentViewSet(
serializer.is_valid(raise_exception=True)
serializer.save()
return drf_response.Response(serializer.data, status=status.HTTP_200_OK)
@decorators.action(detail=True, methods=["post"], url_path="attachment-upload")
# Notify collaboration server about the link updated
CollaborationService().reset_connections(str(document.id))
return drf.response.Response(serializer.data, status=drf.status.HTTP_200_OK)
@drf.decorators.action(detail=True, methods=["post", "delete"], url_path="favorite")
def favorite(self, request, *args, **kwargs):
"""
Mark or unmark the document as a favorite for the logged-in user based on the HTTP method.
"""
# Check permissions first
document = self.get_object()
user = request.user
if request.method == "POST":
# Try to mark as favorite
try:
models.DocumentFavorite.objects.create(document=document, user=user)
except ValidationError:
return drf.response.Response(
{"detail": "Document already marked as favorite"},
status=drf.status.HTTP_200_OK,
)
return drf.response.Response(
{"detail": "Document marked as favorite"},
status=drf.status.HTTP_201_CREATED,
)
# Handle DELETE method to unmark as favorite
deleted, _ = models.DocumentFavorite.objects.filter(
document=document, user=user
).delete()
if deleted:
return drf.response.Response(
{"detail": "Document unmarked as favorite"},
status=drf.status.HTTP_204_NO_CONTENT,
)
return drf.response.Response(
{"detail": "Document was already not marked as favorite"},
status=drf.status.HTTP_200_OK,
)
@drf.decorators.action(detail=True, methods=["post"], url_path="attachment-upload")
def attachment_upload(self, request, *args, **kwargs):
"""Upload a file related to a given document"""
# Check permissions first
@@ -505,15 +589,15 @@ class DocumentViewSet(
file, default_storage.bucket_name, key, ExtraArgs=extra_args
)
return drf_response.Response(
{"file": f"{settings.MEDIA_URL:s}{key:s}"}, status=status.HTTP_201_CREATED
return drf.response.Response(
{"file": f"{settings.MEDIA_URL:s}{key:s}"},
status=drf.status.HTTP_201_CREATED,
)
@decorators.action(detail=False, methods=["get"], url_path="retrieve-auth")
def retrieve_auth(self, request, *args, **kwargs):
def _authorize_subrequest(self, request, pattern):
"""
This view is used by an Nginx subrequest to control access to a document's
attachment file.
Shared method to authorize access based on the original URL of an Nginx subrequest
and user permissions. Returns a dictionary of URL parameters if authorized.
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
See corresponding ingress configuration in Helm chart and read about the
@@ -525,33 +609,108 @@ class DocumentViewSet(
a 403 error). Note that we return 403 errors without any further details for security
reasons.
Parameters:
- pattern: The regex pattern to extract identifiers from the URL.
Returns:
- A dictionary of URL parameters if the request is authorized.
Raises:
- PermissionDenied if authorization fails.
"""
# Extract the original URL from the request header
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
if not original_url:
logger.debug("Missing HTTP_X_ORIGINAL_URL header in subrequest")
raise drf.exceptions.PermissionDenied()
parsed_url = urlparse(original_url)
match = pattern.search(parsed_url.path)
# If the path does not match the pattern, try to extract the parameters from the query
if not match:
match = pattern.search(parsed_url.query)
if not match:
logger.debug(
"Subrequest URL '%s' did not match pattern '%s'",
parsed_url.path,
pattern,
)
raise drf.exceptions.PermissionDenied()
try:
url_params = match.groupdict()
except (ValueError, AttributeError) as exc:
logger.debug("Failed to extract parameters from subrequest URL: %s", exc)
raise drf.exceptions.PermissionDenied() from exc
pk = url_params.get("pk")
if not pk:
logger.debug("Document ID (pk) not found in URL parameters: %s", url_params)
raise drf.exceptions.PermissionDenied()
# Fetch the document and check if the user has access
try:
document, _created = models.Document.objects.get_or_create(pk=pk)
except models.Document.DoesNotExist as exc:
logger.debug("Document with ID '%s' does not exist", pk)
raise drf.exceptions.PermissionDenied() from exc
user_abilities = document.get_abilities(request.user)
if not user_abilities.get(self.action, False):
logger.debug(
"User '%s' lacks permission for document '%s'", request.user, pk
)
raise drf.exceptions.PermissionDenied()
logger.debug(
"Subrequest authorization successful. Extracted parameters: %s", url_params
)
return url_params, user_abilities, request.user.id
@drf.decorators.action(detail=False, methods=["get"], url_path="media-auth")
def media_auth(self, request, *args, **kwargs):
"""
This view is used by an Nginx subrequest to control access to a document's
attachment file.
When we let the request go through, we compute authorization headers that will be added to
the request going through thanks to the nginx.ingress.kubernetes.io/auth-response-headers
annotation. The request will then be proxied to the object storage backend who will
respond with the file after checking the signature included in headers.
"""
original_url = urlparse(request.META.get("HTTP_X_ORIGINAL_URL"))
match = MEDIA_URL_PATTERN.search(original_url.path)
url_params, _, _ = self._authorize_subrequest(
request, MEDIA_STORAGE_URL_PATTERN
)
pk, key = url_params.values()
try:
pk, attachment_key = match.groups()
except AttributeError as excpt:
raise exceptions.PermissionDenied() from excpt
# Generate S3 authorization headers using the extracted URL parameters
request = utils.generate_s3_authorization_headers(f"{pk:s}/{key:s}")
# Check permission
try:
document = models.Document.objects.get(pk=pk)
except models.Document.DoesNotExist as excpt:
raise exceptions.PermissionDenied() from excpt
return drf.response.Response("authorized", headers=request.headers, status=200)
if not document.get_abilities(request.user).get("retrieve", False):
raise exceptions.PermissionDenied()
@drf.decorators.action(detail=False, methods=["get"], url_path="collaboration-auth")
def collaboration_auth(self, request, *args, **kwargs):
"""
This view is used by an Nginx subrequest to control access to a document's
collaboration server.
"""
_, user_abilities, user_id = self._authorize_subrequest(
request, COLLABORATION_WS_URL_PATTERN
)
can_edit = user_abilities["partial_update"]
# Generate authorization headers and return an authorization to proceed with the request
request = utils.generate_s3_authorization_headers(f"{pk:s}/{attachment_key:s}")
return drf_response.Response("authorized", headers=request.headers, status=200)
# Add the collaboration server secret token to the headers
headers = {
"Authorization": settings.COLLABORATION_SERVER_SECRET,
"X-Can-Edit": str(can_edit),
"X-User-Id": str(user_id),
}
@decorators.action(
return drf.response.Response("authorized", headers=headers, status=200)
@drf.decorators.action(
detail=True,
methods=["post"],
name="Apply a transformation action on a piece of text with AI",
@@ -577,9 +736,9 @@ class DocumentViewSet(
response = AIService().transform(text, action)
return drf_response.Response(response, status=status.HTTP_200_OK)
return drf.response.Response(response, status=drf.status.HTTP_200_OK)
@decorators.action(
@drf.decorators.action(
detail=True,
methods=["post"],
name="Translate a piece of text with AI",
@@ -606,17 +765,17 @@ class DocumentViewSet(
response = AIService().translate(text, language)
return drf_response.Response(response, status=status.HTTP_200_OK)
return drf.response.Response(response, status=drf.status.HTTP_200_OK)
class DocumentAccessViewSet(
ResourceAccessViewsetMixin,
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
drf.mixins.CreateModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.ListModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.UpdateModelMixin,
drf.viewsets.GenericViewSet,
):
"""
API ViewSet for all interactions with document accesses.
@@ -661,35 +820,76 @@ class DocumentAccessViewSet(
self.request.user,
)
def perform_update(self, serializer):
"""Update an access to the document and notify the collaboration server."""
access = serializer.save()
access_user_id = None
if access.user:
access_user_id = str(access.user.id)
# Notify collaboration server about the access change
CollaborationService().reset_connections(
str(access.document.id), access_user_id
)
def perform_destroy(self, instance):
"""Delete an access to the document and notify the collaboration server."""
instance.delete()
# Notify collaboration server about the access removed
CollaborationService().reset_connections(
str(instance.document.id), str(instance.user.id)
)
class TemplateViewSet(
ResourceViewsetMixin,
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
drf.mixins.CreateModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.UpdateModelMixin,
drf.viewsets.GenericViewSet,
):
"""Template ViewSet"""
filter_backends = [drf.filters.OrderingFilter]
permission_classes = [
permissions.IsAuthenticatedOrSafe,
permissions.AccessPermission,
]
ordering = ["-created_at"]
ordering_fields = ["created_at", "updated_at", "title"]
serializer_class = serializers.TemplateSerializer
access_model_class = models.TemplateAccess
resource_field_name = "template"
queryset = models.Template.objects.all()
def get_queryset(self):
"""Custom queryset to get user related templates."""
queryset = super().get_queryset()
user = self.request.user
if not user.is_authenticated:
return queryset
user_roles_query = (
models.TemplateAccess.objects.filter(
Q(user=user) | Q(team__in=user.teams),
template_id=OuterRef("pk"),
)
.values("template")
.annotate(roles_array=ArrayAgg("role"))
.values("roles_array")
)
return queryset.annotate(user_roles=Subquery(user_roles_query)).distinct()
def list(self, request, *args, **kwargs):
"""Restrict templates returned by the list endpoint"""
queryset = self.filter_queryset(self.get_queryset())
user = self.request.user
if user.is_authenticated:
queryset = queryset.filter(
Q(accesses__user=user)
| Q(accesses__team__in=user.teams)
| Q(is_public=True)
db.Q(accesses__user=user)
| db.Q(accesses__team__in=user.teams)
| db.Q(is_public=True)
)
else:
queryset = queryset.filter(is_public=True)
@@ -700,9 +900,18 @@ class TemplateViewSet(
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return drf_response.Response(serializer.data)
return drf.response.Response(serializer.data)
@decorators.action(
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,
)
@drf.decorators.action(
detail=True,
methods=["post"],
url_path="generate-document",
@@ -725,8 +934,8 @@ class TemplateViewSet(
serializer = serializers.DocumentGenerationSerializer(data=request.data)
if not serializer.is_valid():
return drf_response.Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
return drf.response.Response(
serializer.errors, status=drf.status.HTTP_400_BAD_REQUEST
)
body = serializer.validated_data["body"]
@@ -739,12 +948,12 @@ class TemplateViewSet(
class TemplateAccessViewSet(
ResourceAccessViewsetMixin,
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
drf.mixins.CreateModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.ListModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.UpdateModelMixin,
drf.viewsets.GenericViewSet,
):
"""
API ViewSet for all interactions with template accesses.
@@ -779,12 +988,12 @@ class TemplateAccessViewSet(
class InvitationViewset(
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
drf.mixins.CreateModelMixin,
drf.mixins.ListModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.UpdateModelMixin,
drf.viewsets.GenericViewSet,
):
"""API ViewSet for user invitations to document.
@@ -807,7 +1016,10 @@ class InvitationViewset(
lookup_field = "id"
pagination_class = Pagination
permission_classes = [permissions.IsAuthenticated, permissions.AccessPermission]
permission_classes = [
permissions.CanCreateInvitationPermission,
permissions.AccessPermission,
]
queryset = (
models.Invitation.objects.all()
.select_related("document")
@@ -833,7 +1045,7 @@ class InvitationViewset(
# Determine which role the logged-in user has in the document
user_roles_query = (
models.DocumentAccess.objects.filter(
Q(user=user) | Q(team__in=teams),
db.Q(user=user) | db.Q(team__in=teams),
document=self.kwargs["resource_id"],
)
.values("document")
@@ -842,14 +1054,20 @@ class InvitationViewset(
)
queryset = (
# The logged-in user should be part of a document to see its accesses
# The logged-in user should be administrator or owner to see its accesses
queryset.filter(
Q(document__accesses__user=user)
| Q(document__accesses__team__in=teams),
db.Q(
document__accesses__user=user,
document__accesses__role__in=models.PRIVILEGED_ROLES,
)
| db.Q(
document__accesses__team__in=teams,
document__accesses__role__in=models.PRIVILEGED_ROLES,
),
)
# Abilities are computed based on logged-in user's role and
# the user role on each document access
.annotate(user_roles=Subquery(user_roles_query))
.annotate(user_roles=db.Subquery(user_roles_query))
.distinct()
)
return queryset
@@ -863,3 +1081,31 @@ class InvitationViewset(
invitation.document.email_invitation(
language, invitation.email, invitation.role, self.request.user
)
class ConfigView(drf.views.APIView):
"""API ViewSet for sharing some public settings."""
permission_classes = [AllowAny]
def get(self, request):
"""
GET /api/v1.0/config/
Return a dictionary of public settings.
"""
array_settings = [
"COLLABORATION_WS_URL",
"CRISP_WEBSITE_ID",
"ENVIRONMENT",
"FRONTEND_THEME",
"MEDIA_BASE_URL",
"LANGUAGES",
"LANGUAGE_CODE",
"SENTRY_DSN",
]
dict_settings = {}
for setting in array_settings:
if hasattr(settings, setting):
dict_settings[setting] = getattr(settings, setting)
return drf.response.Response(dict_settings)

View File

@@ -56,6 +56,7 @@ class DocumentFactory(factory.django.DjangoModelFactory):
title = factory.Sequence(lambda n: f"document{n}")
content = factory.Sequence(lambda n: f"content{n}")
creator = factory.SubFactory(UserFactory)
link_reach = factory.fuzzy.FuzzyChoice(
[a[0] for a in models.LinkReachChoices.choices]
)
@@ -80,6 +81,13 @@ class DocumentFactory(factory.django.DjangoModelFactory):
for item in extracted:
models.LinkTrace.objects.create(document=self, user=item)
@factory.post_generation
def favorited_by(self, create, extracted, **kwargs):
"""Mark document as favorited by a list of users."""
if create and extracted:
for item in extracted:
models.DocumentFavorite.objects.create(document=self, user=item)
class UserDocumentAccessFactory(factory.django.DjangoModelFactory):
"""Create fake document user accesses for testing."""

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.2 on 2024-10-25 11:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0007_fix_users_duplicate'),
]
operations = [
migrations.AlterField(
model_name='document',
name='link_reach',
field=models.CharField(choices=[('restricted', 'Restricted'), ('authenticated', 'Authenticated'), ('public', 'Public')], default='restricted', max_length=20),
),
]

View File

@@ -0,0 +1,37 @@
# Generated by Django 5.1.2 on 2024-11-08 07:59
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0008_alter_document_link_reach'),
]
operations = [
migrations.AlterField(
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
migrations.CreateModel(
name='DocumentFavorite',
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')),
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorited_by_users', to='core.document')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorite_documents', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Document favorite',
'verbose_name_plural': 'Document favorites',
'db_table': 'impress_document_favorite',
'constraints': [models.UniqueConstraint(fields=('user', 'document'), name='unique_document_favorite_user', violation_error_message='This document is already targeted by a favorite relation instance for the same user.')],
},
),
]

View File

@@ -0,0 +1,31 @@
# Generated by Django 5.1.2 on 2024-11-09 11:36
import django.core.validators
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0009_add_document_favorite'),
]
operations = [
migrations.AddField(
model_name='document',
name='creator',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='documents_created', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
migrations.AlterField(
model_name='user',
name='sub',
field=models.CharField(blank=True, help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters.', regex='^[\\w.@+-:]+\\Z')], verbose_name='sub'),
),
]

View File

@@ -0,0 +1,52 @@
# Generated by Django 5.1.2 on 2024-11-09 11:48
import django.db.models.deletion
from django.conf import settings
from django.db import migrations
from django.db.models import F, ForeignKey, Subquery, OuterRef, Q
def set_creator_from_document_access(apps, schema_editor):
"""
Populate the `creator` field for existing Document records.
This function assigns the `creator` field using the existing
DocumentAccess entries. We can be sure that all documents have at
least one user with "owner" role. If the document has several roles,
it should take the entry with the oldest date of creation.
The update is performed using efficient bulk queries with Django's
Subquery and OuterRef to minimize database hits and ensure performance.
Note: After running this migration, we quickly modify the schema to make
the `creator` field required.
"""
Document = apps.get_model("core", "Document")
DocumentAccess = apps.get_model("core", "DocumentAccess")
# Update `creator` using the "owner" role
owner_subquery = DocumentAccess.objects.filter(
document=OuterRef('pk'),
user__isnull=False,
role='owner',
).order_by('created_at').values('user_id')[:1]
Document.objects.filter(
creator__isnull=True
).update(creator=Subquery(owner_subquery))
class Migration(migrations.Migration):
dependencies = [
('core', '0010_add_field_creator_to_document'),
]
operations = [
migrations.RunPython(set_creator_from_document_access, reverse_code=migrations.RunPython.noop),
migrations.AlterField(
model_name='document',
name='creator',
field=ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='documents_created', to=settings.AUTH_USER_MODEL),
),
]

View File

@@ -72,6 +72,9 @@ class RoleChoices(models.TextChoices):
OWNER = "owner", _("Owner")
PRIVILEGED_ROLES = [RoleChoices.ADMIN, RoleChoices.OWNER]
class LinkReachChoices(models.TextChoices):
"""Defines types of access for links"""
@@ -127,17 +130,17 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"""User model to work with OIDC only authentication."""
sub_validator = validators.RegexValidator(
regex=r"^[\w.@+-]+\Z",
regex=r"^[\w.@+-:]+\Z",
message=_(
"Enter a valid sub. This value may contain only letters, "
"numbers, and @/./+/-/_ characters."
"numbers, and @/./+/-/_/: characters."
),
)
sub = models.CharField(
_("sub"),
help_text=_(
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
),
max_length=255,
unique=True,
@@ -333,11 +336,14 @@ class Document(BaseModel):
link_reach = models.CharField(
max_length=20,
choices=LinkReachChoices.choices,
default=LinkReachChoices.AUTHENTICATED,
default=LinkReachChoices.RESTRICTED,
)
link_role = models.CharField(
max_length=20, choices=LinkRoleChoices.choices, default=LinkRoleChoices.READER
)
creator = models.ForeignKey(
User, on_delete=models.RESTRICT, related_name="documents_created"
)
_content = None
@@ -493,7 +499,8 @@ class Document(BaseModel):
# Compute version roles before adding link roles because we don't
# want anonymous users to access versions (we wouldn't know from
# which date to allow them anyway)
can_get_versions = bool(roles)
# Anonymous users should also not see document accesses
has_role = bool(roles)
# Add role provided by the document link
if self.link_reach == LinkReachChoices.PUBLIC or (
@@ -504,22 +511,27 @@ class Document(BaseModel):
is_owner_or_admin = bool(
roles.intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
is_editor = bool(RoleChoices.EDITOR in roles)
can_get = bool(roles)
can_update = is_owner_or_admin or RoleChoices.EDITOR in roles
return {
"ai_transform": is_owner_or_admin or is_editor,
"ai_translate": is_owner_or_admin or is_editor,
"attachment_upload": is_owner_or_admin or is_editor,
"accesses_manage": is_owner_or_admin,
"accesses_view": has_role,
"ai_transform": can_update,
"ai_translate": can_update,
"attachment_upload": can_update,
"collaboration_auth": can_get,
"destroy": RoleChoices.OWNER in roles,
"favorite": can_get and user.is_authenticated,
"link_configuration": is_owner_or_admin,
"manage_accesses": is_owner_or_admin,
"partial_update": is_owner_or_admin or is_editor,
"invite_owner": RoleChoices.OWNER in roles,
"partial_update": can_update,
"retrieve": can_get,
"update": is_owner_or_admin or is_editor,
"media_auth": can_get,
"update": can_update,
"versions_destroy": is_owner_or_admin,
"versions_list": can_get_versions,
"versions_retrieve": can_get_versions,
"versions_list": has_role,
"versions_retrieve": has_role,
}
def email_invitation(self, language, email, role, sender):
@@ -594,6 +606,37 @@ class LinkTrace(BaseModel):
return f"{self.user!s} trace on document {self.document!s}"
class DocumentFavorite(BaseModel):
"""Relation model to store a user's favorite documents."""
document = models.ForeignKey(
Document,
on_delete=models.CASCADE,
related_name="favorited_by_users",
)
user = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="favorite_documents"
)
class Meta:
db_table = "impress_document_favorite"
verbose_name = _("Document favorite")
verbose_name_plural = _("Document favorites")
constraints = [
models.UniqueConstraint(
fields=["user", "document"],
name="unique_document_favorite_user",
violation_error_message=_(
"This document is already targeted by a favorite relation instance "
"for the same user."
),
),
]
def __str__(self):
return f"{self.user!s} favorite on document {self.document!s}"
class DocumentAccess(BaseAccess):
"""Relation model to give access to a document for a user or a team with a role."""
@@ -669,15 +712,15 @@ class Template(BaseModel):
is_owner_or_admin = bool(
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
is_editor = bool(RoleChoices.EDITOR in roles)
can_get = self.is_public or bool(roles)
can_update = is_owner_or_admin or RoleChoices.EDITOR in roles
return {
"destroy": RoleChoices.OWNER in roles,
"generate_document": can_get,
"manage_accesses": is_owner_or_admin,
"update": is_owner_or_admin or is_editor,
"partial_update": is_owner_or_admin or is_editor,
"accesses_manage": is_owner_or_admin,
"update": can_update,
"partial_update": can_update,
"retrieve": can_get,
}
@@ -880,8 +923,6 @@ class Invitation(BaseModel):
def get_abilities(self, user):
"""Compute and return abilities for a given user."""
can_delete = False
can_update = False
roles = []
if user.is_authenticated:
@@ -896,17 +937,13 @@ class Invitation(BaseModel):
except (self._meta.model.DoesNotExist, IndexError):
roles = []
can_delete = bool(
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
can_update = bool(
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
is_admin_or_owner = bool(
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
return {
"destroy": can_delete,
"update": can_update,
"partial_update": can_update,
"retrieve": bool(roles),
"destroy": is_admin_or_owner,
"update": is_admin_or_owner,
"partial_update": is_admin_or_owner,
"retrieve": is_admin_or_owner,
}

View File

@@ -67,10 +67,19 @@ class AIService:
)
content = response.choices[0].message.content
sanitized_content = re.sub(r"(?<!\\)\n", "\\\\n", content)
sanitized_content = re.sub(r"(?<!\\)\t", "\\\\t", sanitized_content)
json_response = json.loads(sanitized_content)
try:
sanitized_content = re.sub(r'\s*"answer"\s*:\s*', '"answer": ', content)
sanitized_content = re.sub(r"\s*\}", "}", sanitized_content)
sanitized_content = re.sub(r"(?<!\\)\n", "\\\\n", sanitized_content)
sanitized_content = re.sub(r"(?<!\\)\t", "\\\\t", sanitized_content)
json_response = json.loads(sanitized_content)
except (json.JSONDecodeError, IndexError):
try:
json_response = json.loads(content)
except json.JSONDecodeError as err:
raise RuntimeError("AI response is not valid JSON", content) from err
if "answer" not in json_response:
raise RuntimeError("AI response does not contain an answer")

View File

@@ -0,0 +1,42 @@
"""Collaboration services."""
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
import requests
class CollaborationService:
"""Service class for Collaboration related operations."""
def __init__(self):
"""Ensure that the collaboration configuration is set properly."""
if settings.COLLABORATION_API_URL is None:
raise ImproperlyConfigured("Collaboration configuration not set")
def reset_connections(self, room, user_id=None):
"""
Reset connections of a room in the collaboration server.
Reseting a connection means that the user will be disconnected and will
have to reconnect to the collaboration server, with updated rights.
"""
endpoint = "reset-connections"
# room is necessary as a parameter, it is easier to stick to the
# same pod thanks to a parameter
endpoint_url = f"{settings.COLLABORATION_API_URL}{endpoint}/?room={room}"
headers = {"Authorization": settings.COLLABORATION_SERVER_SECRET}
if user_id:
headers["X-User-Id"] = user_id
try:
response = requests.post(endpoint_url, headers=headers, timeout=10)
except requests.RequestException as e:
raise requests.HTTPError("Failed to notify WebSocket server.") from e
if response.status_code != 200:
raise requests.HTTPError(
f"Failed to notify WebSocket server. Status code: {response.status_code}, "
f"Response: {response.text}"
)

View File

@@ -11,6 +11,9 @@ from rest_framework.test import APIClient
from core import factories, models
from core.api import serializers
from core.tests.conftest import TEAM, USER, VIA
from core.tests.test_services_collaboration_services import ( # pylint: disable=unused-import
mock_reset_connections,
)
pytestmark = pytest.mark.django_db
@@ -316,7 +319,11 @@ def test_api_document_accesses_update_authenticated_reader_or_editor(
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_update_administrator_except_owner(via, mock_user_teams):
def test_api_document_accesses_update_administrator_except_owner(
via,
mock_user_teams,
mock_reset_connections, # pylint: disable=redefined-outer-name
):
"""
A user who is a direct administrator in a document should be allowed to update a user
access for this document, as long as they don't try to set the role to owner.
@@ -351,18 +358,21 @@ def test_api_document_accesses_update_administrator_except_owner(via, mock_user_
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/documents/{document.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
if new_data["role"] == old_values["role"]:
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
assert response.status_code == 403
else:
assert response.status_code == 200
with mock_reset_connections(document.id, str(access.user_id)):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.DocumentAccessSerializer(instance=access).data
@@ -420,7 +430,11 @@ def test_api_document_accesses_update_administrator_from_owner(via, mock_user_te
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_update_administrator_to_owner(via, mock_user_teams):
def test_api_document_accesses_update_administrator_to_owner(
via,
mock_user_teams,
mock_reset_connections, # pylint: disable=redefined-outer-name
):
"""
A user who is an administrator in a document, should not be allowed to update
the user access of another user to grant document ownership.
@@ -457,16 +471,23 @@ def test_api_document_accesses_update_administrator_to_owner(via, mock_user_team
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/documents/{document.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"]:
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
assert response.status_code == 403
else:
assert response.status_code == 200
with mock_reset_connections(document.id, str(access.user_id)):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.DocumentAccessSerializer(instance=access).data
@@ -474,7 +495,11 @@ def test_api_document_accesses_update_administrator_to_owner(via, mock_user_team
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_update_owner(via, mock_user_teams):
def test_api_document_accesses_update_owner(
via,
mock_user_teams,
mock_reset_connections, # pylint: disable=redefined-outer-name
):
"""
A user who is an owner in a document should be allowed to update
a user access for this document whatever the role.
@@ -507,18 +532,24 @@ def test_api_document_accesses_update_owner(via, mock_user_teams):
for field, value in new_values.items():
new_data = {**old_values, field: value}
response = client.put(
f"/api/v1.0/documents/{document.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
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
assert response.status_code == 403
else:
assert response.status_code == 200
with mock_reset_connections(document.id, str(access.user_id)):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data=new_data,
format="json",
)
assert response.status_code == 200
access.refresh_from_db()
updated_values = serializers.DocumentAccessSerializer(instance=access).data
@@ -530,7 +561,11 @@ def test_api_document_accesses_update_owner(via, mock_user_teams):
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_update_owner_self(via, mock_user_teams):
def test_api_document_accesses_update_owner_self(
via,
mock_user_teams,
mock_reset_connections, # pylint: disable=redefined-outer-name
):
"""
A user who is owner of a document should be allowed to update
their own user access provided there are other owners in the document.
@@ -568,21 +603,23 @@ def test_api_document_accesses_update_owner_self(via, mock_user_teams):
# Add another owner and it should now work
factories.UserDocumentAccessFactory(document=document, role="owner")
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data={
**old_values,
"role": new_role,
"user_id": old_values.get("user", {}).get("id")
if old_values.get("user") is not None
else None,
},
format="json",
)
user_id = str(access.user_id) if via == USER else None
with mock_reset_connections(document.id, user_id):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data={
**old_values,
"role": new_role,
"user_id": old_values.get("user", {}).get("id")
if old_values.get("user") is not None
else None,
},
format="json",
)
assert response.status_code == 200
access.refresh_from_db()
assert access.role == new_role
assert response.status_code == 200
access.refresh_from_db()
assert access.role == new_role
# Delete
@@ -656,7 +693,9 @@ def test_api_document_accesses_delete_reader_or_editor(via, role, mock_user_team
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_delete_administrators_except_owners(
via, mock_user_teams
via,
mock_user_teams,
mock_reset_connections, # pylint: disable=redefined-outer-name
):
"""
Users who are administrators in a document should be allowed to delete an access
@@ -685,12 +724,13 @@ def test_api_document_accesses_delete_administrators_except_owners(
assert models.DocumentAccess.objects.count() == 2
assert models.DocumentAccess.objects.filter(user=access.user).exists()
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
with mock_reset_connections(document.id, str(access.user_id)):
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert models.DocumentAccess.objects.count() == 1
assert response.status_code == 204
assert models.DocumentAccess.objects.count() == 1
@pytest.mark.parametrize("via", VIA)
@@ -729,7 +769,11 @@ def test_api_document_accesses_delete_administrator_on_owners(via, mock_user_tea
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_delete_owners(via, mock_user_teams):
def test_api_document_accesses_delete_owners(
via,
mock_user_teams,
mock_reset_connections, # pylint: disable=redefined-outer-name
):
"""
Users should be able to delete the document access of another user
for a document of which they are owner.
@@ -753,12 +797,13 @@ def test_api_document_accesses_delete_owners(via, mock_user_teams):
assert models.DocumentAccess.objects.count() == 2
assert models.DocumentAccess.objects.filter(user=access.user).exists()
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
with mock_reset_connections(document.id, str(access.user_id)):
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 204
assert models.DocumentAccess.objects.count() == 1
assert response.status_code == 204
assert models.DocumentAccess.objects.count() == 1
@pytest.mark.parametrize("via", VIA)

View File

@@ -47,6 +47,7 @@ def test_api_documents_create_authenticated_success():
assert response.status_code == 201
document = Document.objects.get()
assert document.title == "my document"
assert document.link_reach == "restricted"
assert document.accesses.filter(role="owner", user=user).exists()

View File

@@ -0,0 +1,308 @@
"""Test favorite document API endpoint for users in impress's core app."""
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize(
"reach",
[
"restricted",
"authenticated",
"public",
],
)
@pytest.mark.parametrize("method", ["post", "delete"])
def test_api_document_favorite_anonymous_user(method, reach):
"""Anonymous users should not be able to mark/unmark documents as favorites."""
document = factories.DocumentFactory(link_reach=reach)
response = getattr(APIClient(), method)(
f"/api/v1.0/documents/{document.id!s}/favorite/"
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
# Verify in database
assert models.DocumentFavorite.objects.exists() is False
@pytest.mark.parametrize(
"reach, has_role",
[
["restricted", True],
["authenticated", False],
["authenticated", True],
["public", False],
["public", True],
],
)
def test_api_document_favorite_authenticated_post_allowed(reach, has_role):
"""Authenticated users should be able to mark a document as favorite using POST."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach=reach)
client = APIClient()
client.force_login(user)
if has_role:
models.DocumentAccess.objects.create(document=document, user=user)
# Mark as favorite
response = client.post(f"/api/v1.0/documents/{document.id!s}/favorite/")
assert response.status_code == 201
assert response.json() == {"detail": "Document marked as favorite"}
# Verify in database
assert models.DocumentFavorite.objects.filter(document=document, user=user).exists()
# Verify document format
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.json()["is_favorite"] is True
def test_api_document_favorite_authenticated_post_forbidden():
"""Authenticated users should be able to mark a document as favorite using POST."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach="restricted")
client = APIClient()
client.force_login(user)
# Try marking as favorite
response = client.post(f"/api/v1.0/documents/{document.id!s}/favorite/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
# Verify in database
assert (
models.DocumentFavorite.objects.filter(document=document, user=user).exists()
is False
)
@pytest.mark.parametrize(
"reach, has_role",
[
["restricted", True],
["authenticated", False],
["authenticated", True],
["public", False],
["public", True],
],
)
def test_api_document_favorite_authenticated_post_already_favorited_allowed(
reach, has_role
):
"""POST should not create duplicate favorites if already marked."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach=reach, favorited_by=[user])
client = APIClient()
client.force_login(user)
if has_role:
models.DocumentAccess.objects.create(document=document, user=user)
# Try to mark as favorite again
response = client.post(f"/api/v1.0/documents/{document.id!s}/favorite/")
assert response.status_code == 200
assert response.json() == {"detail": "Document already marked as favorite"}
# Verify in database
assert models.DocumentFavorite.objects.filter(document=document, user=user).exists()
# Verify document format
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.json()["is_favorite"] is True
def test_api_document_favorite_authenticated_post_already_favorited_forbidden():
"""POST should not create duplicate favorites if already marked."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach="restricted", favorited_by=[user])
client = APIClient()
client.force_login(user)
# Try to mark as favorite again
response = client.post(f"/api/v1.0/documents/{document.id!s}/favorite/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
# Verify in database
assert models.DocumentFavorite.objects.filter(document=document, user=user).exists()
@pytest.mark.parametrize(
"reach, has_role",
[
["restricted", True],
["authenticated", False],
["authenticated", True],
["public", False],
["public", True],
],
)
def test_api_document_favorite_authenticated_delete_allowed(reach, has_role):
"""Authenticated users should be able to unmark a document as favorite using DELETE."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach=reach, favorited_by=[user])
client = APIClient()
client.force_login(user)
if has_role:
models.DocumentAccess.objects.create(document=document, user=user)
# Unmark as favorite
response = client.delete(f"/api/v1.0/documents/{document.id!s}/favorite/")
assert response.status_code == 204
# Verify in database
assert (
models.DocumentFavorite.objects.filter(document=document, user=user).exists()
is False
)
# Verify document format
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.json()["is_favorite"] is False
def test_api_document_favorite_authenticated_delete_forbidden():
"""Authenticated users should be able to unmark a document as favorite using DELETE."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach="restricted", favorited_by=[user])
client = APIClient()
client.force_login(user)
# Unmark as favorite
response = client.delete(f"/api/v1.0/documents/{document.id!s}/favorite/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
# Verify in database
assert (
models.DocumentFavorite.objects.filter(document=document, user=user).exists()
is True
)
@pytest.mark.parametrize(
"reach, has_role",
[
["restricted", True],
["authenticated", False],
["authenticated", True],
["public", False],
["public", True],
],
)
def test_api_document_favorite_authenticated_delete_not_favorited_allowed(
reach, has_role
):
"""DELETE should be idempotent if the document is not marked as favorite."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach=reach)
client = APIClient()
client.force_login(user)
if has_role:
models.DocumentAccess.objects.create(document=document, user=user)
# Try to unmark as favorite when no favorite entry exists
response = client.delete(f"/api/v1.0/documents/{document.id!s}/favorite/")
assert response.status_code == 200
assert response.json() == {"detail": "Document was already not marked as favorite"}
# Verify in database
assert (
models.DocumentFavorite.objects.filter(document=document, user=user).exists()
is False
)
# Verify document format
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.json()["is_favorite"] is False
def test_api_document_favorite_authenticated_delete_not_favorited_forbidden():
"""DELETE should be idempotent if the document is not marked as favorite."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach="restricted")
client = APIClient()
client.force_login(user)
# Try to unmark as favorite when no favorite entry exists
response = client.delete(f"/api/v1.0/documents/{document.id!s}/favorite/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
# Verify in database
assert (
models.DocumentFavorite.objects.filter(document=document, user=user).exists()
is False
)
@pytest.mark.parametrize(
"reach, has_role",
[
["restricted", True],
["authenticated", False],
["authenticated", True],
["public", False],
["public", True],
],
)
def test_api_document_favorite_authenticated_post_unmark_then_mark_again_allowed(
reach, has_role
):
"""A user should be able to mark, unmark, and mark a document again as favorite."""
user = factories.UserFactory()
document = factories.DocumentFactory(link_reach=reach)
client = APIClient()
client.force_login(user)
if has_role:
models.DocumentAccess.objects.create(document=document, user=user)
url = f"/api/v1.0/documents/{document.id!s}/favorite/"
# Mark as favorite
response = client.post(url)
assert response.status_code == 201
# Unmark as favorite
response = client.delete(url)
assert response.status_code == 204
# Mark as favorite again
response = client.post(url)
assert response.status_code == 201
assert response.json() == {"detail": "Document marked as favorite"}
# Verify in database
assert models.DocumentFavorite.objects.filter(document=document, user=user).exists()
# Verify document format
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.json()["is_favorite"] is True

View File

@@ -6,6 +6,9 @@ from rest_framework.test import APIClient
from core import factories, models
from core.api import serializers
from core.tests.conftest import TEAM, USER, VIA
from core.tests.test_services_collaboration_services import ( # pylint: disable=unused-import
mock_reset_connections,
)
pytestmark = pytest.mark.django_db
@@ -116,7 +119,10 @@ def test_api_documents_link_configuration_update_authenticated_related_forbidden
@pytest.mark.parametrize("role", ["administrator", "owner"])
@pytest.mark.parametrize("via", VIA)
def test_api_documents_link_configuration_update_authenticated_related_success(
via, role, mock_user_teams
via,
role,
mock_user_teams,
mock_reset_connections, # pylint: disable=redefined-outer-name
):
"""
A user who is administrator or owner of a document should be allowed to update
@@ -139,14 +145,16 @@ def test_api_documents_link_configuration_update_authenticated_related_success(
new_document_values = serializers.LinkDocumentSerializer(
instance=factories.DocumentFactory()
).data
response = client.put(
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
new_document_values,
format="json",
)
assert response.status_code == 200
document = models.Document.objects.get(pk=document.pk)
document_values = serializers.LinkDocumentSerializer(instance=document).data
for key, value in document_values.items():
assert value == new_document_values[key]
with mock_reset_connections(document.id):
response = client.put(
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
new_document_values,
format="json",
)
assert response.status_code == 200
document = models.Document.objects.get(pk=document.pk)
document_values = serializers.LinkDocumentSerializer(instance=document).data
for key, value in document_values.items():
assert value == new_document_values[key]

View File

@@ -3,7 +3,9 @@ Tests for Documents API endpoint in impress's core app: list
"""
import operator
import random
from unittest import mock
from urllib.parse import urlencode
import pytest
from faker import Faker
@@ -32,7 +34,47 @@ def test_api_documents_list_anonymous(reach, role):
assert len(results) == 0
def test_api_documents_list_authenticated_direct():
def test_api_documents_list_format():
"""Validate the format of documents as returned by the list view."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
other_users = factories.UserFactory.create_batch(3)
document = factories.DocumentFactory(
users=[user, *factories.UserFactory.create_batch(2)],
favorited_by=[user, *other_users],
link_traces=other_users,
)
response = client.get("/api/v1.0/documents/")
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),
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"is_favorite": True,
"link_reach": document.link_reach,
"link_role": document.link_role,
"nb_accesses": 3,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
def test_api_documents_list_authenticated_direct(django_assert_num_queries):
"""
Authenticated users should be able to list documents they are a direct
owner/administrator/member of or documents that have a link reach other
@@ -55,9 +97,8 @@ def test_api_documents_list_authenticated_direct():
expected_ids = {str(document.id) for document in documents}
response = client.get(
"/api/v1.0/documents/",
)
with django_assert_num_queries(3):
response = client.get("/api/v1.0/documents/")
assert response.status_code == 200
results = response.json()["results"]
@@ -66,7 +107,9 @@ def test_api_documents_list_authenticated_direct():
assert expected_ids == results_id
def test_api_documents_list_authenticated_via_team(mock_user_teams):
def test_api_documents_list_authenticated_via_team(
django_assert_num_queries, mock_user_teams
):
"""
Authenticated users should be able to list documents they are a
owner/administrator/member of via a team.
@@ -89,7 +132,8 @@ def test_api_documents_list_authenticated_via_team(mock_user_teams):
expected_ids = {str(document.id) for document in documents_team1 + documents_team2}
response = client.get("/api/v1.0/documents/")
with django_assert_num_queries(3):
response = client.get("/api/v1.0/documents/")
assert response.status_code == 200
results = response.json()["results"]
@@ -98,7 +142,9 @@ def test_api_documents_list_authenticated_via_team(mock_user_teams):
assert expected_ids == results_id
def test_api_documents_list_authenticated_link_reach_restricted():
def test_api_documents_list_authenticated_link_reach_restricted(
django_assert_num_queries,
):
"""
An authenticated user who has link traces to a document that is restricted should not
see it on the list view
@@ -115,9 +161,10 @@ def test_api_documents_list_authenticated_link_reach_restricted():
other_document = factories.DocumentFactory(link_reach="public")
models.LinkTrace.objects.create(document=other_document, user=user)
response = client.get(
"/api/v1.0/documents/",
)
with django_assert_num_queries(3):
response = client.get(
"/api/v1.0/documents/",
)
assert response.status_code == 200
results = response.json()["results"]
@@ -127,7 +174,9 @@ def test_api_documents_list_authenticated_link_reach_restricted():
assert results[0]["id"] == str(other_document.id)
def test_api_documents_list_authenticated_link_reach_public_or_authenticated():
def test_api_documents_list_authenticated_link_reach_public_or_authenticated(
django_assert_num_queries,
):
"""
An authenticated user who has link traces to a document with public or authenticated
link reach should see it on the list view.
@@ -144,9 +193,10 @@ def test_api_documents_list_authenticated_link_reach_public_or_authenticated():
]
expected_ids = {str(document.id) for document in documents}
response = client.get(
"/api/v1.0/documents/",
)
with django_assert_num_queries(3):
response = client.get(
"/api/v1.0/documents/",
)
assert response.status_code == 200
results = response.json()["results"]
@@ -224,6 +274,143 @@ def test_api_documents_list_authenticated_distinct():
assert content["results"][0]["id"] == str(document.id)
def test_api_documents_list_favorites_no_extra_queries(django_assert_num_queries):
"""
Ensure that marking documents as favorite does not generate additional queries
when fetching the document list.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
special_documents = factories.DocumentFactory.create_batch(3, users=[user])
factories.DocumentFactory.create_batch(2, users=[user])
url = "/api/v1.0/documents/"
with django_assert_num_queries(3):
response = client.get(url)
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 5
assert all(result["is_favorite"] is False for result in results)
# Mark documents as favorite and check results again
for document in special_documents:
models.DocumentFavorite.objects.create(document=document, user=user)
with django_assert_num_queries(3):
response = client.get(url)
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 5
# Check if the "is_favorite" annotation is correctly set for the favorited documents
favorited_ids = {str(doc.id) for doc in special_documents}
for result in results:
if result["id"] in favorited_ids:
assert result["is_favorite"] is True
else:
assert result["is_favorite"] is False
def test_api_documents_list_filter_and_access_rights():
"""Filtering on querystring parameters should respect access rights."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
def random_favorited_by():
return random.choice([[], [user], [other_user]])
# Documents that should be listed to this user
listed_documents = [
factories.DocumentFactory(
link_reach="public",
link_traces=[user],
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
),
factories.DocumentFactory(
link_reach="authenticated",
link_traces=[user],
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
),
factories.DocumentFactory(
link_reach="restricted",
users=[user],
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
),
]
listed_ids = [str(doc.id) for doc in listed_documents]
word_list = [word for doc in listed_documents for word in doc.title.split(" ")]
# Documents that should not be listed to this user
factories.DocumentFactory(
link_reach="public",
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
)
factories.DocumentFactory(
link_reach="authenticated",
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
)
factories.DocumentFactory(
link_reach="restricted",
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
)
factories.DocumentFactory(
link_reach="restricted",
link_traces=[user],
favorited_by=random_favorited_by(),
creator=random.choice([user, other_user]),
)
filters = {
"link_reach": random.choice([None, *models.LinkReachChoices.values]),
"title": random.choice([None, *word_list]),
"favorite": random.choice([None, True, False]),
"creator": random.choice([None, user, other_user]),
"ordering": random.choice(
[
None,
"created_at",
"-created_at",
"is_favorite",
"-is_favorite",
"nb_accesses",
"-nb_accesses",
"title",
"-title",
"updated_at",
"-updated_at",
]
),
}
query_params = {key: value for key, value in filters.items() if value is not None}
querystring = urlencode(query_params)
response = client.get(f"/api/v1.0/documents/?{querystring:s}")
assert response.status_code == 200
results = response.json()["results"]
# Ensure all documents in results respect expected access rights
for result in results:
assert result["id"] in listed_ids
# Filters: ordering
def test_api_documents_list_ordering_default():
"""Documents should be ordered by descending "updated_at" by default"""
user = factories.UserFactory()
@@ -254,10 +441,14 @@ def test_api_documents_list_ordering_by_fields():
for parameter in [
"created_at",
"-created_at",
"updated_at",
"-updated_at",
"is_favorite",
"-is_favorite",
"nb_accesses",
"-nb_accesses",
"title",
"-title",
"updated_at",
"-updated_at",
]:
is_descending = parameter.startswith("-")
field = parameter.lstrip("-")
@@ -272,3 +463,212 @@ def test_api_documents_list_ordering_by_fields():
compare = operator.ge if is_descending else operator.le
for i in range(4):
assert compare(results[i][field], results[i + 1][field])
# Filters: is_creator_me
def test_api_documents_list_filter_is_creator_me_true():
"""
Authenticated users should be able to filter documents they created.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user], creator=user)
factories.DocumentFactory.create_batch(2, users=[user])
response = client.get("/api/v1.0/documents/?is_creator_me=true")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 3
# Ensure all results are created by the current user
for result in results:
assert result["creator"] == str(user.id)
def test_api_documents_list_filter_is_creator_me_false():
"""
Authenticated users should be able to filter documents created by others.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user], creator=user)
factories.DocumentFactory.create_batch(2, users=[user])
response = client.get("/api/v1.0/documents/?is_creator_me=false")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 2
# Ensure all results are created by other users
for result in results:
assert result["creator"] != str(user.id)
def test_api_documents_list_filter_is_creator_me_invalid():
"""Filtering with an invalid `is_creator_me` value should do nothing."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user], creator=user)
factories.DocumentFactory.create_batch(2, users=[user])
response = client.get("/api/v1.0/documents/?is_creator_me=invalid")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 5
# Filters: is_favorite
def test_api_documents_list_filter_is_favorite_true():
"""
Authenticated users should be able to filter documents they marked as favorite.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user], favorited_by=[user])
factories.DocumentFactory.create_batch(2, users=[user])
response = client.get("/api/v1.0/documents/?is_favorite=true")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 3
# Ensure all results are marked as favorite by the current user
for result in results:
assert result["is_favorite"] is True
def test_api_documents_list_filter_is_favorite_false():
"""
Authenticated users should be able to filter documents they didn't mark as favorite.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user], favorited_by=[user])
factories.DocumentFactory.create_batch(2, users=[user])
response = client.get("/api/v1.0/documents/?is_favorite=false")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 2
# Ensure all results are not marked as favorite by the current user
for result in results:
assert result["is_favorite"] is False
def test_api_documents_list_filter_is_favorite_invalid():
"""Filtering with an invalid `is_favorite` value should do nothing."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user], favorited_by=[user])
factories.DocumentFactory.create_batch(2, users=[user])
response = client.get("/api/v1.0/documents/?is_favorite=invalid")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 5
# Filters: link_reach
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_api_documents_list_filter_link_reach(reach):
"""Authenticated users should be able to filter documents by link reach."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(5, users=[user])
response = client.get(f"/api/v1.0/documents/?link_reach={reach:s}")
assert response.status_code == 200
results = response.json()["results"]
# Ensure all results have the chosen link reach
for result in results:
assert result["link_reach"] == reach
def test_api_documents_list_filter_link_reach_invalid():
"""Filtering with an invalid `link_reach` value should raise an error."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory.create_batch(3, users=[user])
response = client.get("/api/v1.0/documents/?link_reach=invalid")
assert response.status_code == 400
assert response.json() == {
"link_reach": [
"Select a valid choice. invalid is not one of the available choices."
]
}
# Filters: title
@pytest.mark.parametrize(
"query,nb_results",
[
("Project Alpha", 1), # Exact match
("project", 2), # Partial match (case-insensitive)
("Guide", 1), # Word match within a title
("Special", 0), # No match (nonexistent keyword)
("2024", 2), # Match by numeric keyword
("", 5), # Empty string
],
)
def test_api_documents_list_filter_title(query, nb_results):
"""Authenticated users should be able to search documents by their title."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Create documents with predefined titles
titles = [
"Project Alpha Documentation",
"Project Beta Overview",
"User Guide",
"Financial Report 2024",
"Annual Review 2024",
]
for title in titles:
factories.DocumentFactory(title=title, users=[user])
# Perform the search query
response = client.get(f"/api/v1.0/documents/?title={query:s}")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == nb_results
# Ensure all results contain the query in their title
for result in results:
assert query.lower().strip() in result["title"].lower()

View File

@@ -20,7 +20,7 @@ from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
def test_api_documents_retrieve_auth_anonymous_public():
def test_api_documents_media_auth_anonymous_public():
"""Anonymous users should be able to retrieve attachments linked to a public document"""
document = factories.DocumentFactory(link_reach="public")
@@ -36,7 +36,7 @@ def test_api_documents_retrieve_auth_anonymous_public():
original_url = f"http://localhost/media/{key:s}"
response = APIClient().get(
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
@@ -65,7 +65,7 @@ def test_api_documents_retrieve_auth_anonymous_public():
@pytest.mark.parametrize("reach", ["authenticated", "restricted"])
def test_api_documents_retrieve_auth_anonymous_authenticated_or_restricted(reach):
def test_api_documents_media_auth_anonymous_authenticated_or_restricted(reach):
"""
Anonymous users should not be allowed to retrieve attachments linked to a document
with link reach set to authenticated or restricted.
@@ -76,7 +76,7 @@ def test_api_documents_retrieve_auth_anonymous_authenticated_or_restricted(reach
media_url = f"http://localhost/media/{document.pk!s}/attachments/{filename:s}"
response = APIClient().get(
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=media_url
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
)
assert response.status_code == 403
@@ -84,7 +84,7 @@ def test_api_documents_retrieve_auth_anonymous_authenticated_or_restricted(reach
@pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_retrieve_auth_authenticated_public_or_authenticated(reach):
def test_api_documents_media_auth_authenticated_public_or_authenticated(reach):
"""
Authenticated users who are not related to a document should be able to retrieve
attachments related to a document with public or authenticated link reach.
@@ -107,7 +107,7 @@ def test_api_documents_retrieve_auth_authenticated_public_or_authenticated(reach
original_url = f"http://localhost/media/{key:s}"
response = client.get(
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
@@ -135,7 +135,7 @@ def test_api_documents_retrieve_auth_authenticated_public_or_authenticated(reach
assert response.content.decode("utf-8") == "my prose"
def test_api_documents_retrieve_auth_authenticated_restricted():
def test_api_documents_media_auth_authenticated_restricted():
"""
Authenticated users who are not related to a document should not be allowed to
retrieve attachments linked to a document that is restricted.
@@ -150,7 +150,7 @@ def test_api_documents_retrieve_auth_authenticated_restricted():
media_url = f"http://localhost/media/{document.pk!s}/attachments/{filename:s}"
response = client.get(
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=media_url
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
)
assert response.status_code == 403
@@ -158,7 +158,7 @@ def test_api_documents_retrieve_auth_authenticated_restricted():
@pytest.mark.parametrize("via", VIA)
def test_api_documents_retrieve_auth_related(via, mock_user_teams):
def test_api_documents_media_auth_related(via, mock_user_teams):
"""
Users who have a specific access to a document, whatever the role, should be able to
retrieve related attachments.
@@ -186,7 +186,7 @@ def test_api_documents_retrieve_auth_related(via, mock_user_teams):
original_url = f"http://localhost/media/{key:s}"
response = client.get(
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200

View File

@@ -21,12 +21,18 @@ def test_api_documents_retrieve_anonymous_public():
assert response.json() == {
"id": str(document.id),
"abilities": {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": document.link_role == "editor",
"ai_translate": document.link_role == "editor",
"attachment_upload": document.link_role == "editor",
"collaboration_auth": True,
"destroy": False,
# Anonymous user can't favorite a document even with read access
"favorite": False,
"invite_owner": False,
"link_configuration": False,
"manage_accesses": False,
"media_auth": True,
"partial_update": document.link_role == "editor",
"retrieve": True,
"update": document.link_role == "editor",
@@ -34,12 +40,14 @@ def test_api_documents_retrieve_anonymous_public():
"versions_list": False,
"versions_retrieve": False,
},
"accesses": [],
"link_reach": "public",
"link_role": document.link_role,
"title": document.title,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"is_favorite": False,
"link_reach": "public",
"link_role": document.link_role,
"nb_accesses": 0,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -77,12 +85,17 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
assert response.json() == {
"id": str(document.id),
"abilities": {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": document.link_role == "editor",
"ai_translate": document.link_role == "editor",
"attachment_upload": document.link_role == "editor",
"link_configuration": False,
"collaboration_auth": True,
"destroy": False,
"manage_accesses": False,
"favorite": True,
"invite_owner": False,
"media_auth": True,
"link_configuration": False,
"partial_update": document.link_role == "editor",
"retrieve": True,
"update": document.link_role == "editor",
@@ -90,12 +103,14 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"versions_list": False,
"versions_retrieve": False,
},
"accesses": [],
"link_reach": reach,
"link_role": document.link_role,
"title": document.title,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"is_favorite": False,
"link_reach": reach,
"link_role": document.link_role,
"nb_accesses": 0,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
assert (
@@ -164,43 +179,26 @@ def test_api_documents_retrieve_authenticated_related_direct():
client.force_login(user)
document = factories.DocumentFactory()
access1 = factories.UserDocumentAccessFactory(document=document, user=user)
factories.UserDocumentAccessFactory(document=document, user=user)
access2 = factories.UserDocumentAccessFactory(document=document)
access1_user = serializers.UserSerializer(instance=user).data
access2_user = serializers.UserSerializer(instance=access2.user).data
serializers.UserSerializer(instance=user)
serializers.UserSerializer(instance=access2.user)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/",
)
assert response.status_code == 200
content = response.json()
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
[
{
"id": str(access1.id),
"user": access1_user,
"team": "",
"role": access1.role,
"abilities": access1.get_abilities(user),
},
{
"id": str(access2.id),
"user": access2_user,
"team": "",
"role": access2.role,
"abilities": access2.get_abilities(user),
},
],
key=lambda x: x["id"],
)
assert response.json() == {
"id": str(document.id),
"title": document.title,
"content": document.content,
"abilities": document.get_abilities(user),
"content": document.content,
"creator": str(document.creator.id),
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"is_favorite": False,
"link_reach": document.link_reach,
"link_role": document.link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"nb_accesses": 2,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -253,7 +251,7 @@ def test_api_documents_retrieve_authenticated_related_team_members(
):
"""
Authenticated users should be allowed to retrieve a document to which they
are related via a team whatever the role and see all its accesses.
are related via a team whatever the role.
"""
mock_user_teams.return_value = teams
@@ -264,81 +262,34 @@ def test_api_documents_retrieve_authenticated_related_team_members(
document = factories.DocumentFactory(link_reach="restricted")
access_reader = factories.TeamDocumentAccessFactory(
factories.TeamDocumentAccessFactory(
document=document, team="readers", role="reader"
)
access_editor = factories.TeamDocumentAccessFactory(
factories.TeamDocumentAccessFactory(
document=document, team="editors", role="editor"
)
access_administrator = factories.TeamDocumentAccessFactory(
factories.TeamDocumentAccessFactory(
document=document, team="administrators", role="administrator"
)
access_owner = factories.TeamDocumentAccessFactory(
document=document, team="owners", role="owner"
)
other_access = factories.TeamDocumentAccessFactory(document=document)
factories.TeamDocumentAccessFactory(document=document, team="owners", role="owner")
factories.TeamDocumentAccessFactory(document=document)
factories.TeamDocumentAccessFactory()
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
# pylint: disable=R0801
assert response.status_code == 200
content = response.json()
expected_abilities = {
"destroy": False,
"retrieve": True,
"set_role_to": [],
"update": False,
"partial_update": False,
}
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
[
{
"id": str(access_reader.id),
"user": None,
"team": "readers",
"role": access_reader.role,
"abilities": expected_abilities,
},
{
"id": str(access_editor.id),
"user": None,
"team": "editors",
"role": access_editor.role,
"abilities": expected_abilities,
},
{
"id": str(access_administrator.id),
"user": None,
"team": "administrators",
"role": access_administrator.role,
"abilities": expected_abilities,
},
{
"id": str(access_owner.id),
"user": None,
"team": "owners",
"role": access_owner.role,
"abilities": expected_abilities,
},
{
"id": str(other_access.id),
"user": None,
"team": other_access.team,
"role": other_access.role,
"abilities": expected_abilities,
},
],
key=lambda x: x["id"],
)
assert response.json() == {
"id": str(document.id),
"title": document.title,
"content": document.content,
"abilities": document.get_abilities(user),
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"is_favorite": False,
"link_reach": "restricted",
"link_role": document.link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"nb_accesses": 5,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -356,7 +307,7 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
):
"""
Authenticated users should be allowed to retrieve a document to which they
are related via a team whatever the role and see all its accesses.
are related via a team whatever the role.
"""
mock_user_teams.return_value = teams
@@ -367,98 +318,34 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
document = factories.DocumentFactory(link_reach="restricted")
access_reader = factories.TeamDocumentAccessFactory(
factories.TeamDocumentAccessFactory(
document=document, team="readers", role="reader"
)
access_editor = factories.TeamDocumentAccessFactory(
factories.TeamDocumentAccessFactory(
document=document, team="editors", role="editor"
)
access_administrator = factories.TeamDocumentAccessFactory(
factories.TeamDocumentAccessFactory(
document=document, team="administrators", role="administrator"
)
access_owner = factories.TeamDocumentAccessFactory(
document=document, team="owners", role="owner"
)
other_access = factories.TeamDocumentAccessFactory(document=document)
factories.TeamDocumentAccessFactory(document=document, team="owners", role="owner")
factories.TeamDocumentAccessFactory(document=document)
factories.TeamDocumentAccessFactory()
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
# pylint: disable=R0801
assert response.status_code == 200
content = response.json()
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
[
{
"id": str(access_reader.id),
"user": None,
"team": "readers",
"role": "reader",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["administrator", "editor"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_editor.id),
"user": None,
"team": "editors",
"role": "editor",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["administrator", "reader"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_administrator.id),
"user": None,
"team": "administrators",
"role": "administrator",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["editor", "reader"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_owner.id),
"user": None,
"team": "owners",
"role": "owner",
"abilities": {
"destroy": False,
"retrieve": True,
"set_role_to": [],
"update": False,
"partial_update": False,
},
},
{
"id": str(other_access.id),
"user": None,
"team": other_access.team,
"role": other_access.role,
"abilities": other_access.get_abilities(user),
},
],
key=lambda x: x["id"],
)
assert response.json() == {
"id": str(document.id),
"title": document.title,
"content": document.content,
"abilities": document.get_abilities(user),
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"is_favorite": False,
"link_reach": "restricted",
"link_role": document.link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"nb_accesses": 5,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -477,7 +364,7 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
):
"""
Authenticated users should be allowed to retrieve a restricted document to which
they are related via a team whatever the role and see all its accesses.
they are related via a team whatever the role.
"""
mock_user_teams.return_value = teams
@@ -488,100 +375,33 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
document = factories.DocumentFactory(link_reach="restricted")
access_reader = factories.TeamDocumentAccessFactory(
factories.TeamDocumentAccessFactory(
document=document, team="readers", role="reader"
)
access_editor = factories.TeamDocumentAccessFactory(
factories.TeamDocumentAccessFactory(
document=document, team="editors", role="editor"
)
access_administrator = factories.TeamDocumentAccessFactory(
factories.TeamDocumentAccessFactory(
document=document, team="administrators", role="administrator"
)
access_owner = factories.TeamDocumentAccessFactory(
document=document, team="owners", role="owner"
)
other_access = factories.TeamDocumentAccessFactory(document=document)
factories.TeamDocumentAccessFactory(document=document, team="owners", role="owner")
factories.TeamDocumentAccessFactory(document=document)
factories.TeamDocumentAccessFactory()
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
# pylint: disable=R0801
assert response.status_code == 200
content = response.json()
assert sorted(content.pop("accesses"), key=lambda x: x["id"]) == sorted(
[
{
"id": str(access_reader.id),
"user": None,
"team": "readers",
"role": "reader",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["owner", "administrator", "editor"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_editor.id),
"user": None,
"team": "editors",
"role": "editor",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["owner", "administrator", "reader"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_administrator.id),
"user": None,
"team": "administrators",
"role": "administrator",
"abilities": {
"destroy": True,
"retrieve": True,
"set_role_to": ["owner", "editor", "reader"],
"update": True,
"partial_update": True,
},
},
{
"id": str(access_owner.id),
"user": None,
"team": "owners",
"role": "owner",
"abilities": {
# editable only if there is another owner role than the user's team...
"destroy": other_access.role == "owner",
"retrieve": True,
"set_role_to": ["administrator", "editor", "reader"]
if other_access.role == "owner"
else [],
"update": other_access.role == "owner",
"partial_update": other_access.role == "owner",
},
},
{
"id": str(other_access.id),
"user": None,
"team": other_access.team,
"role": other_access.role,
"abilities": other_access.get_abilities(user),
},
],
key=lambda x: x["id"],
)
assert response.json() == {
"id": str(document.id),
"title": document.title,
"content": document.content,
"abilities": document.get_abilities(user),
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"is_favorite": False,
"link_reach": "restricted",
"link_role": document.link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"nb_accesses": 5,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}

View File

@@ -132,7 +132,14 @@ def test_api_documents_update_anonymous_or_authenticated_unrelated(
document = models.Document.objects.get(pk=document.pk)
document_values = serializers.DocumentSerializer(instance=document).data
for key, value in document_values.items():
if key in ["id", "accesses", "created_at", "link_reach", "link_role"]:
if key in [
"id",
"accesses",
"created_at",
"creator",
"link_reach",
"link_role",
]:
assert value == old_document_values[key]
elif key == "updated_at":
assert value > old_document_values[key]
@@ -216,7 +223,14 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
document = models.Document.objects.get(pk=document.pk)
document_values = serializers.DocumentSerializer(instance=document).data
for key, value in document_values.items():
if key in ["id", "accesses", "created_at", "link_reach", "link_role"]:
if key in [
"id",
"created_at",
"creator",
"link_reach",
"link_role",
"nb_accesses",
]:
assert value == old_document_values[key]
elif key == "updated_at":
assert value > old_document_values[key]
@@ -255,7 +269,14 @@ def test_api_documents_update_authenticated_owners(via, mock_user_teams):
document = models.Document.objects.get(pk=document.pk)
document_values = serializers.DocumentSerializer(instance=document).data
for key, value in document_values.items():
if key in ["id", "accesses", "created_at", "link_reach", "link_role"]:
if key in [
"id",
"created_at",
"creator",
"link_reach",
"link_role",
"nb_accesses",
]:
assert value == old_document_values[key]
elif key == "updated_at":
assert value > old_document_values[key]

View File

@@ -22,7 +22,7 @@ def test_api_templates_retrieve_anonymous_public():
"abilities": {
"destroy": False,
"generate_document": True,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"retrieve": True,
"update": False,
@@ -68,7 +68,7 @@ def test_api_templates_retrieve_authenticated_unrelated_public():
"abilities": {
"destroy": False,
"generate_document": True,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"retrieve": True,
"update": False,

View File

@@ -0,0 +1,45 @@
"""
Test config API endpoints in the Impress core app.
"""
from django.test import override_settings
import pytest
from rest_framework.status import (
HTTP_200_OK,
)
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
@override_settings(
COLLABORATION_WS_URL="http://testcollab/",
CRISP_WEBSITE_ID="123",
FRONTEND_THEME="test-theme",
MEDIA_BASE_URL="http://testserver/",
SENTRY_DSN="https://sentry.test/123",
)
@pytest.mark.parametrize("is_authenticated", [False, True])
def test_api_config(is_authenticated):
"""Anonymous users should be allowed to get the configuration."""
client = APIClient()
if is_authenticated:
user = factories.UserFactory()
client.force_login(user)
response = client.get("/api/v1.0/config/")
assert response.status_code == HTTP_200_OK
assert response.json() == {
"COLLABORATION_WS_URL": "http://testcollab/",
"CRISP_WEBSITE_ID": "123",
"ENVIRONMENT": "test",
"FRONTEND_THEME": "test-theme",
"LANGUAGES": [["en-us", "English"], ["fr-fr", "French"], ["de-de", "German"]],
"LANGUAGE_CODE": "en-us",
"MEDIA_BASE_URL": "http://testserver/",
"SENTRY_DSN": "https://sentry.test/123",
}

View File

@@ -69,6 +69,48 @@ def test_api_users_list_query_email():
assert user_ids == [str(nicole.id), str(frank.id)]
def test_api_users_list_query_email_matching():
"""While filtering by email, results should be filtered and sorted by similarity"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
alice = factories.UserFactory(email="alice.johnson@example.gouv.fr")
factories.UserFactory(email="jane.smith@example.gouv.fr")
michael_wilson = factories.UserFactory(email="michael.wilson@example.gouv.fr")
factories.UserFactory(email="david.jones@example.gouv.fr")
michael_brown = factories.UserFactory(email="michael.brown@example.gouv.fr")
factories.UserFactory(email="sophia.taylor@example.gouv.fr")
response = client.get(
"/api/v1.0/users/?q=michael.johnson@example.gouv.f",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(michael_wilson.id)]
response = client.get("/api/v1.0/users/?q=michael.johnson@example.gouv.fr")
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(michael_wilson.id), str(alice.id), str(michael_brown.id)]
response = client.get(
"/api/v1.0/users/?q=ajohnson@example.gouv.f",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(alice.id)]
response = client.get(
"/api/v1.0/users/?q=michael.wilson@example.gouv.f",
)
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(michael_wilson.id)]
def test_api_users_list_query_email_exclude_doc_user():
"""
Authenticated users should be able to list users

View File

@@ -32,15 +32,25 @@ def test_models_documents_id_unique():
factories.DocumentFactory(id=document.id)
def test_models_documents_creator_required():
"""The "creator" field should be required."""
with pytest.raises(ValidationError) as excinfo:
models.Document.objects.create()
assert excinfo.value.message_dict["creator"] == ["This field cannot be null."]
def test_models_documents_title_null():
"""The "title" field can be null."""
document = models.Document.objects.create(title=None)
document = models.Document.objects.create(
title=None, creator=factories.UserFactory()
)
assert document.title is None
def test_models_documents_title_empty():
"""The "title" field can be empty."""
document = models.Document.objects.create(title="")
document = models.Document.objects.create(title="", creator=factories.UserFactory())
assert document.title == ""
@@ -83,12 +93,17 @@ def test_models_documents_get_abilities_forbidden(is_authenticated, reach, role)
user = factories.UserFactory() if is_authenticated else AnonymousUser()
abilities = document.get_abilities(user)
assert abilities == {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"link_configuration": False,
"collaboration_auth": False,
"destroy": False,
"manage_accesses": False,
"favorite": False,
"invite_owner": False,
"media_auth": False,
"link_configuration": False,
"partial_update": False,
"retrieve": False,
"update": False,
@@ -115,12 +130,17 @@ def test_models_documents_get_abilities_reader(is_authenticated, reach):
user = factories.UserFactory() if is_authenticated else AnonymousUser()
abilities = document.get_abilities(user)
assert abilities == {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"collaboration_auth": True,
"destroy": False,
"favorite": is_authenticated,
"invite_owner": False,
"link_configuration": False,
"manage_accesses": False,
"media_auth": True,
"partial_update": False,
"retrieve": True,
"update": False,
@@ -147,12 +167,17 @@ def test_models_documents_get_abilities_editor(is_authenticated, reach):
user = factories.UserFactory() if is_authenticated else AnonymousUser()
abilities = document.get_abilities(user)
assert abilities == {
"accesses_manage": False,
"accesses_view": False,
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"collaboration_auth": True,
"destroy": False,
"favorite": is_authenticated,
"invite_owner": False,
"link_configuration": False,
"manage_accesses": False,
"media_auth": True,
"partial_update": True,
"retrieve": True,
"update": True,
@@ -168,12 +193,17 @@ def test_models_documents_get_abilities_owner():
access = factories.UserDocumentAccessFactory(role="owner", user=user)
abilities = access.document.get_abilities(access.user)
assert abilities == {
"accesses_manage": True,
"accesses_view": True,
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"collaboration_auth": True,
"destroy": True,
"favorite": True,
"invite_owner": True,
"link_configuration": True,
"manage_accesses": True,
"media_auth": True,
"partial_update": True,
"retrieve": True,
"update": True,
@@ -188,12 +218,17 @@ def test_models_documents_get_abilities_administrator():
access = factories.UserDocumentAccessFactory(role="administrator")
abilities = access.document.get_abilities(access.user)
assert abilities == {
"accesses_manage": True,
"accesses_view": True,
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"collaboration_auth": True,
"destroy": False,
"favorite": True,
"invite_owner": False,
"link_configuration": True,
"manage_accesses": True,
"media_auth": True,
"partial_update": True,
"retrieve": True,
"update": True,
@@ -211,12 +246,17 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
abilities = access.document.get_abilities(access.user)
assert abilities == {
"accesses_manage": False,
"accesses_view": True,
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"collaboration_auth": True,
"destroy": False,
"favorite": True,
"invite_owner": False,
"link_configuration": False,
"manage_accesses": False,
"media_auth": True,
"partial_update": True,
"retrieve": True,
"update": True,
@@ -236,12 +276,17 @@ def test_models_documents_get_abilities_reader_user(django_assert_num_queries):
abilities = access.document.get_abilities(access.user)
assert abilities == {
"accesses_manage": False,
"accesses_view": True,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"collaboration_auth": True,
"destroy": False,
"favorite": True,
"invite_owner": False,
"link_configuration": False,
"manage_accesses": False,
"media_auth": True,
"partial_update": False,
"retrieve": True,
"update": False,
@@ -262,12 +307,17 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
abilities = access.document.get_abilities(access.user)
assert abilities == {
"accesses_manage": False,
"accesses_view": True,
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"collaboration_auth": True,
"destroy": False,
"favorite": True,
"invite_owner": False,
"link_configuration": False,
"manage_accesses": False,
"media_auth": True,
"partial_update": False,
"retrieve": True,
"update": False,

View File

@@ -2,10 +2,12 @@
Unit tests for the Invitation model
"""
import time
from datetime import timedelta
from unittest import mock
from django.contrib.auth.models import AnonymousUser
from django.core import exceptions
from django.utils import timezone
import pytest
from faker import Faker
@@ -60,7 +62,7 @@ def test_models_invitations_role_among_choices():
factories.InvitationFactory(role="boss")
def test_models_invitations__is_expired(settings):
def test_models_invitations_is_expired():
"""
The 'is_expired' property should return False until validity duration
is exceeded and True afterwards.
@@ -68,13 +70,16 @@ def test_models_invitations__is_expired(settings):
expired_invitation = factories.InvitationFactory()
assert expired_invitation.is_expired is False
settings.INVITATION_VALIDITY_DURATION = 1
time.sleep(1)
not_late = timezone.now() + timedelta(seconds=604799)
with mock.patch("django.utils.timezone.now", return_value=not_late):
assert expired_invitation.is_expired is False
assert expired_invitation.is_expired is True
too_late = timezone.now() + timedelta(seconds=604800) # 7 days
with mock.patch("django.utils.timezone.now", return_value=too_late):
assert expired_invitation.is_expired is True
def test_models_invitation__new_user__convert_invitations_to_accesses():
def test_models_invitationd_new_userd_convert_invitations_to_accesses():
"""
Upon creating a new user, invitations linked to the email
should be converted to accesses and then deleted.
@@ -109,7 +114,7 @@ def test_models_invitation__new_user__convert_invitations_to_accesses():
).exists() # the other invitation remains
def test_models_invitation__new_user__filter_expired_invitations():
def test_models_invitationd_new_user_filter_expired_invitations():
"""
Upon creating a new identity, valid invitations should be converted into accesses
and expired invitations should remain unchanged.
@@ -140,7 +145,7 @@ def test_models_invitation__new_user__filter_expired_invitations():
@pytest.mark.parametrize("num_invitations, num_queries", [(0, 3), (1, 6), (20, 6)])
def test_models_invitation__new_user__user_creation_constant_num_queries(
def test_models_invitationd_new_userd_user_creation_constant_num_queries(
django_assert_num_queries, num_invitations, num_queries
):
"""
@@ -235,7 +240,7 @@ def test_models_document_invitations_get_abilities_reader(via, mock_user_teams):
assert abilities == {
"destroy": False,
"retrieve": True,
"retrieve": False,
"partial_update": False,
"update": False,
}
@@ -260,7 +265,7 @@ def test_models_document_invitations_get_abilities_editor(via, mock_user_teams):
assert abilities == {
"destroy": False,
"retrieve": True,
"retrieve": False,
"partial_update": False,
"update": False,
}

View File

@@ -62,7 +62,7 @@ def test_models_templates_get_abilities_anonymous_public():
"destroy": False,
"retrieve": True,
"update": False,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": True,
}
@@ -76,7 +76,7 @@ def test_models_templates_get_abilities_anonymous_not_public():
"destroy": False,
"retrieve": False,
"update": False,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": False,
}
@@ -90,7 +90,7 @@ def test_models_templates_get_abilities_authenticated_public():
"destroy": False,
"retrieve": True,
"update": False,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": True,
}
@@ -104,7 +104,7 @@ def test_models_templates_get_abilities_authenticated_not_public():
"destroy": False,
"retrieve": False,
"update": False,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": False,
}
@@ -119,7 +119,7 @@ def test_models_templates_get_abilities_owner():
"destroy": True,
"retrieve": True,
"update": True,
"manage_accesses": True,
"accesses_manage": True,
"partial_update": True,
"generate_document": True,
}
@@ -133,7 +133,7 @@ def test_models_templates_get_abilities_administrator():
"destroy": False,
"retrieve": True,
"update": True,
"manage_accesses": True,
"accesses_manage": True,
"partial_update": True,
"generate_document": True,
}
@@ -150,7 +150,7 @@ def test_models_templates_get_abilities_editor_user(django_assert_num_queries):
"destroy": False,
"retrieve": True,
"update": True,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": True,
"generate_document": True,
}
@@ -167,7 +167,7 @@ def test_models_templates_get_abilities_reader_user(django_assert_num_queries):
"destroy": False,
"retrieve": True,
"update": False,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": True,
}
@@ -185,7 +185,7 @@ def test_models_templates_get_abilities_preset_role(django_assert_num_queries):
"destroy": False,
"retrieve": True,
"update": False,
"manage_accesses": False,
"accesses_manage": False,
"partial_update": False,
"generate_document": True,
}

View File

@@ -102,3 +102,24 @@ def test_api_ai__success_sanitize(mock_create):
response = AIService().transform("hello", "prompt")
assert response == {"answer": "Salut\n \tle \nmonde"}
@override_settings(
AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="test-model"
)
@patch("openai.resources.chat.completions.Completions.create")
def test_api_ai__success_when_sanitize_fails(mock_create):
"""The AI request should work as expected even with badly formatted response."""
# pylint: disable=C0303
answer = """{
"answer" :
"Salut le monde"
}"""
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
response = AIService().transform("hello", "prompt")
assert response == {"answer": "Salut le monde"}

View File

@@ -0,0 +1,185 @@
"""
This module contains tests for the CollaborationService class in the
core.services.collaboration_services module.
"""
import json
import re
from contextlib import contextmanager
from django.core.exceptions import ImproperlyConfigured
import pytest
import requests
import responses
from core.services.collaboration_services import CollaborationService
@pytest.fixture
def mock_reset_connections(settings):
"""
Creates a context manager to mock the reset-connections endpoint for collaboration services.
Args:
settings: A settings object that contains the configuration for the collaboration API.
Returns:
A context manager function that mocks the reset-connections endpoint.
The context manager function takes the following parameters:
document_id (str): The ID of the document for which connections are being reset.
user_id (str, optional): The ID of the user making the request. Defaults to None.
Usage:
with mock_reset_connections(settings)(document_id, user_id) as mock:
# Your test code here
The context manager performs the following actions:
- Mocks the reset-connections endpoint using responses.RequestsMock.
- Sets the COLLABORATION_API_URL and COLLABORATION_SERVER_SECRET in the settings.
- Verifies that the reset-connections endpoint is called exactly once.
- Checks that the request URL and headers are correct.
- If user_id is provided, checks that the X-User-Id header is correct.
"""
@contextmanager
def _mock_reset_connections(document_id, user_id=None):
with responses.RequestsMock() as rsps:
# Mock the reset-connections endpoint
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
endpoint_url = (
f"{settings.COLLABORATION_API_URL}reset-connections/?room={document_id}"
)
rsps.add(
responses.POST,
endpoint_url,
json={},
status=200,
)
yield
assert (
len(rsps.calls) == 1
), "Expected one call to reset-connections endpoint"
request = rsps.calls[0].request
assert request.url == endpoint_url, f"Unexpected URL called: {request.url}"
assert (
request.headers.get("Authorization")
== settings.COLLABORATION_SERVER_SECRET
), "Incorrect Authorization header"
if user_id:
assert (
request.headers.get("X-User-Id") == user_id
), "Incorrect X-User-Id header"
return _mock_reset_connections
def test_init_without_api_url(settings):
"""Test that ImproperlyConfigured is raised when COLLABORATION_API_URL is None."""
settings.COLLABORATION_API_URL = None
with pytest.raises(ImproperlyConfigured):
CollaborationService()
def test_init_with_api_url(settings):
"""Test that the service initializes correctly when COLLABORATION_API_URL is set."""
settings.COLLABORATION_API_URL = "http://example.com/"
service = CollaborationService()
assert isinstance(service, CollaborationService)
@responses.activate
def test_reset_connections_with_user_id(settings):
"""Test reset_connections with a provided user_id."""
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
service = CollaborationService()
room = "room1"
user_id = "user123"
endpoint_url = "http://example.com/reset-connections/?room=" + room
responses.add(responses.POST, endpoint_url, json={}, status=200)
service.reset_connections(room, user_id)
assert len(responses.calls) == 1
request = responses.calls[0].request
assert request.url == endpoint_url
assert request.headers.get("Authorization") == "secret-token"
assert request.headers.get("X-User-Id") == "user123"
@responses.activate
def test_reset_connections_without_user_id(settings):
"""Test reset_connections without a user_id."""
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
service = CollaborationService()
room = "room1"
user_id = None
endpoint_url = "http://example.com/reset-connections/?room=" + room
responses.add(
responses.POST,
endpoint_url,
json={},
status=200,
)
service.reset_connections(room, user_id)
assert len(responses.calls) == 1
request = responses.calls[0].request
assert request.url == endpoint_url
assert request.headers.get("Authorization") == "secret-token"
assert request.headers.get("X-User-Id") is None
@responses.activate
def test_reset_connections_non_200_response(settings):
"""Test that an HTTPError is raised when the response status is not 200."""
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
service = CollaborationService()
room = "room1"
user_id = "user123"
endpoint_url = "http://example.com/reset-connections/?room=" + room
response_body = {"error": "Internal Server Error"}
responses.add(responses.POST, endpoint_url, json=response_body, status=500)
expected_exception_message = re.escape(
"Failed to notify WebSocket server. Status code: 500, Response: "
) + re.escape(json.dumps(response_body))
with pytest.raises(requests.HTTPError, match=expected_exception_message):
service.reset_connections(room, user_id)
assert len(responses.calls) == 1
@responses.activate
def test_reset_connections_request_exception(settings):
"""Test that an HTTPError is raised when a RequestException occurs."""
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
service = CollaborationService()
room = "room1"
user_id = "user123"
endpoint_url = "http://example.com/reset-connections?room=" + room
responses.add(
responses.POST,
endpoint_url,
body=requests.exceptions.ConnectionError("Network error"),
)
with pytest.raises(requests.HTTPError, match="Failed to notify WebSocket server."):
service.reset_connections(room, user_id)
assert len(responses.calls) == 1

View File

@@ -55,4 +55,5 @@ urlpatterns = [
]
),
),
path(f"api/{settings.API_VERSION}/config/", viewsets.ConfigView.as_view()),
]

View File

@@ -132,10 +132,13 @@ def create_demo(stdout):
)
queue.flush()
users_ids = list(models.User.objects.values_list("id", flat=True))
with Timeit(stdout, "Creating documents"):
for _ in range(defaults.NB_OBJECTS["docs"]):
queue.push(
models.Document(
creator_id=random.choice(users_ids),
title=fake.sentence(nb_words=4),
link_reach=models.LinkReachChoices.AUTHENTICATED
if random_true_with_probability(0.5)
@@ -147,7 +150,6 @@ def create_demo(stdout):
with Timeit(stdout, "Creating docs accesses"):
docs_ids = list(models.Document.objects.values_list("id", flat=True))
users_ids = list(models.User.objects.values_list("id", flat=True))
for doc_id in docs_ids:
for user_id in random.sample(
users_ids,

View File

@@ -10,8 +10,9 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.1/ref/settings/
"""
import json
import os
import tomllib
from socket import gethostbyname, gethostname
from django.utils.translation import gettext_lazy as _
@@ -27,19 +28,12 @@ DATA_DIR = os.path.join("/", "data")
def get_release():
"""
Get the current release of the application
By release, we mean the release from the version.json file à la Mozilla [1]
(if any). If this file has not been found, it defaults to "NA".
[1]
https://github.com/mozilla-services/Dockerflow/blob/master/docs/version_object.md
"""
# Try to get the current release from the version.json file generated by the
# CI during the Docker image build
try:
with open(os.path.join(BASE_DIR, "version.json"), encoding="utf8") as version:
return json.load(version)["version"]
except FileNotFoundError:
with open(os.path.join(BASE_DIR, "pyproject.toml"), "rb") as f:
pyproject_data = tomllib.load(f)
return pyproject_data["project"]["version"]
except (FileNotFoundError, KeyError):
return "NA" # Default: not available
@@ -56,7 +50,7 @@ class Base(Configuration):
You may also want to override default configuration by setting the following environment
variables:
* DJANGO_SENTRY_DSN
* SENTRY_DSN
* DB_NAME
* DB_HOST
* DB_PASSWORD
@@ -104,6 +98,9 @@ class Base(Configuration):
STATIC_ROOT = os.path.join(DATA_DIR, "static")
MEDIA_URL = "/media/"
MEDIA_ROOT = os.path.join(DATA_DIR, "media")
MEDIA_BASE_URL = values.Value(
None, environ_name="MEDIA_BASE_URL", environ_prefix=None
)
SITE_ID = 1
@@ -223,6 +220,7 @@ class Base(Configuration):
# Languages
LANGUAGE_CODE = values.Value("en-us")
LANGUAGE_COOKIE_NAME = "docs_language" # cookie & language is set from frontend
DRF_NESTED_MULTIPART_PARSER = {
# output of parser is converted to querydict
@@ -236,6 +234,7 @@ class Base(Configuration):
(
("en-us", _("English")),
("fr-fr", _("French")),
("de-de", _("German")),
)
)
@@ -370,7 +369,28 @@ class Base(Configuration):
CORS_ALLOWED_ORIGIN_REGEXES = values.ListValue([])
# Sentry
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN")
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN", environ_prefix=None)
# Collaboration
COLLABORATION_API_URL = values.Value(
None, environ_name="COLLABORATION_API_URL", environ_prefix=None
)
COLLABORATION_SERVER_SECRET = values.Value(
None, environ_name="COLLABORATION_SERVER_SECRET", environ_prefix=None
)
COLLABORATION_WS_URL = values.Value(
None, environ_name="COLLABORATION_WS_URL", environ_prefix=None
)
# Frontend
FRONTEND_THEME = values.Value(
None, environ_name="FRONTEND_THEME", environ_prefix=None
)
# Crisp
CRISP_WEBSITE_ID = values.Value(
None, environ_name="CRISP_WEBSITE_ID", environ_prefix=None
)
# Easy thumbnails
THUMBNAIL_EXTENSION = "webp"
@@ -451,9 +471,22 @@ class Base(Configuration):
environ_prefix=None,
)
USER_OIDC_FIELDS_TO_FULLNAME = values.ListValue(
default=["first_name", "last_name"],
environ_name="USER_OIDC_FIELDS_TO_FULLNAME",
environ_prefix=None,
)
USER_OIDC_FIELD_TO_SHORTNAME = values.Value(
default="first_name",
environ_name="USER_OIDC_FIELD_TO_SHORTNAME",
environ_prefix=None,
)
ALLOW_LOGOUT_GET_METHOD = values.BooleanValue(
default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None
)
# AI service
AI_API_KEY = values.Value(None, environ_name="AI_API_KEY", environ_prefix=None)
AI_BASE_URL = values.Value(None, environ_name="AI_BASE_URL", environ_prefix=None)
AI_MODEL = values.Value(None, environ_name="AI_MODEL", environ_prefix=None)
@@ -469,16 +502,41 @@ class Base(Configuration):
"day": 200,
}
USER_OIDC_FIELDS_TO_FULLNAME = values.ListValue(
default=["first_name", "last_name"],
environ_name="USER_OIDC_FIELDS_TO_FULLNAME",
environ_prefix=None,
)
USER_OIDC_FIELD_TO_SHORTNAME = values.Value(
default="first_name",
environ_name="USER_OIDC_FIELD_TO_SHORTNAME",
environ_prefix=None,
)
# Logging
# We want to make it easy to log to console but by default we log production
# to Sentry and don't want to log to console.
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {
"class": "logging.StreamHandler",
"level": values.Value(
"ERROR",
environ_name="LOGGING_LEVEL_HANDLERS_CONSOLE",
environ_prefix=None,
),
},
},
# Override root logger to send it to console
"root": {
"handlers": ["console"],
"level": values.Value(
"INFO", environ_name="LOGGING_LEVEL_LOGGERS_ROOT", environ_prefix=None
),
},
"loggers": {
"core": {
"handlers": ["console"],
"level": values.Value(
"INFO",
environ_name="LOGGING_LEVEL_LOGGERS_APP",
environ_prefix=None,
),
"propagate": False,
},
},
}
# pylint: disable=invalid-name
@property
@@ -575,23 +633,6 @@ class Development(Base):
class Test(Base):
"""Test environment settings"""
LOGGING = values.DictValue(
{
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {
"class": "logging.StreamHandler",
},
},
"loggers": {
"impress": {
"handlers": ["console"],
"level": "DEBUG",
},
},
}
)
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.MD5PasswordHasher",
]
@@ -622,7 +663,13 @@ class Production(Base):
"""
# Security
ALLOWED_HOSTS = values.ListValue(None)
# Add allowed host from environment variables.
# The machine hostname is added by default,
# it makes the application pingable by a load balancer on the same machine by example
ALLOWED_HOSTS = [
*values.ListValue([], environ_name="ALLOWED_HOSTS"),
gethostbyname(gethostname()),
]
CSRF_TRUSTED_ORIGINS = values.ListValue([])
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True

Binary file not shown.

View File

@@ -0,0 +1,349 @@
msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-09-25 10:15+0000\n"
"PO-Revision-Date: 2024-09-25 10:21\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de_DE\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
"X-Crowdin-Project: lasuite-people\n"
"X-Crowdin-Project-ID: 637934\n"
"X-Crowdin-Language: de\n"
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 8\n"
#: core/admin.py:32
msgid "Personal info"
msgstr "Persönliche Angaben"
#: core/admin.py:34
msgid "Permissions"
msgstr "Berechtigungen"
#: core/admin.py:46
msgid "Important dates"
msgstr "Wichtige Termine"
#: core/api/serializers.py:253
msgid "Body"
msgstr ""
#: core/api/serializers.py:256
msgid "Body type"
msgstr ""
#: core/api/serializers.py:262
msgid "Format"
msgstr ""
#: core/authentication/backends.py:56
msgid "Invalid response format or token verification failed"
msgstr ""
#: core/authentication/backends.py:81
msgid "User info contained no recognizable user identification"
msgstr ""
#: core/authentication/backends.py:101
msgid "Claims contained no recognizable user identification"
msgstr ""
#: core/models.py:62 core/models.py:69
msgid "Reader"
msgstr "Leser"
#: core/models.py:63 core/models.py:70
msgid "Editor"
msgstr "Bearbeiter"
#: core/models.py:71
msgid "Administrator"
msgstr "Administrator"
#: core/models.py:72
msgid "Owner"
msgstr "Eigentümer"
#: core/models.py:80
msgid "Restricted"
msgstr "Eingeschränkt"
#: core/models.py:84
msgid "Authenticated"
msgstr "Authentifiziert"
#: core/models.py:86
msgid "Public"
msgstr "Öffentlich"
#: core/models.py:98
msgid "id"
msgstr ""
#: core/models.py:99
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:105
msgid "created on"
msgstr ""
#: core/models.py:106
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:111
msgid "updated on"
msgstr ""
#: core/models.py:112
msgid "date and time at which a record was last updated"
msgstr ""
#: core/models.py:132
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
msgstr ""
#: core/models.py:138
msgid "sub"
msgstr ""
#: core/models.py:140
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
#: core/models.py:148
msgid "identity email address"
msgstr ""
#: core/models.py:153
msgid "admin email address"
msgstr ""
#: core/models.py:160
msgid "language"
msgstr ""
#: core/models.py:161
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:167
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:170
msgid "device"
msgstr ""
#: core/models.py:172
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:175
msgid "staff status"
msgstr ""
#: core/models.py:177
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:180
msgid "active"
msgstr ""
#: core/models.py:183
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: core/models.py:195
msgid "user"
msgstr ""
#: core/models.py:196
msgid "users"
msgstr ""
#: core/models.py:328 core/models.py:644
msgid "title"
msgstr ""
#: core/models.py:343
msgid "Document"
msgstr ""
#: core/models.py:344
msgid "Documents"
msgstr ""
#: core/models.py:347
msgid "Untitled Document"
msgstr ""
#: core/models.py:537
#, python-format
msgid "%(username)s shared a document with you: %(document)s"
msgstr "%(username)s hat ein Dokument mit Ihnen geteilt: %(document)s"
#: core/models.py:580
msgid "Document/user link trace"
msgstr ""
#: core/models.py:581
msgid "Document/user link traces"
msgstr ""
#: core/models.py:587
msgid "A link trace already exists for this document/user."
msgstr ""
#: core/models.py:608
msgid "Document/user relation"
msgstr ""
#: core/models.py:609
msgid "Document/user relations"
msgstr ""
#: core/models.py:615
msgid "This user is already in this document."
msgstr ""
#: core/models.py:621
msgid "This team is already in this document."
msgstr ""
#: core/models.py:627 core/models.py:816
msgid "Either user or team must be set, not both."
msgstr ""
#: core/models.py:645
msgid "description"
msgstr ""
#: core/models.py:646
msgid "code"
msgstr ""
#: core/models.py:647
msgid "css"
msgstr ""
#: core/models.py:649
msgid "public"
msgstr ""
#: core/models.py:651
msgid "Whether this template is public for anyone to use."
msgstr ""
#: core/models.py:657
msgid "Template"
msgstr ""
#: core/models.py:658
msgid "Templates"
msgstr ""
#: core/models.py:797
msgid "Template/user relation"
msgstr ""
#: core/models.py:798
msgid "Template/user relations"
msgstr ""
#: core/models.py:804
msgid "This user is already in this template."
msgstr ""
#: core/models.py:810
msgid "This team is already in this template."
msgstr ""
#: core/models.py:833
msgid "email address"
msgstr ""
#: core/models.py:850
msgid "Document invitation"
msgstr ""
#: core/models.py:851
msgid "Document invitations"
msgstr ""
#: core/models.py:868
msgid "This email is already associated to a registered user."
msgstr ""
#: core/templates/mail/html/invitation.html:160
#: core/templates/mail/html/invitation2.html:160
#: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/text/invitation2.txt:3
msgid "La Suite Numérique"
msgstr ""
#: core/templates/mail/html/invitation.html:190
#: core/templates/mail/text/invitation.txt:6
#, python-format
msgid " %(username)s shared a document with you ! "
msgstr " %(username)s hat ein Dokument mit Ihnen geteilt! "
#: core/templates/mail/html/invitation.html:197
#: core/templates/mail/text/invitation.txt:8
#, python-format
msgid " %(username)s invited you as an %(role)s on the following document : "
msgstr " %(username)s hat Sie als %(role)s zum folgenden Dokument eingeladen: "
#: core/templates/mail/html/invitation.html:206
#: core/templates/mail/html/invitation2.html:211
#: core/templates/mail/text/invitation.txt:10
#: core/templates/mail/text/invitation2.txt:11
msgid "Open"
msgstr "Öffnen"
#: core/templates/mail/html/invitation.html:223
#: core/templates/mail/text/invitation.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborate on your documents as a team. "
msgstr " Docs, Ihr neues unverzichtbares Werkzeug zum Organisieren, Teilen und Zusammenarbeiten an Dokumenten im Team. "
#: core/templates/mail/html/invitation.html:230
#: core/templates/mail/html/invitation2.html:235
#: core/templates/mail/text/invitation.txt:16
#: core/templates/mail/text/invitation2.txt:17
msgid "Brought to you by La Suite Numérique"
msgstr "Bereitgestellt von La Suite Numérique"
#: core/templates/mail/html/invitation2.html:190
#, python-format
msgid "%(username)s shared a document with you"
msgstr "%(username)s hat ein Dokument mit Ihnen geteilt"
#: core/templates/mail/html/invitation2.html:197
#: core/templates/mail/text/invitation2.txt:8
#, python-format
msgid "%(username)s invited you as an %(role)s on the following document :"
msgstr "%(username)s hat Sie als %(role)s zum folgenden Dokument eingeladen:"
#: core/templates/mail/html/invitation2.html:228
#: core/templates/mail/text/invitation2.txt:15
msgid "Docs, your new essential tool for organizing, sharing and collaborate on your document as a team."
msgstr "Docs, Ihr neues unverzichtbares Werkzeug zum Organisieren, Teilen und gemeinsamen Arbeiten an Dokumenten im Team."
#: impress/settings.py:177
msgid "English"
msgstr ""
#: impress/settings.py:178
msgid "French"
msgstr ""
#: impress/settings.py:176
msgid "German"
msgstr ""

View File

@@ -345,11 +345,14 @@ msgstr ""
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: impress/settings.py:176
#: impress/settings.py:177
msgid "English"
msgstr ""
#: impress/settings.py:177
#: impress/settings.py:178
msgid "French"
msgstr ""
#: impress/settings.py:176
msgid "German"
msgstr ""

View File

@@ -345,11 +345,14 @@ msgstr "Proposé par La Suite Numérique"
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: impress/settings.py:176
#: impress/settings.py:177
msgid "English"
msgstr ""
#: impress/settings.py:177
#: impress/settings.py:178
msgid "French"
msgstr ""
#: impress/settings.py:176
msgid "German"
msgstr ""

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "1.6.0"
version = "1.9.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -17,20 +17,21 @@ classifiers = [
"License :: OSI Approved :: MIT License",
"Natural Language :: English",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.12",
]
description = "An application to print markdown to pdf from a set of managed templates."
keywords = ["Django", "Contacts", "Templates", "RBAC"]
license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
requires-python = ">=3.12"
dependencies = [
"boto3==1.35.41",
"boto3==1.35.44",
"Brotli==1.1.0",
"celery[redis]==5.4.0",
"django-configurations==2.5.1",
"django-cors-headers==4.5.0",
"django-countries==7.6.1",
"django-filter==24.3",
"django-parler==2.3",
"redis==5.1.1",
"django-redis==5.4.0",
@@ -46,14 +47,14 @@ dependencies = [
"jsonschema==4.23.0",
"markdown==3.7",
"nested-multipart-parser==1.5.0",
"openai==1.44.1",
"openai==1.55.3",
"psycopg[binary]==3.2.3",
"PyJWT==2.9.0",
"pypandoc==1.14",
"python-frontmatter==1.1.0",
"python-magic==0.4.27",
"requests==2.32.3",
"sentry-sdk==2.16.0",
"sentry-sdk==2.17.0",
"url-normalize==1.4.3",
"WeasyPrint>=60.2",
"whitenoise==6.7.0",
@@ -82,7 +83,7 @@ dev = [
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.3",
"ruff==0.6.9",
"ruff==0.7.0",
"types-requests==2.32.0.20241016",
]
@@ -127,6 +128,7 @@ select = [
[tool.ruff.lint.isort]
section-order = ["future","standard-library","django","third-party","impress","first-party","local-folder"]
sections = { impress=["core"], django=["django"] }
extra-standard-library = ["tomllib"]
[tool.ruff.lint.per-file-ignores]
"**/tests/*" = ["S", "SLF"]

View File

@@ -61,18 +61,9 @@ FROM impress AS impress-builder
WORKDIR /home/frontend/apps/impress
ARG FRONTEND_THEME
ENV NEXT_PUBLIC_THEME=${FRONTEND_THEME}
ARG Y_PROVIDER_URL
ENV NEXT_PUBLIC_Y_PROVIDER_URL=${Y_PROVIDER_URL}
ARG API_ORIGIN
ENV NEXT_PUBLIC_API_ORIGIN=${API_ORIGIN}
ARG MEDIA_URL
ENV NEXT_PUBLIC_MEDIA_URL=${MEDIA_URL}
ARG SW_DEACTIVATED
ENV NEXT_PUBLIC_SW_DEACTIVATED=${SW_DEACTIVATED}

View File

@@ -4,17 +4,17 @@ export const keyCloakSignIn = async (page: Page, browserName: string) => {
const login = `user-e2e-${browserName}`;
const password = `password-e2e-${browserName}`;
await expect(
page.locator('.login-pf-page-header').getByText('impress'),
).toBeVisible();
if (await page.getByLabel('Restart login').isVisible()) {
await page.getByRole('textbox', { name: 'password' }).fill(password);
await page.click('input[type="submit"]', { force: true });
} else {
await page.getByRole('textbox', { name: 'username' }).fill(login);
await page.getByRole('textbox', { name: 'password' }).fill(password);
await page.click('input[type="submit"]', { force: true });
await page.getByLabel('Restart login').click();
}
await page.getByRole('textbox', { name: 'username' }).fill(login);
await page.getByRole('textbox', { name: 'password' }).fill(password);
await page.click('input[type="submit"]', { force: true });
};
export const randomName = (name: string, browserName: string, length: number) =>
@@ -27,7 +27,6 @@ export const createDoc = async (
docName: string,
browserName: string,
length: number,
isPublic: boolean = false,
) => {
const randomDocs = randomName(docName, browserName, length);
@@ -44,22 +43,6 @@ export const createDoc = async (
await page.getByRole('heading', { name: 'Untitled document' }).click();
await page.keyboard.type(randomDocs[i]);
await page.getByText('Created at ').click();
if (isPublic) {
await page.getByRole('button', { name: 'Share' }).click();
await page.getByText('Doc private').click();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
force: true,
});
await expect(
page
.getByLabel('It is the card information about the document.')
.getByText('Public'),
).toBeVisible();
}
}
return randomDocs;
@@ -161,7 +144,7 @@ export const mockedDocument = async (page: Page, json: object) => {
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
accesses_manage: false, // Means not admin
update: false,
partial_update: false, // Means not editor
retrieve: true,

View File

@@ -0,0 +1,161 @@
import path from 'path';
import { expect, test } from '@playwright/test';
import { createDoc } from './common';
const config = {
CRISP_WEBSITE_ID: null,
COLLABORATION_WS_URL: 'ws://localhost:8083/collaboration/ws/',
ENVIRONMENT: 'development',
FRONTEND_THEME: 'dsfr',
MEDIA_BASE_URL: 'http://localhost:8083',
LANGUAGES: [
['en-us', 'English'],
['fr-fr', 'French'],
['de-de', 'German'],
],
LANGUAGE_CODE: 'en-us',
SENTRY_DSN: null,
};
test.describe('Config', () => {
test('it checks the config api is called', async ({ page }) => {
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes('/config/') && response.status() === 200,
);
await page.goto('/');
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
expect(await response.json()).toStrictEqual(config);
});
test('it checks that sentry is trying to init from config endpoint', async ({
page,
}) => {
await page.route('**/api/v1.0/config/', async (route) => {
const request = route.request();
if (request.method().includes('GET')) {
await route.fulfill({
json: {
...config,
SENTRY_DSN: 'https://sentry.io/123',
},
});
} else {
await route.continue();
}
});
const invalidMsg = 'Invalid Sentry Dsn: https://sentry.io/123';
const consoleMessage = page.waitForEvent('console', {
timeout: 5000,
predicate: (msg) => msg.text().includes(invalidMsg),
});
await page.goto('/');
expect((await consoleMessage).text()).toContain(invalidMsg);
});
test('it checks that theme is configured from config endpoint', async ({
page,
}) => {
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes('/config/') && response.status() === 200,
);
await page.goto('/');
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
const jsonResponse = await response.json();
expect(jsonResponse.FRONTEND_THEME).toStrictEqual('dsfr');
const footer = page.locator('footer').first();
// alt 'Gouvernement Logo' comes from the theme
await expect(footer.getByAltText('Gouvernement Logo')).toBeVisible();
});
test('it checks that media server is configured from config endpoint', async ({
page,
browserName,
}) => {
await page.goto('/');
await createDoc(page, 'doc-media', browserName, 1);
const fileChooserPromise = page.waitForEvent('filechooser');
await page.locator('.bn-block-outer').last().fill('Anything');
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Resizable image with caption').click();
await page.getByText('Upload image').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(
path.join(__dirname, 'assets/logo-suite-numerique.png'),
);
const image = page.getByRole('img', { name: 'logo-suite-numerique.png' });
await expect(image).toBeVisible();
// Check src of image
expect(await image.getAttribute('src')).toMatch(
/http:\/\/localhost:8083\/media\/.*\/attachments\/.*.png/,
);
});
test('it checks that collaboration server is configured from config endpoint', async ({
page,
browserName,
}) => {
const webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket.url().includes('ws://localhost:8083/collaboration/ws/');
});
await page.goto('/');
const randomDoc = await createDoc(
page,
'doc-collaboration',
browserName,
1,
);
await expect(page.locator('h2').getByText(randomDoc[0])).toBeVisible();
const webSocket = await webSocketPromise;
expect(webSocket.url()).toContain('ws://localhost:8083/collaboration/ws/');
});
test('it checks that Crisp is trying to init from config endpoint', async ({
page,
}) => {
await page.route('**/api/v1.0/config/', async (route) => {
const request = route.request();
if (request.method().includes('GET')) {
await route.fulfill({
json: {
...config,
CRISP_WEBSITE_ID: '1234',
},
});
} else {
await route.continue();
}
});
await page.goto('/');
await expect(
page.locator('#crisp-chatbox').getByText('Invalid website'),
).toBeVisible();
});
});

View File

@@ -9,26 +9,141 @@ test.beforeEach(async ({ page }) => {
});
test.describe('Doc Editor', () => {
test('checks the Doc is connected to the provider server', async ({
test('it check translations of the slash menu when changing language', async ({
page,
browserName,
}) => {
const webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket.url().includes('ws://localhost:4444/');
await createDoc(page, 'doc-toolbar', browserName, 1);
const header = page.locator('header').first();
const editor = page.locator('.ProseMirror');
// Trigger slash menu to show english menu
await editor.click();
await editor.fill('/');
await expect(page.getByText('Headings', { exact: true })).toBeVisible();
await header.click();
await expect(page.getByText('Headings', { exact: true })).toBeHidden();
// Reset menu
await editor.click();
await editor.fill('');
// Change language to French
await header.click();
await header.getByRole('combobox').getByText('English').click();
await header.getByRole('option', { name: 'Français' }).click();
await expect(
header.getByRole('combobox').getByText('Français'),
).toBeVisible();
// Trigger slash menu to show french menu
await editor.click();
await editor.fill('/');
await expect(page.getByText('Titres', { exact: true })).toBeVisible();
await header.click();
await expect(page.getByText('Titres', { exact: true })).toBeHidden();
});
test('it checks default toolbar buttons are displayed', async ({
page,
browserName,
}) => {
await createDoc(page, 'doc-toolbar', browserName, 1);
const editor = page.locator('.ProseMirror');
await editor.click();
await editor.fill('test content');
await editor.getByText('test content').dblclick();
const toolbar = page.locator('.bn-formatting-toolbar');
await expect(toolbar.locator('button[data-test="bold"]')).toBeVisible();
await expect(toolbar.locator('button[data-test="italic"]')).toBeVisible();
await expect(
toolbar.locator('button[data-test="underline"]'),
).toBeVisible();
await expect(toolbar.locator('button[data-test="strike"]')).toBeVisible();
await expect(
toolbar.locator('button[data-test="alignTextLeft"]'),
).toBeVisible();
await expect(
toolbar.locator('button[data-test="alignTextCenter"]'),
).toBeVisible();
await expect(
toolbar.locator('button[data-test="alignTextRight"]'),
).toBeVisible();
await expect(toolbar.locator('button[data-test="colors"]')).toBeVisible();
await expect(
toolbar.locator('button[data-test="unnestBlock"]'),
).toBeVisible();
await expect(
toolbar.locator('button[data-test="createLink"]'),
).toBeVisible();
});
/**
* We check:
* - connection to the collaborative server
* - signal of the backend to the collaborative server (connection should close)
* - reconnection to the collaborative server
*/
test('checks the connection with collaborative server', async ({
page,
browserName,
}) => {
let webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket
.url()
.includes('ws://localhost:8083/collaboration/ws/?room=');
});
const randomDoc = await createDoc(page, 'doc-editor', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc[0])).toBeVisible();
const webSocket = await webSocketPromise;
expect(webSocket.url()).toContain('ws://localhost:4444/');
let webSocket = await webSocketPromise;
expect(webSocket.url()).toContain(
'ws://localhost:8083/collaboration/ws/?room=',
);
const framesentPromise = webSocket.waitForEvent('framesent');
// Is connected
let framesentPromise = webSocket.waitForEvent('framesent');
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
const framesent = await framesentPromise;
let framesent = await framesentPromise;
expect(framesent.payload).not.toBeNull();
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByRole('combobox', {
name: 'Visibility',
});
// When the visibility is changed, the ws should closed the connection (backend signal)
const wsClosePromise = webSocket.waitForEvent('close');
await selectVisibility.click();
await page
.getByRole('option', {
name: 'Authenticated',
})
.click();
// Assert that the doc reconnects to the ws
const wsClose = await wsClosePromise;
expect(wsClose.isClosed()).toBeTruthy();
// Checkt the ws is connected again
webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket
.url()
.includes('ws://localhost:8083/collaboration/ws/?room=');
});
webSocket = await webSocketPromise;
framesentPromise = webSocket.waitForEvent('framesent');
framesent = await framesentPromise;
expect(framesent.payload).not.toBeNull();
});
@@ -59,9 +174,10 @@ test.describe('Doc Editor', () => {
test('it renders correctly when we switch from one doc to another', async ({
page,
browserName,
}) => {
// Check the first doc
const firstDoc = await goToGridDoc(page);
const [firstDoc] = await createDoc(page, 'doc-switch-1', browserName, 1);
await expect(page.locator('h2').getByText(firstDoc)).toBeVisible();
const editor = page.locator('.ProseMirror');
@@ -70,9 +186,7 @@ test.describe('Doc Editor', () => {
await expect(editor.getByText('Hello World Doc 1')).toBeVisible();
// Check the second doc
const secondDoc = await goToGridDoc(page, {
nthRow: 2,
});
const [secondDoc] = await createDoc(page, 'doc-switch-2', browserName, 1);
await expect(page.locator('h2').getByText(secondDoc)).toBeVisible();
await expect(editor.getByText('Hello World Doc 1')).toBeHidden();
await editor.click();
@@ -88,9 +202,12 @@ test.describe('Doc Editor', () => {
await expect(editor.getByText('Hello World Doc 1')).toBeVisible();
});
test('it saves the doc when we change pages', async ({ page }) => {
test('it saves the doc when we change pages', async ({
page,
browserName,
}) => {
// Check the first doc
const doc = await goToGridDoc(page);
const [doc] = await createDoc(page, 'doc-saves-change', browserName, 1);
await expect(page.locator('h2').getByText(doc)).toBeVisible();
const editor = page.locator('.ProseMirror');
@@ -141,7 +258,7 @@ test.describe('Doc Editor', () => {
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
accesses_manage: false, // Means not admin
update: false,
partial_update: false, // Means not editor
retrieve: true,

View File

@@ -303,7 +303,7 @@ test.describe('Documents Grid mobile', () => {
attachment_upload: true,
destroy: true,
link_configuration: true,
manage_accesses: true,
accesses_manage: true,
partial_update: true,
retrieve: true,
update: true,

View File

@@ -21,6 +21,7 @@ test.describe('Doc Header', () => {
role: 'owner',
user: {
email: 'super@owner.com',
full_name: 'Super Owner',
},
},
{
@@ -44,7 +45,7 @@ test.describe('Doc Header', () => {
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: true,
accesses_manage: true,
update: true,
partial_update: true,
retrieve: true,
@@ -64,9 +65,6 @@ test.describe('Doc Header', () => {
await expect(
card.getByText('Created at 09/01/2021, 11:00 AM'),
).toBeVisible();
await expect(
card.getByText('Owners: super@owner.com / super2@owner.com'),
).toBeVisible();
await expect(card.getByText('Your role: Owner')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
});
@@ -126,7 +124,9 @@ test.describe('Doc Header', () => {
.getByRole('heading', { name: 'Top World', level: 2 })
.fill(' ');
await page.getByText('Created at').click();
await page.getByText('Created at').click({
delay: 200,
});
await expect(
docHeader.getByRole('heading', { name: 'Untitled document', level: 2 }),
@@ -176,12 +176,13 @@ test.describe('Doc Header', () => {
test('it checks the options available if administrator', async ({ page }) => {
await mockedDocument(page, {
abilities: {
accesses_manage: true, // Means admin
accesses_view: true,
destroy: false, // Means not owner
link_configuration: true,
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: true, // Means admin
update: true,
partial_update: true,
retrieve: true,
@@ -211,7 +212,11 @@ test.describe('Doc Header', () => {
const shareModal = page.getByLabel('Share modal');
await expect(shareModal.getByLabel('Doc private')).toBeEnabled();
await expect(
shareModal.getByRole('combobox', {
name: 'Visibility',
}),
).not.toHaveAttribute('disabled');
await expect(shareModal.getByText('Search by email')).toBeVisible();
const invitationCard = shareModal.getByLabel('List invitation card');
@@ -242,12 +247,13 @@ test.describe('Doc Header', () => {
test('it checks the options available if editor', async ({ page }) => {
await mockedDocument(page, {
abilities: {
accesses_manage: false, // Means not admin
accesses_view: true,
destroy: false, // Means not owner
link_configuration: false,
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
update: true,
partial_update: true, // Means editor
retrieve: true,
@@ -284,7 +290,11 @@ test.describe('Doc Header', () => {
const shareModal = page.getByLabel('Share modal');
await expect(shareModal.getByLabel('Doc private')).toBeDisabled();
await expect(
shareModal.getByRole('combobox', {
name: 'Visibility',
}),
).toHaveAttribute('disabled');
await expect(shareModal.getByText('Search by email')).toBeHidden();
const invitationCard = shareModal.getByLabel('List invitation card');
@@ -315,12 +325,13 @@ test.describe('Doc Header', () => {
test('it checks the options available if reader', async ({ page }) => {
await mockedDocument(page, {
abilities: {
accesses_manage: false, // Means not admin
accesses_view: true,
destroy: false, // Means not owner
link_configuration: false,
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
update: false,
partial_update: false, // Means not editor
retrieve: true,
@@ -357,7 +368,11 @@ test.describe('Doc Header', () => {
const shareModal = page.getByLabel('Share modal');
await expect(shareModal.getByLabel('Doc private')).toBeDisabled();
await expect(
shareModal.getByRole('combobox', {
name: 'Visibility',
}),
).toHaveAttribute('disabled');
await expect(shareModal.getByText('Search by email')).toBeHidden();
const invitationCard = shareModal.getByLabel('List invitation card');
@@ -476,7 +491,7 @@ test.describe('Documents Header mobile', () => {
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: true,
accesses_manage: true,
update: true,
partial_update: true,
retrieve: true,

View File

@@ -25,6 +25,7 @@ test.describe('Document list members', () => {
user: {
id: `fc092149-cafa-4ffa-a29d-e4b18af751-${pageId}-${i}`,
email: `impress@impress.world-page-${pageId}-${i}`,
full_name: `Impress World Page ${pageId}-${i}`,
},
team: '',
role: 'editor',
@@ -58,9 +59,11 @@ test.describe('Document list members', () => {
await waitForElementCount(list.locator('li'), 21, 10000);
expect(await list.locator('li').count()).toBeGreaterThan(20);
await expect(list.getByText(`Impress World Page 1-16`)).toBeVisible();
await expect(
list.getByText(`impress@impress.world-page-1-16`),
).toBeVisible();
await expect(list.getByText(`Impress World Page 2-15`)).toBeVisible();
await expect(
list.getByText(`impress@impress.world-page-2-15`),
).toBeVisible();
@@ -164,14 +167,22 @@ test.describe('Document list members', () => {
const shareModal = page.getByLabel('Share modal');
// Admin still have the right to share
await expect(shareModal.getByLabel('Doc private')).toBeEnabled();
await expect(
shareModal.getByRole('combobox', {
name: 'Visibility',
}),
).not.toHaveAttribute('disabled');
await SelectRoleCurrentUser.click();
await page.getByRole('option', { name: 'Reader' }).click();
await expect(page.getByText('The role has been updated')).toBeVisible();
// Reader does not have the right to share
await expect(shareModal.getByLabel('Doc private')).toBeDisabled();
await expect(
shareModal.getByRole('combobox', {
name: 'Visibility',
}),
).toHaveAttribute('disabled');
});
test('it checks the delete members', async ({ page, browserName }) => {

View File

@@ -7,6 +7,22 @@ test.describe('Doc Routing', () => {
await page.goto('/');
});
test('Check the presence of the meta tag noindex', async ({ page }) => {
const buttonCreateHomepage = page.getByRole('button', {
name: 'Create a new document',
});
await expect(buttonCreateHomepage).toBeVisible();
await buttonCreateHomepage.click();
await expect(
page.getByRole('button', {
name: 'Share',
}),
).toBeVisible();
const metaDescription = page.locator('meta[name="robots"]');
await expect(metaDescription).toHaveAttribute('content', 'noindex');
});
test('checks alias docs url with homepage', async ({ page }) => {
await expect(page).toHaveURL('/');

View File

@@ -2,39 +2,13 @@ import { expect, test } from '@playwright/test';
import { createDoc, keyCloakSignIn } from './common';
const browsersName = ['chromium', 'webkit', 'firefox'];
test.describe('Doc Visibility', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('Make a public doc', async ({ page, browserName }) => {
const [docTitle] = await createDoc(
page,
'My new doc',
browserName,
1,
true,
);
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
const datagrid = page.getByLabel('Datagrid of the documents page 1');
const datagridTable = datagrid.getByRole('table');
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
timeout: 10000,
});
await expect(datagridTable.getByText(docTitle)).toBeVisible();
const row = datagridTable.getByRole('row').filter({
hasText: docTitle,
});
await expect(row.getByRole('cell').nth(0)).toHaveText('Public');
});
test('It checks the copy link button', async ({ page, browserName }) => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(
@@ -56,12 +30,48 @@ test.describe('Doc Visibility', () => {
expect(clipboardContent).toMatch(page.url());
});
test('It checks the link role options', async ({ page, browserName }) => {
await createDoc(page, 'Doc role options', browserName, 1);
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByRole('combobox', {
name: 'Visibility',
});
await expect(selectVisibility.getByText('Restricted')).toBeVisible();
await expect(page.getByLabel('Read only')).toBeHidden();
await expect(page.getByLabel('Can read and edit')).toBeHidden();
await selectVisibility.click();
await page
.getByRole('option', {
name: 'Authenticated',
})
.click();
await expect(page.getByLabel('Read only')).toBeVisible();
await expect(page.getByLabel('Can read and edit')).toBeVisible();
await selectVisibility.click();
await page
.getByRole('option', {
name: 'Public',
})
.click();
await expect(page.getByLabel('Read only')).toBeVisible();
await expect(page.getByLabel('Can read and edit')).toBeVisible();
});
});
test.describe('Doc Visibility: Not loggued', () => {
test.describe('Doc Visibility: Restricted', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('A public doc is accessible even when not authentified.', async ({
test('A doc is not accessible when not authentified.', async ({
page,
browserName,
}) => {
@@ -70,14 +80,157 @@ test.describe('Doc Visibility: Not loggued', () => {
const [docTitle] = await createDoc(
page,
'My new doc',
'Restricted no auth',
browserName,
1,
true,
);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
const urlDoc = page.url();
await page
.getByRole('button', {
name: 'Logout',
})
.click();
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible();
await page.goto(urlDoc);
await expect(page.getByRole('textbox', { name: 'password' })).toBeVisible();
});
test('A doc is not accessible when authentified but not member.', async ({
page,
browserName,
}) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docTitle] = await createDoc(page, 'Restricted auth', browserName, 1);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
const urlDoc = page.url();
await page
.getByRole('button', {
name: 'Logout',
})
.click();
const otherBrowser = browsersName.find((b) => b !== browserName);
await keyCloakSignIn(page, otherBrowser!);
await page.goto(urlDoc);
await expect(
page.getByText('The document visiblitity has been updated.'),
page.getByText('You do not have permission to perform this action.'),
).toBeVisible();
});
test('A doc is accessible when member.', async ({ page, browserName }) => {
test.slow();
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docTitle] = await createDoc(page, 'Restricted auth', browserName, 1);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByLabel(/Find a member to add to the document/);
const otherBrowser = browsersName.find((b) => b !== browserName);
const username = `user@${otherBrowser}.e2e`;
await inputSearch.fill(username);
await page.getByRole('option', { name: username }).click();
// Choose a role
await page.getByRole('combobox', { name: /Choose a role/ }).click();
await page.getByRole('option', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Validate' }).click();
await expect(
page.getByText(`User ${username} added to the document.`),
).toBeVisible();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
const urlDoc = page.url();
await page
.getByRole('button', {
name: 'Logout',
})
.click();
await keyCloakSignIn(page, otherBrowser!);
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
});
});
test.describe('Doc Visibility: Public', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('It checks a public doc in read only mode', async ({
page,
browserName,
}) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docTitle] = await createDoc(
page,
'Public read only',
browserName,
1,
);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await page
.getByRole('combobox', {
name: 'Visibility',
})
.click();
await page
.getByRole('option', {
name: 'Public',
})
.click();
await expect(
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.getByLabel('Read only').click();
await expect(
page.getByText('The document visibility has been updated.').first(),
).toBeVisible();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
await expect(
page
.getByLabel('It is the card information about the document.')
.getByText('Public', { exact: true }),
).toBeVisible();
const urlDoc = page.url();
@@ -94,19 +247,54 @@ test.describe('Doc Visibility: Not loggued', () => {
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
await expect(
page.getByText('Read only, you cannot edit this document'),
).toBeVisible();
});
test('A private doc redirect to the OIDC when not authentified.', async ({
test('It checks a public doc in editable mode', async ({
page,
browserName,
}) => {
test.slow();
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docTitle] = await createDoc(page, 'My private doc', browserName, 1);
const [docTitle] = await createDoc(page, 'Public editable', browserName, 1);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await page
.getByRole('combobox', {
name: 'Visibility',
})
.click();
await page
.getByRole('option', {
name: 'Public',
})
.click();
await expect(
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.getByLabel('Can read and edit').click();
await expect(
page.getByText('The document visibility has been updated.').first(),
).toBeVisible();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
await expect(
page
.getByLabel('It is the card information about the document.')
.getByText('Public', { exact: true }),
).toBeVisible();
const urlDoc = page.url();
@@ -116,10 +304,214 @@ test.describe('Doc Visibility: Not loggued', () => {
})
.click();
await expect(page.getByRole('textbox', { name: 'password' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible();
await page.goto(urlDoc);
await expect(page.getByRole('textbox', { name: 'password' })).toBeVisible();
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
await expect(
page.getByText('Read only, you cannot edit this document'),
).toBeHidden();
});
});
test.describe('Doc Visibility: Authenticated', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('A doc is not accessible when unauthentified.', async ({
page,
browserName,
}) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docTitle] = await createDoc(
page,
'Authenticated unauthentified',
browserName,
1,
);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await page
.getByRole('combobox', {
name: 'Visibility',
})
.click();
await page
.getByRole('option', {
name: 'Authenticated',
})
.click();
await expect(
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
const urlDoc = page.url();
await page
.getByRole('button', {
name: 'Logout',
})
.click();
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible();
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docTitle)).toBeHidden();
await expect(page.getByRole('textbox', { name: 'password' })).toBeVisible();
});
test('It checks a authenticated doc in read only mode', async ({
page,
browserName,
}) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docTitle] = await createDoc(
page,
'Authenticated read only',
browserName,
1,
);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await page
.getByRole('combobox', {
name: 'Visibility',
})
.click();
await page
.getByRole('option', {
name: 'Authenticated',
})
.click();
await expect(
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
const urlDoc = page.url();
await page
.getByRole('button', {
name: 'Logout',
})
.click();
const otherBrowser = browsersName.find((b) => b !== browserName);
await keyCloakSignIn(page, otherBrowser!);
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await expect(
page.getByText('Read only, you cannot edit this document'),
).toBeVisible();
const shareModal = page.getByLabel('Share modal');
await expect(
shareModal.getByRole('combobox', {
name: 'Visibility',
}),
).toHaveAttribute('disabled');
await expect(shareModal.getByText('Search by email')).toBeHidden();
await expect(shareModal.getByLabel('List members card')).toBeHidden();
});
test('It checks a authenticated doc in editable mode', async ({
page,
browserName,
}) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docTitle] = await createDoc(
page,
'Authenticated editable',
browserName,
1,
);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await page
.getByRole('combobox', {
name: 'Visibility',
})
.click();
await page
.getByRole('option', {
name: 'Authenticated',
})
.click();
await expect(
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
const urlDoc = page.url();
await page.getByRole('button', { name: 'Share' }).click();
await page.getByLabel('Can read and edit').click();
await expect(
page.getByText('The document visibility has been updated.').first(),
).toBeVisible();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
await page
.getByRole('button', {
name: 'Logout',
})
.click();
const otherBrowser = browsersName.find((b) => b !== browserName);
await keyCloakSignIn(page, otherBrowser!);
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await expect(
page.getByText('Read only, you cannot edit this document'),
).toBeHidden();
const shareModal = page.getByLabel('Share modal');
await expect(
shareModal.getByRole('combobox', {
name: 'Visibility',
}),
).toHaveAttribute('disabled');
await expect(shareModal.getByText('Search by email')).toBeHidden();
await expect(shareModal.getByLabel('List members card')).toBeHidden();
});
});

View File

@@ -24,5 +24,51 @@ test.describe('Language', () => {
name: 'Créer un nouveau document',
}),
).toBeVisible();
await header.getByRole('combobox').getByText('Français').click();
await header.getByRole('option', { name: 'Deutsch' }).click();
await expect(
header.getByRole('combobox').getByText('Deutsch'),
).toBeVisible();
await expect(
page.getByRole('button', {
name: 'Neues Dokument erstellen',
}),
).toBeVisible();
});
test('checks that backend uses the same language as the frontend', async ({
page,
}) => {
// Helper function to intercept and assert 404 response
const check404Response = async (expectedDetail: string) => {
const expectedBackendResponse = page.waitForResponse(
(response) =>
response.url().includes('/api') &&
response.url().includes('non-existent-doc-uuid') &&
response.status() === 404,
);
// Trigger the specific 404 XHR response by navigating to a non-existent document
await page.goto('/docs/non-existent-doc-uuid');
// Assert that the intercepted error message is in the expected language
const interceptedBackendResponse = await expectedBackendResponse;
expect(await interceptedBackendResponse.json()).toStrictEqual({
detail: expectedDetail,
});
};
// Check for English 404 response
await check404Response('Not found.');
// Switch language to French
const header = page.locator('header').first();
await header.getByRole('combobox').getByText('English').click();
await header.getByRole('option', { name: 'Français' }).click();
// Check for French 404 response
await check404Response('Pas trouvé.');
});
});

View File

@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "1.6.0",
"version": "1.9.0",
"private": true,
"scripts": {
"lint": "eslint . --ext .ts",
@@ -12,7 +12,7 @@
"test:ui::chromium": "yarn test:ui --project=chromium"
},
"devDependencies": {
"@playwright/test": "1.48.1",
"@playwright/test": "1.49.0",
"@types/node": "*",
"@types/pdf-parse": "1.1.4",
"eslint-config-impress": "*",

View File

@@ -1,5 +1,2 @@
NEXT_PUBLIC_API_ORIGIN=
NEXT_PUBLIC_Y_PROVIDER_URL=
NEXT_PUBLIC_MEDIA_URL=
NEXT_PUBLIC_THEME=dsfr
NEXT_PUBLIC_SW_DEACTIVATED=

View File

@@ -1,4 +1,2 @@
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
NEXT_PUBLIC_Y_PROVIDER_URL=ws://localhost:4444
NEXT_PUBLIC_MEDIA_URL=http://localhost:8083
NEXT_PUBLIC_SW_DEACTIVATED=true

View File

@@ -1,2 +1 @@
NEXT_PUBLIC_API_ORIGIN=http://test.jest
NEXT_PUBLIC_THEME=test-theme

View File

@@ -358,6 +358,8 @@ const config = {
},
'forms-field': {
color: 'var(--c--theme--colors--primary-text)',
'footer-font-size': 'var(--c--theme--font--sizes--t)',
'footer-color': 'var(--c--theme--colors--greyscale-text)',
},
'forms-input': {
'border-radius': '4px',
@@ -372,6 +374,9 @@ const config = {
big: 'var(--c--theme--colors--primary-text)',
},
},
'forms-radio': {
'accent-color': 'var(--c--theme--colors--primary-600)',
},
'forms-select': {
'item-font-size': '14px',
'border-radius': '4px',

View File

@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "1.6.0",
"version": "1.9.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -19,50 +19,53 @@
"@blocknote/mantine": "*",
"@blocknote/react": "*",
"@gouvfr-lasuite/integration": "1.0.2",
"@hocuspocus/provider": "2.13.7",
"@hocuspocus/provider": "2.14.0",
"@openfun/cunningham-react": "2.9.4",
"@tanstack/react-query": "5.59.15",
"i18next": "23.16.0",
"@sentry/nextjs": "8.42.0",
"@tanstack/react-query": "5.62.2",
"crisp-sdk-web": "1.0.25",
"i18next": "24.0.5",
"i18next-browser-languagedetector": "8.0.0",
"idb": "8.0.0",
"lodash": "4.17.21",
"luxon": "3.5.0",
"next": "14.2.15",
"next": "15.0.3",
"react": "*",
"react-aria-components": "1.4.1",
"react-aria-components": "1.5.0",
"react-dom": "*",
"react-i18next": "15.0.3",
"react-select": "5.8.1",
"react-i18next": "15.1.3",
"react-select": "5.8.3",
"styled-components": "6.1.13",
"y-protocols": "1.0.6",
"yjs": "*",
"zustand": "5.0.0"
"zustand": "5.0.1"
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.59.15",
"@tanstack/react-query-devtools": "5.62.2",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.6.1",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.0.1",
"@testing-library/user-event": "14.5.2",
"@types/jest": "29.5.13",
"@types/lodash": "4.17.10",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.13",
"@types/luxon": "3.4.2",
"@types/node": "*",
"@types/react": "18.3.11",
"@types/react": "18.3.12",
"@types/react-dom": "*",
"cross-env": "*",
"dotenv": "16.4.5",
"dotenv": "16.4.7",
"eslint-config-impress": "*",
"fetch-mock": "9.11.0",
"jest": "29.7.0",
"jest-environment-jsdom": "29.7.0",
"node-fetch": "2.7.0",
"prettier": "3.3.3",
"stylelint": "16.10.0",
"prettier": "3.4.1",
"stylelint": "16.11.0",
"stylelint-config-standard": "36.0.1",
"stylelint-prettier": "5.0.2",
"typescript": "*",
"webpack": "5.95.0",
"webpack": "5.97.0",
"workbox-webpack-plugin": "7.1.0"
}
}

View File

@@ -5,7 +5,7 @@ import { AppWrapper } from '@/tests/utils';
import Page from '../pages';
jest.mock('next/navigation', () => ({
jest.mock('next/router', () => ({
useRouter() {
return {
push: jest.fn(),
@@ -13,6 +13,12 @@ jest.mock('next/navigation', () => ({
},
}));
jest.mock('@sentry/nextjs', () => ({
captureException: jest.fn(),
captureMessage: jest.fn(),
setUser: jest.fn(),
}));
describe('Page', () => {
it('checks Page rendering', () => {
render(<Page />, { wrapper: AppWrapper });

View File

@@ -0,0 +1,6 @@
export const backendUrl = () =>
process.env.NEXT_PUBLIC_API_ORIGIN ||
(typeof window !== 'undefined' ? window.location.origin : '');
export const baseApiUrl = (apiVersion: string = '1.0') =>
`${backendUrl()}/api/v${apiVersion}/`;

View File

@@ -1,5 +1,4 @@
import { baseApiUrl } from '@/core';
import { baseApiUrl } from './config';
import { getCSRFToken } from './utils';
interface FetchAPIInit extends RequestInit {

View File

@@ -1,4 +1,5 @@
export * from './APIError';
export * from './config';
export * from './fetchApi';
export * from './helpers';
export * from './types';

View File

@@ -1,6 +1,6 @@
import { ComponentPropsWithRef, ReactHTML } from 'react';
import styled from 'styled-components';
import { CSSProperties } from 'styled-components/dist/types';
import { CSSProperties, RuleSet } from 'styled-components/dist/types';
import {
MarginPadding,
@@ -15,11 +15,11 @@ export interface BoxProps {
$align?: CSSProperties['alignItems'];
$background?: CSSProperties['background'];
$color?: CSSProperties['color'];
$css?: string;
$css?: string | RuleSet<object>;
$direction?: CSSProperties['flexDirection'];
$display?: CSSProperties['display'];
$effect?: 'show' | 'hide';
$flex?: boolean;
$flex?: CSSProperties['flex'];
$gap?: CSSProperties['gap'];
$hasTransition?: boolean | 'slow';
$height?: CSSProperties['height'];
@@ -50,7 +50,7 @@ export const Box = styled('div')<BoxProps>`
${({ $color }) => $color && `color: ${$color};`}
${({ $direction }) => $direction && `flex-direction: ${$direction};`}
${({ $display }) => $display && `display: ${$display};`}
${({ $flex }) => $flex === false && `display: block;`}
${({ $flex }) => $flex && `flex: ${$flex};`}
${({ $gap }) => $gap && `gap: ${$gap};`}
${({ $height }) => $height && `height: ${$height};`}
${({ $hasTransition }) =>
@@ -73,7 +73,7 @@ export const Box = styled('div')<BoxProps>`
${({ $transition }) => $transition && `transition: ${$transition};`}
${({ $width }) => $width && `width: ${$width};`}
${({ $wrap }) => $wrap && `flex-wrap: ${$wrap};`}
${({ $css }) => $css && `${$css};`}
${({ $css }) => $css && (typeof $css === 'string' ? `${$css};` : $css)}
${({ $zIndex }) => $zIndex && `z-index: ${$zIndex};`}
${({ $effect }) => {
let effect;

View File

@@ -1,4 +1,5 @@
import { ComponentPropsWithRef, forwardRef } from 'react';
import { css } from 'styled-components';
import { Box, BoxType } from './Box';
@@ -26,7 +27,7 @@ const BoxButton = forwardRef<HTMLDivElement, BoxType>(
$background="none"
$margin="none"
$padding="none"
$css={`
$css={css`
cursor: pointer;
border: none;
outline: none;

View File

@@ -1,4 +1,5 @@
import { PropsWithChildren } from 'react';
import { css } from 'styled-components';
import { useCunninghamTheme } from '@/cunningham';
@@ -15,7 +16,7 @@ export const Card = ({
<Box
$background="white"
$radius="4px"
$css={`
$css={css`
box-shadow: 2px 2px 5px ${colorsTokens()['greyscale-300']};
border: 1px solid ${colorsTokens()['card-border']};
${$css}

View File

@@ -1,4 +1,9 @@
import { CSSProperties, ComponentPropsWithRef, ReactHTML } from 'react';
import {
CSSProperties,
ComponentPropsWithRef,
ReactHTML,
forwardRef,
} from 'react';
import styled from 'styled-components';
import { tokens } from '@/cunningham';
@@ -55,18 +60,21 @@ export const TextStyled = styled(Box)<TextProps>`
`white-space: nowrap; overflow: hidden; text-overflow: ellipsis;`}
`;
export const Text = ({
className,
$isMaterialIcon,
...props
}: ComponentPropsWithRef<typeof TextStyled>) => {
return (
<TextStyled
as="span"
$theme="greyscale"
$variation="text"
className={`${className || ''}${$isMaterialIcon ? ' material-icons' : ''}`}
{...props}
/>
);
};
const Text = forwardRef<HTMLElement, ComponentPropsWithRef<typeof TextStyled>>(
({ className, $isMaterialIcon, ...props }, ref) => {
return (
<TextStyled
ref={ref}
as="span"
$theme="greyscale"
$variation="text"
className={`${className || ''}${$isMaterialIcon ? ' material-icons' : ''}`}
{...props}
/>
);
},
);
Text.displayName = 'Text';
export { Text };

View File

@@ -7,6 +7,7 @@ import '@/i18n/initI18n';
import { useResponsiveStore } from '@/stores/';
import { Auth } from './auth/';
import { ConfigProvider } from './config/';
/**
* QueryClient:
@@ -39,7 +40,9 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<CunninghamProvider theme={theme}>
<Auth>{children}</Auth>
<ConfigProvider>
<Auth>{children}</Auth>
</ConfigProvider>
</CunninghamProvider>
</QueryClientProvider>
);

View File

@@ -0,0 +1,40 @@
import { Crisp } from 'crisp-sdk-web';
import fetchMock from 'fetch-mock';
import { useAuthStore } from '../useAuthStore';
jest.mock('crisp-sdk-web', () => ({
...jest.requireActual('crisp-sdk-web'),
Crisp: {
isCrispInjected: jest.fn().mockReturnValue(true),
setTokenId: jest.fn(),
user: {
setEmail: jest.fn(),
},
session: {
reset: jest.fn(),
},
},
}));
describe('useAuthStore', () => {
afterEach(() => {
jest.clearAllMocks();
fetchMock.restore();
});
it('checks support session is terminated when logout', () => {
window.$crisp = true;
Object.defineProperty(window, 'location', {
value: {
...window.location,
replace: jest.fn(),
},
writable: true,
});
useAuthStore.getState().logout();
expect(Crisp.session.reset).toHaveBeenCalled();
});
});

View File

@@ -8,4 +8,6 @@
export interface User {
id: string;
email: string;
full_name: string;
short_name: string;
}

View File

@@ -1,6 +1,7 @@
import { create } from 'zustand';
import { baseApiUrl } from '@/core/conf';
import { baseApiUrl } from '@/api';
import { terminateCrispSession } from '@/services';
import { User, getMe } from './api';
import { PATH_AUTH_LOCAL_STORAGE } from './conf';
@@ -42,6 +43,7 @@ export const useAuthStore = create<AuthStore>((set, get) => ({
window.location.replace(`${baseApiUrl()}authenticate/`);
},
logout: () => {
terminateCrispSession();
window.location.replace(`${baseApiUrl()}logout/`);
},
// If we try to access a specific page and we are not authenticated

View File

@@ -1,18 +0,0 @@
export const mediaUrl = () =>
process.env.NEXT_PUBLIC_MEDIA_URL ||
(typeof window !== 'undefined' ? window.location.origin : '');
export const backendUrl = () =>
process.env.NEXT_PUBLIC_API_ORIGIN ||
(typeof window !== 'undefined' ? window.location.origin : '');
export const baseApiUrl = (apiVersion: string = '1.0') =>
`${backendUrl()}/api/v${apiVersion}/`;
export const providerUrl = (docId: string) => {
const base =
process.env.NEXT_PUBLIC_Y_PROVIDER_URL ||
(typeof window !== 'undefined' ? `wss://${window.location.host}/ws` : '');
return `${base}/${docId}`;
};

View File

@@ -0,0 +1,49 @@
import { Loader } from '@openfun/cunningham-react';
import { PropsWithChildren, useEffect } from 'react';
import { Box } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { configureCrispSession } from '@/services';
import { useSentryStore } from '@/stores/useSentryStore';
import { useConfig } from './api/useConfig';
export const ConfigProvider = ({ children }: PropsWithChildren) => {
const { data: conf } = useConfig();
const { setSentry } = useSentryStore();
const { setTheme } = useCunninghamTheme();
useEffect(() => {
if (!conf?.SENTRY_DSN) {
return;
}
setSentry(conf.SENTRY_DSN, conf.ENVIRONMENT);
}, [conf?.SENTRY_DSN, conf?.ENVIRONMENT, setSentry]);
useEffect(() => {
if (!conf?.FRONTEND_THEME) {
return;
}
setTheme(conf.FRONTEND_THEME);
}, [conf?.FRONTEND_THEME, setTheme]);
useEffect(() => {
if (!conf?.CRISP_WEBSITE_ID) {
return;
}
configureCrispSession(conf.CRISP_WEBSITE_ID);
}, [conf?.CRISP_WEBSITE_ID]);
if (!conf) {
return (
<Box $height="100vh" $width="100vw" $align="center" $justify="center">
<Loader />
</Box>
);
}
return children;
};

View File

@@ -0,0 +1 @@
export * from './useConfig';

View File

@@ -0,0 +1,35 @@
import { useQuery } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Theme } from '@/cunningham/';
interface ConfigResponse {
LANGUAGES: [string, string][];
LANGUAGE_CODE: string;
ENVIRONMENT: string;
COLLABORATION_WS_URL?: string;
CRISP_WEBSITE_ID?: string;
FRONTEND_THEME?: Theme;
MEDIA_BASE_URL?: string;
SENTRY_DSN?: string;
}
export const getConfig = async (): Promise<ConfigResponse> => {
const response = await fetchAPI(`config/`);
if (!response.ok) {
throw new APIError('Failed to get the doc', await errorCauses(response));
}
return response.json() as Promise<ConfigResponse>;
};
export const KEY_CONFIG = 'config';
export function useConfig() {
return useQuery<ConfigResponse, APIError, ConfigResponse>({
queryKey: [KEY_CONFIG],
queryFn: () => getConfig(),
staleTime: Infinity,
});
}

View File

@@ -0,0 +1,2 @@
export * from './useMediaUrl';
export * from './useCollaborationUrl';

View File

@@ -0,0 +1,17 @@
import { useConfig } from '../api';
export const useCollaborationUrl = (room?: string) => {
const { data: conf } = useConfig();
if (!room) {
return;
}
const base =
conf?.COLLABORATION_WS_URL ||
(typeof window !== 'undefined'
? `wss://${window.location.host}/collaboration/ws/`
: '');
return `${base}?room=${room}`;
};

View File

@@ -0,0 +1,10 @@
import { useConfig } from '../api';
export const useMediaUrl = () => {
const { data: conf } = useConfig();
return (
conf?.MEDIA_BASE_URL ||
(typeof window !== 'undefined' ? window.location.origin : '')
);
};

View File

@@ -0,0 +1,3 @@
export * from './api/';
export * from './ConfigProvider';
export * from './hooks';

View File

@@ -1,3 +1,3 @@
export * from './AppProvider';
export * from './auth';
export * from './conf';
export * from './config';

View File

@@ -1,12 +1,6 @@
import useCunninghamTheme from '../useCunninghamTheme';
import { useCunninghamTheme } from '../useCunninghamTheme';
describe('<useCunninghamTheme />', () => {
it('has the theme from NEXT_PUBLIC_THEME', () => {
const { theme } = useCunninghamTheme.getState();
expect(theme).toBe('test-theme');
});
it('has the dsfr logo correctly set', () => {
const { themeTokens, setTheme } = useCunninghamTheme.getState();
setTheme('dsfr');

View File

@@ -16,6 +16,12 @@
line-height: initial;
}
.c__field .c__field__footer {
padding: 2px 0 0;
font-size: var(--c--components--forms-field--footer-font-size);
color: var(--c--components--forms-field--footer-color);
}
.labelled-box label {
color: var(--c--theme--colors--primary-text);
}
@@ -328,6 +334,10 @@ input:-webkit-autofill:focus {
cursor: not-allowed;
}
.c__checkbox.c__checkbox--disabled .c__checkbox__label {
color: var(--c--theme--colors--greyscale-400);
}
/**
* Button
*/
@@ -532,3 +542,10 @@ input:-webkit-autofill:focus {
.c__toast__container {
z-index: 10000;
}
/**
* Tooltip
*/
.c__tooltip {
padding: 4px 6px;
}

View File

@@ -477,6 +477,12 @@
--c--components--forms-datepicker--border-radius: 0;
--c--components--forms-fileuploader--border-radius: 0;
--c--components--forms-field--color: var(--c--theme--colors--primary-text);
--c--components--forms-field--footer-font-size: var(
--c--theme--font--sizes--t
);
--c--components--forms-field--footer-color: var(
--c--theme--colors--greyscale-text
);
--c--components--forms-input--border-radius: 4px;
--c--components--forms-input--background-color: #fff;
--c--components--forms-input--border-color: var(
@@ -492,6 +498,9 @@
--c--components--forms-labelledbox--label-color--big: var(
--c--theme--colors--primary-text
);
--c--components--forms-radio--accent-color: var(
--c--theme--colors--primary-600
);
--c--components--forms-select--item-font-size: 14px;
--c--components--forms-select--border-radius: 4px;
--c--components--forms-select--border-radius-hover: 4px;

View File

@@ -479,7 +479,11 @@ export const tokens = {
},
'forms-datepicker': { 'border-radius': '0' },
'forms-fileuploader': { 'border-radius': '0' },
'forms-field': { color: 'var(--c--theme--colors--primary-text)' },
'forms-field': {
color: 'var(--c--theme--colors--primary-text)',
'footer-font-size': 'var(--c--theme--font--sizes--t)',
'footer-color': 'var(--c--theme--colors--greyscale-text)',
},
'forms-input': {
'border-radius': '4px',
'background-color': '#ffffff',
@@ -491,6 +495,9 @@ export const tokens = {
'forms-labelledbox': {
'label-color': { big: 'var(--c--theme--colors--primary-text)' },
},
'forms-radio': {
'accent-color': 'var(--c--theme--colors--primary-600)',
},
'forms-select': {
'item-font-size': '14px',
'border-radius': '4px',

View File

@@ -1,4 +1,2 @@
import { tokens } from './cunningham-tokens';
import useCunninghamTheme from './useCunninghamTheme';
export { tokens, useCunninghamTheme };
export * from './cunningham-tokens';
export * from './useCunninghamTheme';

View File

@@ -6,22 +6,25 @@ import { tokens } from './cunningham-tokens';
type Tokens = typeof tokens.themes.default & Partial<typeof tokens.themes.dsfr>;
type ColorsTokens = Tokens['theme']['colors'];
type ComponentTokens = Tokens['components'];
type Theme = 'default' | 'dsfr';
export type Theme = keyof typeof tokens.themes;
interface AuthStore {
theme: Theme;
theme: string;
setTheme: (theme: Theme) => void;
themeTokens: () => Partial<Tokens['theme']>;
colorsTokens: () => Partial<ColorsTokens>;
componentTokens: () => ComponentTokens;
}
const useCunninghamTheme = create<AuthStore>((set, get) => {
export const useCunninghamTheme = create<AuthStore>((set, get) => {
const currentTheme = () =>
merge(tokens.themes['default'], tokens.themes[get().theme]) as Tokens;
merge(
tokens.themes['default'],
tokens.themes[get().theme as keyof typeof tokens.themes],
) as Tokens;
return {
theme: (process.env.NEXT_PUBLIC_THEME as Theme) || 'dsfr',
theme: 'dsfr',
themeTokens: () => currentTheme().theme,
colorsTokens: () => currentTheme().theme.colors,
componentTokens: () => currentTheme().components,
@@ -30,5 +33,3 @@ const useCunninghamTheme = create<AuthStore>((set, get) => {
},
};
});
export default useCunninghamTheme;

View File

@@ -20,9 +20,6 @@ declare module '*.svg?url' {
namespace NodeJS {
interface ProcessEnv {
NEXT_PUBLIC_API_ORIGIN?: string;
NEXT_PUBLIC_MEDIA_URL?: string;
NEXT_PUBLIC_Y_PROVIDER_URL?: string;
NEXT_PUBLIC_SW_DEACTIVATED?: string;
NEXT_PUBLIC_THEME?: string;
}
}

View File

@@ -9,26 +9,18 @@ import {
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import {
PropsWithChildren,
ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { PropsWithChildren, ReactNode, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { isAPIError } from '@/api';
import { Box, Text } from '@/components';
import { useDocOptions } from '@/features/docs/doc-management/';
import { useDocOptions, useDocStore } from '@/features/docs/doc-management/';
import {
AITransformActions,
useDocAITransform,
useDocAITranslate,
} from '../api/';
import { useDocStore } from '../stores';
type LanguageTranslate = {
value: string;
@@ -70,9 +62,8 @@ export function AIGroupButton() {
const { t } = useTranslation();
const { currentDoc } = useDocStore();
const { data: docOptions } = useDocOptions();
const [languages, setLanguages] = useState<LanguageTranslate[]>([]);
useEffect(() => {
const languages = useMemo(() => {
const languages = docOptions?.actions.POST.language.choices;
if (!languages) {
@@ -90,7 +81,7 @@ export function AIGroupButton() {
'pl',
]);
setLanguages(languages);
return languages;
}, [docOptions?.actions.POST.language.choices]);
const show = useMemo(() => {
@@ -220,45 +211,19 @@ const AIMenuItemTransform = ({
children,
icon,
}: PropsWithChildren<AIMenuItemTransform>) => {
const editor = useBlockNoteEditor();
const { mutateAsync: requestAI, isPending } = useDocAITransform();
const handleAIError = useHandleAIError();
const handleAIAction = useCallback(async () => {
const selectedBlocks = editor.getSelection()?.blocks;
if (!selectedBlocks || selectedBlocks.length === 0) {
return;
}
const markdown = await editor.blocksToMarkdownLossy(selectedBlocks);
try {
const responseAI = await requestAI({
text: markdown,
action,
docId,
});
if (!responseAI.answer) {
return;
}
const blockMarkdown = await editor.tryParseMarkdownToBlocks(
responseAI.answer,
);
editor.replaceBlocks(selectedBlocks, blockMarkdown);
} catch (error) {
handleAIError(error);
}
}, [editor, requestAI, action, docId, handleAIError]);
const requestAIAction = async (markdown: string) => {
const responseAI = await requestAI({
text: markdown,
action,
docId,
});
return responseAI.answer;
};
return (
<AIMenuItem
icon={icon}
handleAIAction={handleAIAction}
isPending={isPending}
>
<AIMenuItem icon={icon} requestAI={requestAIAction} isPending={isPending}>
{children}
</AIMenuItem>
);
@@ -276,43 +241,21 @@ const AIMenuItemTranslate = ({
icon,
language,
}: PropsWithChildren<AIMenuItemTranslate>) => {
const editor = useBlockNoteEditor();
const { mutateAsync: requestAI, isPending } = useDocAITranslate();
const handleAIError = useHandleAIError();
const handleAIAction = useCallback(async () => {
const selectedBlocks = editor.getSelection()?.blocks;
if (!selectedBlocks || selectedBlocks.length === 0) {
return;
}
const markdown = await editor.blocksToMarkdownLossy(selectedBlocks);
try {
const responseAI = await requestAI({
text: markdown,
language,
docId,
});
if (!responseAI.answer) {
return;
}
const blockMarkdown = await editor.tryParseMarkdownToBlocks(
responseAI.answer,
);
editor.replaceBlocks(selectedBlocks, blockMarkdown);
} catch (error) {
handleAIError(error);
}
}, [editor, requestAI, language, docId, handleAIError]);
const requestAITranslate = async (markdown: string) => {
const responseAI = await requestAI({
text: markdown,
language,
docId,
});
return responseAI.answer;
};
return (
<AIMenuItem
icon={icon}
handleAIAction={handleAIAction}
requestAI={requestAITranslate}
isPending={isPending}
>
{children}
@@ -321,19 +264,49 @@ const AIMenuItemTranslate = ({
};
interface AIMenuItemProps {
handleAIAction: () => Promise<void>;
requestAI: (markdown: string) => Promise<string>;
isPending: boolean;
icon?: ReactNode;
}
const AIMenuItem = ({
handleAIAction,
requestAI,
isPending,
children,
icon,
}: PropsWithChildren<AIMenuItemProps>) => {
const Components = useComponentsContext();
const editor = useBlockNoteEditor();
const handleAIError = useHandleAIError();
const handleAIAction = async () => {
let selectedBlocks = editor.getSelection()?.blocks;
if (!selectedBlocks || selectedBlocks.length === 0) {
selectedBlocks = [editor.getTextCursorPosition().block];
if (!selectedBlocks || selectedBlocks.length === 0) {
return;
}
}
const markdown = await editor.blocksToMarkdownLossy(selectedBlocks);
try {
const responseAI = await requestAI(markdown);
if (!responseAI) {
return;
}
const blockMarkdown = await editor.tryParseMarkdownToBlocks(responseAI);
editor.replaceBlocks(selectedBlocks, blockMarkdown);
} catch (error) {
handleAIError(error);
}
};
if (!Components) {
return null;
}
@@ -359,26 +332,12 @@ const useHandleAIError = () => {
const { toast } = useToastProvider();
const { t } = useTranslation();
const handleAIError = useCallback(
(error: unknown) => {
if (isAPIError(error)) {
error.cause?.forEach((cause) => {
if (
cause === 'Request was throttled. Expected available in 60 seconds.'
) {
toast(
t('Too many requests. Please wait 60 seconds.'),
VariantType.ERROR,
);
}
});
}
return (error: unknown) => {
if (isAPIError(error) && error.status === 429) {
toast(t('Too many requests. Please wait 60 seconds.'), VariantType.ERROR);
return;
}
toast(t('AI seems busy! Please try again.'), VariantType.ERROR);
console.error(error);
},
[toast, t],
);
return handleAIError;
toast(t('AI seems busy! Please try again.'), VariantType.ERROR);
};
};

View File

@@ -1,19 +1,21 @@
import { BlockNoteEditor as BlockNoteEditorCore } from '@blocknote/core';
import { Dictionary, locales } from '@blocknote/core';
import '@blocknote/core/fonts/inter.css';
import { BlockNoteView } from '@blocknote/mantine';
import '@blocknote/mantine/style.css';
import { useCreateBlockNote } from '@blocknote/react';
import { HocuspocusProvider } from '@hocuspocus/provider';
import React, { useCallback, useEffect, useMemo } from 'react';
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import * as Y from 'yjs';
import { Box, TextErrors } from '@/components';
import { mediaUrl } from '@/core';
import { useAuthStore } from '@/core/auth';
import { Doc } from '@/features/docs/doc-management';
import { Version } from '@/features/docs/doc-versioning/';
import { Doc, Role, currentDocRole } from '@/features/docs/doc-management';
import { useCreateDocAttachment } from '../api/useCreateDocUpload';
import { useUploadFile } from '../hook';
import { useHeadings } from '../hook/useHeadings';
import useSaveDoc from '../hook/useSaveDoc';
import { useDocStore, useHeadingStore } from '../stores';
import { useEditorStore } from '../stores';
import { randomColor } from '../utils';
import { BlockNoteToolbar } from './BlockNoteToolbar';
@@ -26,9 +28,6 @@ const cssEditor = (readonly: boolean) => `
padding-right: 30px;
${readonly && `padding-left: 30px;`}
};
& .collaboration-cursor__caret.ProseMirror-widget{
word-wrap: initial;
}
& .bn-inline-content code {
background-color: gainsboro;
padding: 2px;
@@ -66,106 +65,96 @@ const cssEditor = (readonly: boolean) => `
`;
interface BlockNoteEditorProps {
doc: Doc;
version?: Version;
}
export const BlockNoteEditor = ({ doc, version }: BlockNoteEditorProps) => {
const { createProvider, docsStore } = useDocStore();
const storeId = version?.id || doc.id;
const initialContent = version?.content || doc.content;
const provider = docsStore?.[storeId]?.provider;
useEffect(() => {
if (!provider || provider.document.guid !== storeId) {
createProvider(storeId, initialContent);
}
}, [createProvider, initialContent, provider, storeId]);
if (!provider) {
return null;
}
return <BlockNoteContent doc={doc} provider={provider} storeId={storeId} />;
};
interface BlockNoteContentProps {
doc: Doc;
provider: HocuspocusProvider;
storeId: string;
}
export const BlockNoteContent = ({
doc,
provider,
storeId,
}: BlockNoteContentProps) => {
const isVersion = doc.id !== storeId;
export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
const { userData } = useAuthStore();
const { setStore, docsStore } = useDocStore();
const { setEditor } = useEditorStore();
const { t } = useTranslation();
const readOnly = !doc.abilities.partial_update || isVersion;
const readOnly = !doc.abilities.partial_update;
useSaveDoc(doc.id, provider.document, !readOnly);
const storedEditor = docsStore?.[storeId]?.editor;
const {
mutateAsync: createDocAttachment,
isError: isErrorAttachment,
error: errorAttachment,
} = useCreateDocAttachment();
const { setHeadings, resetHeadings } = useHeadingStore();
const { i18n } = useTranslation();
const lang = i18n.language;
const uploadFile = useCallback(
async (file: File) => {
const body = new FormData();
body.append('file', file);
const { uploadFile, errorAttachment } = useUploadFile(doc.id);
const ret = await createDocAttachment({
docId: doc.id,
body,
});
const collabName = readOnly
? 'Reader'
: userData?.full_name || userData?.email || t('Anonymous');
return `${mediaUrl()}${ret.file}`;
},
[createDocAttachment, doc.id],
);
const editor = useMemo(() => {
if (storedEditor) {
return storedEditor;
}
return BlockNoteEditorCore.create({
const editor = useCreateBlockNote(
{
collaboration: {
provider,
fragment: provider.document.getXmlFragment('document-store'),
user: {
name: userData?.email || 'Anonymous',
name: collabName,
color: randomColor(),
},
/**
* We re-use the blocknote code to render the cursor but we:
* - fix rendering issue with Firefox
* - We don't want to show the cursor when anonymous users
*/
renderCursor: (user: { color: string; name: string }) => {
const cursor = document.createElement('span');
if (user.name === 'Reader') {
return cursor;
}
cursor.classList.add('collaboration-cursor__caret');
cursor.setAttribute('style', `border-color: ${user.color}`);
const label = document.createElement('span');
label.classList.add('collaboration-cursor__label');
label.setAttribute('style', `background-color: ${user.color}`);
label.insertBefore(document.createTextNode(user.name), null);
cursor.insertBefore(label, null);
return cursor;
},
},
dictionary: locales[lang as keyof typeof locales] as Dictionary,
uploadFile,
});
}, [provider, storedEditor, uploadFile, userData?.email]);
},
[collabName, lang, provider, uploadFile],
);
useHeadings(editor);
/**
* With the collaboration it gets complicated to create the initial block
* better to let Blocknote manage, then we update the block with the content.
*/
useEffect(() => {
if (doc.content || currentDocRole(doc.abilities) !== Role.OWNER) {
return;
}
setTimeout(() => {
editor.updateBlock(editor.document[0], {
type: 'heading',
content: '',
});
}, 100);
}, [editor, doc.content, doc.abilities]);
useEffect(() => {
setStore(storeId, { editor });
}, [setStore, storeId, editor]);
useEffect(() => {
setHeadings(editor);
editor?.onEditorContentChange(() => {
setHeadings(editor);
});
setEditor(editor);
return () => {
resetHeadings();
setEditor(undefined);
};
}, [editor, resetHeadings, setHeadings]);
}, [setEditor, editor]);
return (
<Box $css={cssEditor(readOnly)}>
{isErrorAttachment && (
{errorAttachment && (
<Box $margin={{ bottom: 'big' }}>
<TextErrors
causes={errorAttachment.cause}
@@ -186,3 +175,42 @@ export const BlockNoteContent = ({
</Box>
);
};
interface BlockNoteEditorVersionProps {
initialContent: Y.XmlFragment;
}
export const BlockNoteEditorVersion = ({
initialContent,
}: BlockNoteEditorVersionProps) => {
const readOnly = true;
const { setEditor } = useEditorStore();
const editor = useCreateBlockNote(
{
collaboration: {
fragment: initialContent,
user: {
name: '',
color: '',
},
provider: undefined,
},
},
[initialContent],
);
useHeadings(editor);
useEffect(() => {
setEditor(editor);
return () => {
setEditor(undefined);
};
}, [setEditor, editor]);
return (
<Box $css={cssEditor(readOnly)}>
<BlockNoteView editor={editor} editable={!readOnly} theme="light" />
</Box>
);
};

View File

@@ -1,14 +1,8 @@
import '@blocknote/mantine/style.css';
import {
BasicTextStyleButton,
BlockTypeSelect,
ColorStyleButton,
CreateLinkButton,
FormattingToolbar,
FormattingToolbarController,
NestBlockButton,
TextAlignButton,
UnnestBlockButton,
getFormattingToolbarItems,
} from '@blocknote/react';
import React from 'react';
@@ -18,42 +12,15 @@ import { MarkdownButton } from './MarkdownButton';
export const BlockNoteToolbar = () => {
return (
<FormattingToolbarController
formattingToolbar={() => (
formattingToolbar={({ blockTypeSelectItems }) => (
<FormattingToolbar>
<BlockTypeSelect key="blockTypeSelect" />
{getFormattingToolbarItems(blockTypeSelectItems)}
{/* Extra button to do some AI powered actions */}
<AIGroupButton key="AIButton" />
{/* Extra button to convert from markdown to json */}
<MarkdownButton key="customButton" />
<BasicTextStyleButton basicTextStyle="bold" key="boldStyleButton" />
<BasicTextStyleButton
basicTextStyle="italic"
key="italicStyleButton"
/>
<BasicTextStyleButton
basicTextStyle="underline"
key="underlineStyleButton"
/>
<BasicTextStyleButton
basicTextStyle="strike"
key="strikeStyleButton"
/>
{/* Extra button to toggle code styles */}
<BasicTextStyleButton key="codeStyleButton" basicTextStyle="code" />
<TextAlignButton textAlignment="left" key="textAlignLeftButton" />
<TextAlignButton textAlignment="center" key="textAlignCenterButton" />
<TextAlignButton textAlignment="right" key="textAlignRightButton" />
<ColorStyleButton key="colorStyleButton" />
<NestBlockButton key="nestBlockButton" />
<UnnestBlockButton key="unnestBlockButton" />
<CreateLinkButton key="createLinkButton" />
</FormattingToolbar>
)}
/>

View File

@@ -1,19 +1,21 @@
import { Alert, Loader, VariantType } from '@openfun/cunningham-react';
import { useRouter as useNavigate } from 'next/navigation';
import { useRouter } from 'next/router';
import React from 'react';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import * as Y from 'yjs';
import { Box, Card, Text, TextErrors } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { DocHeader } from '@/features/docs/doc-header';
import { Doc } from '@/features/docs/doc-management';
import {
Doc,
base64ToBlocknoteXmlFragment,
useDocStore,
} from '@/features/docs/doc-management';
import { Versions, useDocVersion } from '@/features/docs/doc-versioning/';
import { useResponsiveStore } from '@/stores';
import { useHeadingStore } from '../stores';
import { BlockNoteEditor } from './BlockNoteEditor';
import { BlockNoteEditor, BlockNoteEditorVersion } from './BlockNoteEditor';
import { IconOpenPanelEditor, PanelEditor } from './PanelEditor';
interface DocEditorProps {
@@ -25,16 +27,22 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
query: { versionId },
} = useRouter();
const { t } = useTranslation();
const { headings } = useHeadingStore();
const { isMobile } = useResponsiveStore();
const isVersion = versionId && typeof versionId === 'string';
const { colorsTokens } = useCunninghamTheme();
const { providers } = useDocStore();
const provider = providers?.[doc.id];
if (!provider) {
return null;
}
return (
<>
<DocHeader doc={doc} versionId={versionId as Versions['version_id']} />
<DocHeader doc={doc} />
{!doc.abilities.partial_update && (
<Box $margin={{ all: 'small', top: 'none' }}>
<Alert type={VariantType.WARNING}>
@@ -64,39 +72,51 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
$position="relative"
>
{isVersion ? (
<DocVersionEditor doc={doc} versionId={versionId} />
<DocVersionEditor docId={doc.id} versionId={versionId} />
) : (
<BlockNoteEditor doc={doc} />
<BlockNoteEditor doc={doc} provider={provider} />
)}
{!isMobile && <IconOpenPanelEditor headings={headings} />}
{!isMobile && <IconOpenPanelEditor />}
</Card>
<PanelEditor doc={doc} headings={headings} />
<PanelEditor doc={doc} />
</Box>
</>
);
};
interface DocVersionEditorProps {
doc: Doc;
docId: Doc['id'];
versionId: Versions['version_id'];
}
export const DocVersionEditor = ({ doc, versionId }: DocVersionEditorProps) => {
export const DocVersionEditor = ({
docId,
versionId,
}: DocVersionEditorProps) => {
const {
data: version,
isLoading,
isError,
error,
} = useDocVersion({
docId: doc.id,
docId,
versionId,
});
const navigate = useNavigate();
const { replace } = useRouter();
const [initialContent, setInitialContent] = useState<Y.XmlFragment>();
useEffect(() => {
if (!version?.content) {
return;
}
setInitialContent(base64ToBlocknoteXmlFragment(version.content));
}, [version?.content]);
if (isError && error) {
if (error.status === 404) {
navigate.replace(`/404`);
void replace(`/404`);
return null;
}
@@ -116,7 +136,7 @@ export const DocVersionEditor = ({ doc, versionId }: DocVersionEditorProps) => {
);
}
if (isLoading || !version) {
if (isLoading || !version || !initialContent) {
return (
<Box $align="center" $justify="center" $height="100%">
<Loader />
@@ -124,5 +144,5 @@ export const DocVersionEditor = ({ doc, versionId }: DocVersionEditorProps) => {
);
}
return <BlockNoteEditor doc={doc} version={version} />;
return <BlockNoteEditorVersion initialContent={initialContent} />;
};

View File

@@ -6,6 +6,7 @@ import {
} from '@blocknote/react';
import { forEach, isArray } from 'lodash';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
type Block = {
type: string;
@@ -42,9 +43,14 @@ export function MarkdownButton() {
const editor = useBlockNoteEditor();
const Components = useComponentsContext();
const selectedBlocks = useSelectedBlocks(editor);
const { t } = useTranslation();
const handleConvertMarkdown = () => {
const blocks = editor.getSelection()?.blocks;
let blocks = editor.getSelection()?.blocks;
if (!blocks || blocks.length === 0) {
blocks = [editor.getTextCursorPosition().block];
}
forEach(blocks, async (block) => {
if (!isBlock(block as unknown as Block)) {
@@ -75,7 +81,7 @@ export function MarkdownButton() {
return (
<Components.FormattingToolbar.Button
mainTooltip="Convert Markdown"
mainTooltip={t('Convert Markdown')}
onClick={handleConvertMarkdown}
>
M

View File

@@ -8,18 +8,13 @@ import { TableContent } from '@/features/docs/doc-table-content';
import { VersionList } from '@/features/docs/doc-versioning';
import { useResponsiveStore } from '@/stores';
import { usePanelEditorStore } from '../stores';
import { HeadingBlock } from '../types';
import { useHeadingStore, usePanelEditorStore } from '../stores';
interface PanelProps {
doc: Doc;
headings: HeadingBlock[];
}
export const PanelEditor = ({
doc,
headings,
}: PropsWithChildren<PanelProps>) => {
export const PanelEditor = ({ doc }: PropsWithChildren<PanelProps>) => {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const { isMobile } = useResponsiveStore();
@@ -63,7 +58,7 @@ export const PanelEditor = ({
`}
$maxHeight="99vh"
>
{isMobile && <IconOpenPanelEditor headings={headings} />}
{isMobile && <IconOpenPanelEditor />}
<Box
$direction="row"
$justify="space-between"
@@ -127,9 +122,7 @@ export const PanelEditor = ({
</BoxButton>
)}
</Box>
{isPanelTableContentOpen && (
<TableContent doc={doc} headings={headings} />
)}
{isPanelTableContentOpen && <TableContent />}
{!isPanelTableContentOpen && doc.abilities.versions_list && (
<VersionList doc={doc} />
)}
@@ -138,11 +131,8 @@ export const PanelEditor = ({
);
};
interface IconOpenPanelEditorProps {
headings: HeadingBlock[];
}
export const IconOpenPanelEditor = ({ headings }: IconOpenPanelEditorProps) => {
export const IconOpenPanelEditor = () => {
const { headings } = useHeadingStore();
const { t } = useTranslation();
const { setIsPanelOpen, isPanelOpen, setIsPanelTableContentOpen } =
usePanelEditorStore();

View File

@@ -0,0 +1,2 @@
export * from './useSaveDoc';
export * from './useUploadFile';

View File

@@ -0,0 +1,20 @@
import { BlockNoteEditor } from '@blocknote/core';
import { useEffect } from 'react';
import { useHeadingStore } from '../stores';
export const useHeadings = (editor: BlockNoteEditor) => {
const { setHeadings, resetHeadings } = useHeadingStore();
useEffect(() => {
setHeadings(editor);
editor?.onEditorContentChange(() => {
setHeadings(editor);
});
return () => {
resetHeadings();
};
}, [editor, resetHeadings, setHeadings]);
};

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