Compare commits

...

242 Commits

Author SHA1 Message Date
Jacques ROUSSEL
4c44238dc4 fix helmfile linter 2024-10-18 13:47:04 +02:00
Jacques ROUSSEL
ccc206113b overwrite commit 2024-10-18 11:36:29 +02:00
Anthony LC
133b005263 change model IA 2024-10-18 11:23:04 +02:00
Anthony LC
ff845e1a6b fixup increase throttle IA 2024-10-17 17:09:38 +02:00
Anthony LC
980605aecd increase throttle IA 2024-10-17 16:34:25 +02:00
lebaudantoine
f72547e6e6 fix tag 2024-10-17 16:23:02 +02:00
lebaudantoine
3310d8b18f update tag 2024-10-17 16:23:02 +02:00
lebaudantoine
e0d41b712e wip expose a summary 2024-10-17 16:23:02 +02:00
Jacques ROUSSEL
2f8c50540e 🚀(docs-ia) deploy new environment
Because we use staging for German people, we need a new environment for
our demo
2024-10-17 16:23:02 +02:00
Anthony LC
e816f0afc8 🚸(frontend) add toast error when AI request fails
When the AI request fails, a toast error is
displayed to the user.
2024-10-17 16:22:13 +02:00
lindenb1
7e8732822b 🐛(docker) update docker-compose.yml to make nginx depend on app-dev
Modified docker-compose.yml to ensure nginx starts only after app-dev.

Signed-off-by: lindenb1 <linden@b1-systems.de>
2024-10-17 14:50:21 +02:00
Anthony LC
9ed6b11bb1 ⬆️(dependencies) update js dependencies 2024-10-17 13:11:01 +02:00
Anthony LC
62124ae475 🌐(frontend) translate last features
Translate:
- IA Buttons
- doc clipboard markdown / html
2024-10-17 10:11:37 +02:00
Anthony LC
c327928921 🐛(frontend) fix flaky e2e test
A test on e2e was flaky, this commit fixes it.
2024-10-16 22:58:52 +02:00
Anthony LC
be26a9457f 🔧(helm) add ai setting to environments
Add the ai setting to the environments.
2024-10-16 22:58:52 +02:00
Anthony LC
5dc43cbc8b (frontend) add ai blocknote feature
Add AI button to the editor toolbar.
We can use AI to generate content with our editor.
A list of predefined actions are available to use.
2024-10-16 22:58:52 +02:00
Anthony LC
9abf6888aa 🎨(frontend) reduce prop drilling thanks to doc store
We start to have a deep prop drilling with doc,
time to use the doc store to reduce that.
We still prefer to pass the doc as a prop to
keep our component as "pure" as possible, but if
the drilling is too deep, better
to use the doc store.
2024-10-16 22:58:52 +02:00
Anthony LC
aff3b43c9d (backend) create ai endpoint
We created 2 new action endpoints on the document
to perform AI operations:
- POST /api/v1.0/documents/{uuid}/ai-transform
- POST /api/v1.0/documents/{uuid}/ai-translate
2024-10-16 22:58:52 +02:00
Samuel Paccoud - DINUM
e8d95facdf (backend) allow uploading more types of attachments
We want to allow users to upload files to a document, not just images.
We try to enforce coherence between the file extension and the real
mime type of its content. If a file is deemed unsafe, it is still accepted
during upload and the information is stored as metadata on the object
for display to readers.
2024-10-16 19:40:28 +02:00
Samuel Paccoud - DINUM
a9f08df566 (backend) move freezegun to dev dependencies
Freezegun is for testing and should not be installed in the
production image.
2024-10-16 19:40:28 +02:00
Samuel Paccoud - DINUM
2fecbc1162 🚚(backend) split test file for api template accesses
The number of lines in this file had exceeded 1000 lines.
2024-10-16 19:16:50 +02:00
Samuel Paccoud - DINUM
1fc3029d12 🐛(backend) fix dysfunctional permissions on document create
When creating a document access, users were benefitting on the targeted
document from the highest access right they have among all documents.
This is because we forgot to filter on the document ID when retrieving
the role of the user. We improved all tests to secure this issue.
2024-10-16 19:16:50 +02:00
rvveber
bbcb5e0cf1 (frontend) added copy-as buttons for HTML and Markdown
Add buttons to copy editor content as HTML or Markdown. Closes #300
2024-10-16 17:57:10 +02:00
renovate[bot]
e4a7ac0f3c ⬆️(dependencies) update python dependencies 2024-10-16 10:41:25 +02:00
Anthony LC
24630791d8 ♻️(email) use full name instead of email
If the full name is available,
we will use it to identify the user in the email
instead of the email address.
2024-10-16 09:36:33 +02:00
Anthony LC
97d00b678f 🚨(docker) fix docker warning about casing
When we build the docker image, we get a warning
about the casing in the Dockerfile. This commit
fixes the casing in the Dockerfile.
2024-10-14 22:20:54 +02:00
Anthony LC
52eb973164 (CI) refecto test-e2e
With the new container available, we can simplify
the workflow by removing the build step
and using the container directly.
2024-10-14 22:20:54 +02:00
Anthony LC
789879a9cc 🧑‍💻(project) improve frontend bootstrap
We were providing a frontend development container
to the developers, but it was not working properly.
Problem of hot reload was present for Windows and
Linux users.
We stop to provide this development container and
we will provide a container connected to the build
of the frontend.
You can still access the frontend after bootstrap
on the "localhost:3000", but if you want to develop
you will have to install the frontend dependencies
localy and run the frontend in development mode.
This will be more efficient and will avoid the
problem of hot reload, and right on folder access.
2024-10-14 22:20:54 +02:00
Anthony LC
52c52d53b7 🔧(docker) add missing frontent env
The env MEDIA_URL was missing in the frontend
Dockerfile. It is not necessary in our
running environment (staging / preprod ...) but it
is necessary if we want to run the frontend with
a different media url.
SW_DEACTIVATED was missing as well, we need to
deactivate the service worker in the frontend when
we test with Playwright.
2024-10-14 22:20:54 +02:00
Anthony LC
54fe6a2319 🐛(frontend) invalidate queries after removing user
When we remove a user from the list of members,
we need to invalidate the user query for the
user to be found again.
We improve the error message when a user is
already a member of the document.
2024-10-14 19:58:41 +02:00
Anthony LC
bc5dcb0ed5 ️(frontend) use Marianne woff2 if compatible
Woff2 is a more modern format for web fonts,
and it is supported by all modern browsers.
We still keep the woff format for
compatibility with older browsers.
2024-10-11 15:26:18 +02:00
Anthony LC
6c3f3f6a77 💄(frontend) components more multi theme friendly
We adapt a bit the tokens of some components to be
more multi theme friendly.
When we will add another theme, it will be
easier to adapt to the new theme.
2024-10-11 15:26:18 +02:00
Anthony LC
6e64bad1e2 🔖(patch) release 1.5.1
Fixed:
- 🐛(db) fix users duplicate
2024-10-10 16:46:27 +02:00
Anthony LC
0d5b2382ab 🐛(db) fix users duplicate
Some OIDC identity providers provide a random
value in the "sub" field instead of an
identifying ID.
It created duplicate users in the database.
This migration fixes the issue by removing the
duplicate users after having updated all
the references to the old users.
2024-10-10 16:23:46 +02:00
Anthony LC
39d0211593 🔖(minor) release 1.5.0
Added:
- (backend) add name fields to the user synchronized with OIDC
- (ci) add security scan
- (frontend) Activate versions feature
- (frontend) one-click document creation
- (frontend) edit title inline
- 📱(frontend) mobile responsive
- 🌐(frontend) Update translation

Changed:
- 💄(frontend) error alert closeable on editor
- ♻️(backend) Change email content
- 🛂(frontend) viewers and editors can access share modal
- ♻️(frontend) remove footer on doc editor

Fixed:
- 🛂(frontend) match email if no existing user
matches the sub
- 🐛(backend) gitlab oicd userinfo endpoint
- 🛂(frontend) redirect to the OIDC when private doc
and unauthentified
- ♻️(backend) getting list of document versions
available for a user
- 🔧(backend) fix configuration to avoid different
ssl warning
- 🐛(frontend) fix editor break line not working
2024-10-09 16:48:12 +02:00
Anthony LC
86085f87a1 ♻️(frontend) remove share button when not logged in
We remove the share button if the user is not
logged in. Most of the elements in the share modal
nececessitate the user to be logged in.
2024-10-09 16:17:03 +02:00
Anthony LC
ebdcb4b2f0 (frontend) add back the footer and cgu pages
We need to add back the footer and cgu pages,
but we will not display the footer on the doc
editor pages.
2024-10-09 16:17:03 +02:00
Anthony LC
3a0dff5b0e 🐛(service-worker) fix circular import problem
When we were installing the service-worker, errors
were thrown because of circular imports.
This commit fixes the problem by being more explicit
about the imports.
2024-10-09 11:56:35 +02:00
Anthony LC
c682bce6f6 📱(frontend) docs mobile friendly
We adapt the docs component to be
mobile friendly.
2024-10-08 17:25:52 +02:00
Anthony LC
8dd7671d1f 🌐(frontend) add language name to LanguagePicker
The language picker were only showing the language
code, now it shows the language name.
2024-10-08 17:25:52 +02:00
Anthony LC
fe391523c8 📱(frontend) header small mobile friendly
We adapt the header to be small mobile friendly.
We added a burger menu to display the dropdown
menu on small mobile.
2024-10-08 17:25:52 +02:00
Anthony LC
399cf893ad 📱(frontend) add hook store useResponsiveStore
useResponsiveStore is a hook store that provides
the current screen size and the current device type.
2024-10-08 17:25:52 +02:00
Anthony LC
f081f7826a 🔥(frontend) remove footer and legal pages
With the new ui, the footer and legal pages
are no longer needed.
This commit removes them.
2024-10-08 17:25:52 +02:00
Anthony LC
638e1aedb7 🏷️(service-worker) retype the doc creation in SW
Recent changes made the doc creation in SW outdated.
This commit retype the doc creation in SW.
We adapt the main type to fit the new doc type.
2024-10-08 16:30:50 +02:00
Anthony LC
dcbef9630e (e2e) reduce e2e flakiness
We identified some tests causing flakiness
in the e2e tests.
2024-10-08 16:30:50 +02:00
Anthony LC
a745cb7498 🌐(frontend) translate last features
Translate:
- doc visibility
- doc versions
- doc inline title editing
2024-10-08 16:30:50 +02:00
Anthony LC
d701195ae5 🧑‍💻(i18n) rebuild translations for crowdin
For some unexpected reasons it can happen that the
translations in Crowdin are lost.
If that happens, we can rebuild the Crowdin
translations file from our translated json file.
"translations-skeleton.json" is the downloaded
source file from Crowdin.
It will generate "translations-rebuild.json",
which can be uploaded directly to Crowdin.
2024-10-08 16:30:50 +02:00
renovate[bot]
ac18d23fbc ⬆️(dependencies) update python dependencies 2024-10-07 17:32:27 +02:00
Samuel Paccoud - DINUM
ff7914f6d3 🛂(backend) match email if no existing user matches the sub
Some OIDC identity providers may provide a random value in the "sub"
field instead of an identifying ID. In this case, it may be a good
idea to fallback to matching the user on its email field.
2024-10-04 22:08:39 +02:00
Anthony LC
647e6c1cf5 ⬆️(frontend) upgrade blocknote to 0.16.0
Version 0.16.0 of Blocknote fixes the breakline issue.
2024-10-04 11:04:41 +02:00
Anthony LC
98b60ebe93 🐛(frontend) fix infinity scroll on invitation list
The infinity scroll had some difficulties to
load the next page of invitations because the ref
could be not init.
This commit fixes the issue.
2024-10-04 11:04:41 +02:00
Anthony LC
0b15ebba71 🛂(frontend) readers and editors can access share modal
Readers and editors of a document can access the share
modal and see the list of members and their roles.
2024-10-04 11:04:41 +02:00
Samuel Paccoud - DINUM
eee20033ae (backend) add full_name and short_name to user model and API
The full_name and short_name field are synchronized with the OIDC
token upon each login.
2024-10-03 23:39:56 +02:00
Anthony LC
e642506675 🚚(frontend) move Markdown button to is own file
To keep the codebase clean and organized,
we moved the Markdown button to its own file.
2024-10-02 15:24:29 +02:00
Anthony LC
883055b5fb (frontend) first heading is the title of the document
When the title of the doc is not set, the first heading
is used as the title of the document.
2024-10-02 15:24:29 +02:00
Anthony LC
968a1383f7 (frontend) initial editor content is now a heading
When we create a new document,
the initial content is now a heading instead of a
paragraph.
This is to make it easier to set the title
of the document.
2024-10-02 15:24:29 +02:00
Anthony LC
6a2030e235 ♻️(frontend) change useHeading to useHeadingStore
We need to get the headings in multiple places.
To not have multiple listeners to compute the same
thing, we will use a store to store the editor
headings.
2024-10-02 15:24:29 +02:00
Anthony LC
4d2a73556a 🔥(frontend) remove useless update title codes
We can now update the title directly in the header,
so we don't need the update title modal anymore.
We remove the buttons to trigger the modal
and the modal itself.
2024-10-02 15:24:29 +02:00
Anthony LC
90027d3a5a (frontend) edit title inline
We can now edit the title of the document inline.
This is a feature that is very useful for users
who want to change the title of the document
without having to go to the document
management page.
2024-10-02 15:24:29 +02:00
Anthony LC
61593bd807 ♻️(frontend) one click create doc
We can now create a doc in one click.
The doc will be created with a default name,
the user will be able to edit the name inline.
2024-10-02 15:24:29 +02:00
Anthony LC
99ebc9fc9c 🚚(frontend) rename useTransRole to useTrans
We rename useTransRole to useTrans to make
it more general and reusable.
It will be used for all reusable doc translation.
2024-10-02 15:24:29 +02:00
Anthony LC
a5e798164c 🚚(frontend) add utils userAgent
In order to stay DRY, we moved the function
to know if a user is from a firefox browser
from useSaveDoc to utils userAgent.
2024-10-02 15:24:29 +02:00
Anthony LC
002b9340e3 🛂(frontend) redirect to the OIDC when private doc
We now redirect to the OIDC when a user is on
a private doc and get a 401 error.
2024-10-02 15:24:29 +02:00
Anthony LC
f00f833ee2 🐛(frontend) fix sticky panel editor
the sticky panel editor is not working properly,
the panel editor should be sticky until the bottom
when the user scrolls the page.
2024-10-02 15:24:29 +02:00
Jacques ROUSSEL
3a6bc8c0f7 🔧(backend) fix configuration to avoid different ssl warning
Fix following warning messages :
- You have not set a value for the SECURE_HSTS_SECONDS setting.
- Your SECURE_SSL_REDIRECT setting is not set to True.
2024-10-01 09:27:37 +02:00
virgile-dev
76368f1ae9 📝(project) update README.md
Minimal update to the readme file to better reflect the project.
2024-09-30 17:52:14 +02:00
Anthony LC
fab86f7f87 ⬆️(dependencies) bump js dependencies
We bump the js dependencies to their latest version.
2024-09-30 17:39:28 +02:00
Anthony LC
ac74db2fde ♻️(frontend) add versions in the panel editor
We add the features version to the panel editor.
We had to refactor the panel to be able to
have the version with the table of content in
the same panel.
2024-09-30 17:26:23 +02:00
Anthony LC
b2480eea74 ♻️(frontend) minor components refacto
Improve some props in different components.
2024-09-30 17:26:23 +02:00
Anthony LC
20a898c978 👔(frontend) adapt versions api with new types
We updated the backend recently, the types of the
versions list has changed.
This commit adapts the frontend to the new types.
2024-09-30 17:26:23 +02:00
renovate[bot]
589d3abd8d ⬆️(dependencies) update python dependencies 2024-09-30 12:39:51 +02:00
renovate[bot]
1ba588d416 ⬆️(dependencies) update js dependencies 2024-09-30 11:57:15 +02:00
Anthony LC
b1f37495d6 🚑️(backend) fix CVEs in backend image
Use alpine version for production image instead of
debian in order to have less CVEs.
2024-09-30 10:59:52 +02:00
Jacques ROUSSEL
8c9cb43097 🚑️(frontend) fixe CVEs in frontend image
Use alpine version for production image instead of debian in order to
have less CVEs.
2024-09-30 10:59:52 +02:00
Jacques ROUSSEL
aeeed8feb5 (ci) add security scan
Add a security scan for CVE with trivy
2024-09-30 10:59:52 +02:00
Anthony LC
1e89eb1a21 🛂(frontend) redirect to the OIDC when private doc
We now redirect to the OIDC when a user is on
a private doc and is not authentified.
2024-09-27 16:04:31 +02:00
Anthony LC
413e0bebad 🐛(frontend) fix redirection after login
The redirection after login was not working properly.
The user was redirected to the home page
instead of the page he was trying to access.
2024-09-27 16:04:31 +02:00
Samuel Paccoud - DINUM
a2a184bb93 ♻️(api) refactor getting versions to expose pagination
Getting versions was not working properly. Some versions returned
were not accessible by the user requesting the list of available
versions.

We refactor the code to make it simpler and let the frontend handle
pagination (load more style).
2024-09-27 14:59:32 +02:00
Anthony LC
827d8cc8e1 ♻️(backend) change email invitation content
Change the email invitation content. More
document related variables are added.
To benefit of the document inheritance, we moved
the function email_invitation to the document model.
2024-09-26 09:58:11 +02:00
Anthony LC
833c53f5aa 💄(frontend) error alert closeable on editor
When we were uploading a file that was not allowed,
an error alert was shown. This alert was not closeable.
This commit makes the alert closeable.
2024-09-24 16:38:25 +02:00
Jacques ROUSSEL
2775a74bdb (ci) add helmfile linter and fix issue in argocd sync
Add a github job to run helmfile linter on PR
Add argocd annotation to fix job syncing issue
2024-09-24 14:09:26 +02:00
Anthony LC
450790366d (CI) fix flaky test on MinIO initialized
MinIO server need to be initialized before
running the job to configure MinIO.
We add a delay to wait for MinIO server to be ready.
2024-09-24 09:45:09 +02:00
Anthony LC
7b04f664cd (backend) fix flaky test on tmp file
It seems to have a race condition, sometimes the
tmp file is not deleted before the test assertion.
We let the test sleep for 0.5 second before
the assertion.
2024-09-24 09:45:09 +02:00
renovate[bot]
358508ffa3 ⬆️(dependencies) update python dependencies 2024-09-23 11:32:22 +02:00
Anthony LC
9388c8f8f4 🛂(backend) oidc userinfo endpoint json format
The userinfo endpoint can return 2 content types:
- application/json
- application/jwt

Gitlab oidc returns a json object, while
Agent Connect oidc returns a jwt token.
We are adapting the authentication to handle both cases.
2024-09-23 10:57:57 +02:00
renovate[bot]
40d8c949d9 ⬆️(dependencies) update js dependencies 2024-09-23 10:43:10 +02:00
Jacques ROUSSEL
6b0b052d78 🔒️(helm) fix secret sync precedence
When new secret is added to backend secret, it's not sync at the
beginning of argocd synchronisation and jobs are blocked. Theses new
annotations fix this issue.
2024-09-20 18:29:10 +02:00
Anthony LC
ac86a4e7f7 🔖(minor) release 1.4.0
Added:
- (backend) Add link public/authenticated/restricted
access with read/editor roles
- (frontend) add copy link button
- 🛂(frontend) access public docs without being logged

Changed:
- ♻️(backend) Allow null titles on documents
for easier creation
- 🛂(backend) stop to list public doc to everyone
- 🚚(frontend) change visibility in share modal
- ️(frontend) Improve summary

Fixed:
- 🐛(backend) Fix forcing ID when creating a
document via API endpoint
- 🐛 Rebuild frontend dev container from makefile
2024-09-18 12:01:52 +02:00
Anthony LC
bbe5501297 🌐(frontend) translate last features
Translate:
- doc visibility
- doc table of contents
2024-09-18 11:18:29 +02:00
Anthony LC
b37acf3138 💄(frontend) improve ui table of contents
- keep correctly the text on the left side
- improve accuracy highlightment heading when scrolling
- display full heading text when text transform is applied
- fix typo
2024-09-18 11:18:29 +02:00
Anthony LC
5bd78b8068 🚚(frontend) rename feature summary to table of content
We rename the feature summary to table of content
to better reflect the feature purpose.
2024-09-17 15:06:37 +02:00
Anthony LC
ed39c01608 ♻️(frontent) improve summary feature
- Change Summary to Table of content
- No dash before the title
- Change font-size depend the type of heading
- If more than 2 headings the panel is open
by default
- improve sticky
- highligth the title where you are in the page
2024-09-17 15:06:37 +02:00
Anthony LC
748ebc8f26 🔧(helm) change conf helm dev
Some frontend env vars were added on the frontend
side, we need to add them to the dev helm chart.
2024-09-17 15:06:37 +02:00
renovate[bot]
03262878c4 ⬆️(dependencies) update js dependencies 2024-09-16 14:30:29 +02:00
Anthony LC
97fa5b8532 🧑‍💻(makefile) add build frontend dev
docker build frontend dev was lacking.
If dependencies were updated, the change were
not reflected in the frontend container.
2024-09-12 08:06:17 +02:00
Anthony LC
a092c2915b ♻️(frontend) adapt doc visibility to new api
We updated the way we handle the visibility of a doc
in the backend. Now we use a new api to update
the visibility (documents/{id}/link-configuration/)
of a doc. We adapted the frontend to use this new api.
We changed the types to reflect the new api and
to keep the same logic.
2024-09-11 22:31:30 +02:00
Samuel Paccoud - DINUM
9b44e021fd ♻️(models) allow null titles on documents
We want to make it as fast as possible to create a new document.
We should not have any modal asking the title before creating the
document but rather show an "untitle document" title and let the
owner set it on the already created document.
2024-09-11 22:31:30 +02:00
Samuel Paccoud - DINUM
2c3eef4dd9 (api) allow forcing ID when creating a document via API endpoint
We need to be able to force the ID when creating a document via
the API endpoint. This is usefull for documents that are created
offline as synchronization is achieved by replaying stacked requests.

We do it via the serializer, making sure that we don't override an
existing document.
2024-09-11 22:31:30 +02:00
Samuel Paccoud - DINUM
dec1a1a870 🔥(api) remove possibility to force document id on creation
This feature poses security issues in the way it is implemented.
We decide to remove it while clarifying the use case.
2024-09-11 22:31:30 +02:00
Samuel Paccoud - DINUM
1e432cfdc2 (api) allow updating link configuration for a document
We open a specific endpoint to update documents link configuration
because it makes it more secure and simple to limit access rights
to administrators/owners whereas other document fields like title
and content can be edited by anonymous or authenticated users with
much less access rights.
2024-09-11 22:31:30 +02:00
Samuel Paccoud - DINUM
f5c4106547 🐛(api) fix randomly failing test on document list ordering via API
The test was randomly failing because postgresql and python sorting
was not 100% consistent e.g "treatment" vs "treat them" were not
ordered the same.

Comparing each field value insteat of relying on "sort" solves the
issue and makes the test simpler.
2024-09-11 22:31:30 +02:00
Samuel Paccoud - DINUM
494638d306 (models/api) add link access reach and role
Link access was either public or private and was only allowing readers.

This commit makes link access more powerful:
- link reach can be private (users need to obtain specific access by
  document's administrators), restricted (any authenticated user) or
  public (anybody including anonymous users)
- link role can be reader or editor.

It is thus now possible to give editor access to an anonymous user or
any authenticated user.
2024-09-11 22:31:30 +02:00
Samuel Paccoud - DINUM
41260de1c3 🔥(compose) remove docker compose version
The version is now automatically guessed by Docker Compose. This
commit will fix the warning raised about the uselessness of this
setting.
2024-09-11 22:31:30 +02:00
Anthony LC
140a630a6e 🛂(backend) stop to list public doc to everyone
Everybody could see the full list of public docs.
Now only members can see their public docs.
They can still access to any specific public doc.
2024-09-11 22:31:30 +02:00
Anthony LC
b716881d50 📌(frontend) add lacking peerdependencies
- Add some lacking peerdependencies to the packages.
- Resolve conflicts eslint dependencies.
2024-09-11 11:39:03 +02:00
renovate[bot]
fa8466d44d ⬆️(dependencies) update js dependencies 2024-09-11 11:39:03 +02:00
Anthony LC
4ba34f6c80 (CI) add ngnix for the frontend
Because of the Next.js bug with the 404 on the
dynamic routes, we are not able to assert some
behaviors from the e2e tests and the CI.
So we are adding a ngnix to the CI e2e tests
to be able to route correctly our frontend.
2024-09-10 15:51:28 +02:00
Anthony LC
e4712831f2 🎨(frontend) standalone component DocTagPublic
We want to rerender the public tag when we update
the visibility of a document. The problem is that
the public tag is not a standalone component, so
to have it rerender we needed to rerender the whole
document, it is not visually nice.
We created a standalone component for
the public tag, so when we update the visibility
of a document, only the public tag will be rerender.
2024-09-10 15:51:28 +02:00
Anthony LC
37db31a8d5 (frontend) add copy link button
Add a copy link button to the doc
visibility component. This button will
copy the link of the doc to the clipboard.
2024-09-10 15:51:28 +02:00
Anthony LC
4321511631 🚚(frontend) change visibility in share modal
We stop to propose to make the document public
from the doc creation modal.
We now propose to change the visibility of
the document from the share modal.
2024-09-10 15:51:28 +02:00
Anthony LC
459cb5e2e2 🛂(frontend) access public docs without being logged
We can now access public docs without being logged.
2024-09-10 15:51:28 +02:00
Anthony LC
2a7e3116bd 🔖(minor) release 1.3.0
Added:
- Add image attachments with access control
- (frontend) Upload image to a document
- (frontend) Summary
- (frontend) update meta title for docs page

Changed:
- 💄(frontend) code background darkened on editor
- 🔥(frontend) hide markdown button if not text

Fixed:
- 🐛 Fix emoticon in pdf export
- 🐛 Fix collaboration on document
- 🐛 (docker) Fix compatibility with mac

Removed:
- 🔥(frontend) remove saving modal
2024-09-10 09:04:54 +02:00
Anthony LC
b9046a2d9b 🐛(frontend) meta title rerender issue
The meta title is not displayed when we come back to
a page from the dynamic router. The code seems to
compute to quickly so we need to add a delay to the
meta title computation.
2024-09-09 17:47:24 +02:00
Anthony LC
d249ed0c71 🔧(helm) change production media storage name
The set the correct media storage name
for production environment.
2024-09-09 16:36:24 +02:00
Anthony LC
48d3738ec2 (frontend) update meta title for docs page
We update the meta title for the docs page
with the title of the document.
It will be easier for the user
to identify the document in their browser tab,
in their bookmarks and history.
2024-09-05 13:26:49 +02:00
Anthony LC
92102e4a36 🔧(compose) stop forcing platform for Keycloak PostgreSQL image
Forcing `platform: linux/amd64` for the PostgreSQL
image causes compatibility issues and performance
degradation on Mac ARM chips (M1/M2).
Removing the platform specification allows Docker
to select the appropriate architecture automatically,
ensuring better performance and compatibility.
2024-09-05 12:09:15 +02:00
Anthony LC
dd1b271b71 🌐(frontend) translate last features
Translate:
- doc versions
- doc summary
- export docx
2024-09-05 09:30:56 +02:00
Anthony LC
7cfc1d8036 ⬆️(i18n) i18next-parser to 9.0.2
i18next-parser had a compatibility issue with
a dependency (cheerio). The last version
fixed this issue, plus fixed another issue
about a configuration problem.
We can now remove it from the renovate ignore list.
2024-09-05 09:30:56 +02:00
Anthony LC
86fdbeacaa 🔥(frontend) do not display feature version
A bug was found in the version feature.
A 404 error appears sometimes, probably because
of Minio that does not keep enough versions.
We want to do a realease, so we will remove the
version feature for now.
2024-09-05 09:30:56 +02:00
Anthony LC
520d511f59 🔧(project) replace webrtc by yProvider
Replace webrtc by yProvider the project
(docker, helm chart, etc).
2024-09-04 21:10:24 +02:00
Anthony LC
9c512fae69 ♻️(y-provider) replace y-webrtc-signaling by server-y-provider
We replace the y-webrtc-signaling app by
the server-y-provider server.
The server-y-provider server uses @hocuspocus to
do collaborative editing on docs.
2024-09-04 21:10:24 +02:00
Anthony LC
1139c0abea ♻️(frontend) replace y-webrtc by @hocuspocus
y-webrtc had some issues, users had difficulties
to connect with each others.
We replace it by @hocuspocus/provider.
2024-09-04 21:10:24 +02:00
Anthony LC
9e1979f637 🐛(docker) add emoji font
In order to have the emoji font available in
the container, we need to install it.
The font will be then available in the
PDF export.
2024-09-03 17:37:56 +02:00
Anthony LC
ddd93ab0c5 🐛(frontend) close panel when unmount
When the panel is unmounted, the summary and
version panel should be closed.
2024-09-03 17:37:56 +02:00
Anthony LC
85044fd665 (frontend) summary feature
Add the summary feature to the doc.
We will be able to access part of the doc quickly
from the summary.
2024-09-03 15:55:25 +02:00
Anthony LC
b83875fc97 ♻️(frontend) move Panel component
We will have multiple Panel components in the future,
so we move it to the root of the components folder.
We refacto the Version Panel to use the new
Panel component.
2024-09-03 15:55:25 +02:00
Anthony LC
7a8caf5475 🐛(backend) compatibility issue with django and easy_thumbnails
There is a compatibility issue between django 5.1
and easy_thumbnails 2.9.
This commit fixes the issue.
2024-09-03 11:36:50 +02:00
renovate[bot]
e927f2c004 ⬆️(dependencies) update python dependencies 2024-09-03 11:36:50 +02:00
Anthony LC
7f25b05474 ⚗️(frontend) add button Restore this version near title
When a user is on a page version, we will display
a button "Restore this version" near the title of
the page. It gives an obvious way to restore the
version of the doc.
2024-09-02 17:02:23 +02:00
Anthony LC
296b5dbf59 ♻️(frontend) add modal confirmation restore version
Add modal confirmation restore version explaining
that the current version will be replaced
by the selected version, and that some data
may be lost.
2024-09-02 17:02:23 +02:00
Anthony LC
accbda44e2 ♻️(frontend) open version panel from docs options
Versions panel is a feature that will not be used
by all users, so it should be hidden
by default. The user can open it from the docs
options.
2024-09-02 17:02:23 +02:00
Anthony LC
f2a78ada47 🔧(helm) replace storage url in ingressMedia
There is no mechanism to have the media storage
URL from a secret from the ingress.
The media storage URL has to be hardcoded.
We replace the media storage URL in the ingress,
if we change the cluster, we will have to update
these urls.
2024-09-02 12:17:40 +02:00
renovate[bot]
4cb0423511 ⬆️(dependencies) update js dependencies 2024-09-02 09:50:21 +02:00
Anthony LC
766aee6a92 💄(frontend) code background darkened on editor
The "code" was not visible on the editor
because the background was too light.
The background color was darkened to make the
"code" more visible.
2024-08-30 15:58:25 +02:00
Anthony LC
3d19893091 🔥(frontend) remove saving modal
The saving toast are removed.
Users were complaining about the toast
that was shown when saving a document.
2024-08-30 15:43:48 +02:00
Anthony LC
00b223f648 🔥(frontend) hide markdown button if not text
If we are selected a block that is not a text block,
we hide the markdown button.
2024-08-30 15:43:48 +02:00
Anthony LC
38b32c1227 ️(tilt) stop kind-registry to restart when stopped
To save resources, it is nive to be able to stop
the kind-registry when it is not needed.
This commit allow us to stop it.
2024-08-29 18:31:26 +02:00
Anthony LC
1ff3d9c54e 🧑‍💻(ngnix) add conf ngnix to proxy media url
In development mode with docker-compose, we need to
configure Nginx to proxy requests to the Minio server.
Before to proxy to Minio, we need to
authenticate the request, so we proxy to the
Django server first to fill the request with the
necessary headers, then we proxy to Minio.
2024-08-29 18:31:26 +02:00
Anthony LC
6eff21f51e (frontend) add upload to the doc editor
We can now upload images to the doc editor.
The image is uploaded to the server
and the URL is inserted into the editor.
2024-08-29 18:31:26 +02:00
Anthony LC
3eb8f88b5c 👔(frontend) integrate attachment-upload endpoint
Integrate the `documents/${docId}/attachment-upload/`
endpoint. This endpoint is used to upload attachments
to a document.
To have automatically the good content-type form-data,
the `fetchApi` function has been updated to remove the
prefill `Content-Type` header.
2024-08-29 18:31:26 +02:00
renovate[bot]
3a3483b776 ⬆️(dependencies) update js dependencies 2024-08-29 14:11:11 +02:00
Samuel Paccoud - DINUM
67a20f249e (backend) add url to download media attachments with access rights
We make use of nginx subrequests to block media file downloads while
we check for access rights. The request is then proxied to the object
storage engine and authorization is added via the "Authorization"
header. This way the media urls are static and can be stored in the
document's json content without compromising on security: access
control is done on all requests based on the user cookie session.
2024-08-27 15:59:44 +02:00
Samuel Paccoud - DINUM
c9f1356d3e (backend) allow uploading images as attachments to a document
We only rely on S3 to store attachments for a document. Nothing
is persisted in the database as the image media urls will be
stored in the document json.
2024-08-27 15:59:44 +02:00
Samuel Paccoud - DINUM
f12708acee ⬆️(backend) upgrade boto3 to 1.14.4 for unsigned urls
For media urls, we want to compute authorization as a header
instead of computing signed urls.

The url of a media file can then be computed without the
querystring authorization part. This requires upgrading
django-storages to the 1.14 version to benefit from the
"unsigned connection" in the S3Storage backend.
2024-08-27 15:59:44 +02:00
Anthony LC
58eaea000c 🔖(patch) release 1.2.1
Changed:
- ♻️ Change ordering docs datagrid
- 🔥(helm) use scaleway email
2024-08-23 16:27:52 +02:00
Anthony LC
7d97a037f6 🧑‍💻(makefile) bump-packages-version
Create the command bump-packages-version in Makefile.
This command will bump the version of
all the javascript packages in the project.
2024-08-23 16:27:52 +02:00
Anthony LC
c830b4dae6 ♻️(email) replace base64 image with a link
The emails were too big, gmail by example was not
able to display them correctly.
It was caused by base64 image, so they are
replaced with a link to the image.

We fixed the link to the website, it will improve
the score of the email.
2024-08-23 15:37:01 +02:00
Anthony LC
a0fe98e156 🔥(helm) configure staging to use scaleway email
Change staging configuration to use scaleway transactionnal email
2024-08-23 15:37:01 +02:00
Anthony LC
fa105e5b54 📌(e2e) pin pdf-parse to 1.1.1
pdf-parse was not pinned to a specific version.
This could lead to unexpected behavior
if the package is updated.
This change pins pdf-parse to version 1.1.1.
2024-08-23 14:29:52 +02:00
Anthony LC
ced850aecf ♻️(frontend) datagrid ordered by updated_at desc
The datagrid is now ordered by updated_at desc.
2024-08-23 14:29:52 +02:00
Anthony LC
3a420c0416 ♻️(backend) document list order by updated_at desc
Document list is now ordered by updated_at in
descending order.
Test cases were improved as well.
2024-08-23 14:29:52 +02:00
Anthony LC
b5a67df88b 🔖(minor) minor release to 1.2.0
Added:
- 🎨(frontend) better conversion editor to pdf
- Export docx (word)
- 🌐Internationalize invitation email
- (frontend) White branding
- Email invitation when add user to doc
- Invitation management

Fixed:
- 🐛(y-webrtc) fix prob connection
- ️(frontend) improve select share stability
- 🐛(backend) enable SSL when sending email

Changed:
- 🎨(frontend) stop limit layout height to screen size
- ️(CI) only e2e chrome mandatory

Removed:
- 🔥(helm) remove htaccess
2024-08-22 13:39:18 +02:00
Anthony LC
6683821eaf 🔥(frontend) do not display feature version
We want to improve the version feature before
releasing it. We will not display it for now.
2024-08-22 12:33:52 +02:00
Anthony LC
9c67c9e4d4 🔥(helm) remove htaccess
We will not use htaccess anymore,
so we can remove it from the project.
2024-08-21 16:53:38 +02:00
Anthony LC
543770ae63 📝(project) update release document
Update release document.
2024-08-21 16:53:38 +02:00
Anthony LC
f0e2a2b710 ♻️(backend) automatic delete temporary files
To leverage the automatic deletion of temporary
files, we do the conversion inside the with context.
Even if the conversion fails, the temporary file
will be deleted.
2024-08-21 15:27:31 +02:00
Anthony LC
67625dff7a ⬇️(backend) downgrade django-storages[s3] to 1.14.2
Downgrade django-storages[s3] from 1.14.4 to 1.14.2.
It seems to have an issue with our setup.
"default_storage.exists(file_key)" is returning
False when we save a document even if the file
exists in the S3 bucket.
2024-08-20 18:06:02 +02:00
Anthony LC
4ed964240f (frontend) add @typescript-eslint/parser
In order to work correctly
@typescript-eslint/eslint-plugin requires
@typescript-eslint/parser to be installed as well.
We added @typescript-eslint/parser, and
upgraded @typescript-eslint/eslint-plugin to 8.1.0.
We fixed the linting issues related to the
upgrade.
2024-08-20 18:06:02 +02:00
Anthony LC
a970a83229 🚨(backend) fix linting issues after upgrading
The last upgrades introduced some linting issues.
This commit fixes them.
2024-08-20 18:06:02 +02:00
Anthony LC
7babc46261 ⬇️(backend) downgrade django to 5.0.8
Downgrade django from 5.1 to 5.0.8.
There is a compatibility issue with easy_thumbnails,
which is not yet compatible with Django 5.1.
2024-08-20 18:06:02 +02:00
renovate[bot]
2af88c5a4d ⬆️(dependencies) update python dependencies 2024-08-20 18:06:02 +02:00
Anthony LC
07d9e290fa (backend) adapt test to djangorestframework 3.15.2
A recent update of the djangorestframework changes
the detail message of the 404 error.
We update the tests to match the new message.
2024-08-19 17:20:52 +02:00
renovate[bot]
29c5199b72 ⬆️(dependencies) update djangorestframework to v3.15.2 [SECURITY] 2024-08-19 17:20:52 +02:00
Anthony LC
98b3fc4a1b ⬇️(frontend) downgrade @typescript-eslint/eslint-plugin to 7.13.1
@typescript-eslint/eslint-plugin released the
version 8, but it is causing some issues
(@typescript-eslint/no-duplicate-enum-values).
We downgrade it to 7.13.1 in waiting for a fix.
2024-08-19 16:53:17 +02:00
renovate[bot]
754c5d06f4 ⬆️(dependencies) update js dependencies 2024-08-19 16:53:17 +02:00
Anthony LC
fe00d65500 🗑️(frontend) clean member feature
Clean a bit member feature, remove unused files
and refactor some code.
2024-08-19 16:32:46 +02:00
Anthony LC
5ef0f825e0 (frontend) invitation list
- Display the list of invitations for a document
in the share modal.
- We can now cancel an invitation.
- We can now update the role of a invited user.
2024-08-19 16:32:46 +02:00
Anthony LC
3e5dae4ff1 🛂(backend) can update role invitation
Allow to update role invitation if owner or admin.
2024-08-19 16:32:46 +02:00
Anthony LC
affe3be937 ️(CI) only e2e chrome mandatory
To speed up pull request flow, put e2e tests
only mandatory for Chrome.
We still have tests for Firefox and Webkit,
but they are not mandatory.
They will still have to be checked regularly,
particularly during the deployment phase.
2024-08-19 15:51:44 +02:00
Anthony LC
0512af273c 🛂(frontend) redirect to correct url after login
If a user wanted to access a doc but was not logged in,
they would be redirected to the login page.
After logging in, they would be redirected to the home page.
This change makes it so that they are
redirected to the doc they originally wanted to access.

Usefull from the mail sent to the user to access the doc
they were invited to.
2024-08-16 15:17:27 +02:00
Anthony LC
a925b0bedf 🚨(backend) fix linter warning too many lines
The linter was complaining about too many lines
in test_api_document_accesses.py. We split the
test file into two files to fix the warning.
We move as well the test_api_document tests to
the documents folder.
2024-08-16 15:17:27 +02:00
Anthony LC
143f1a02eb 🌐(frontend) add Content-Language on doc access endpoint
We send an internationalized email from
the POST /api/docs/:docId/access endpoint.
We add the language of the website with the
Content-Language header.
2024-08-16 15:17:27 +02:00
Anthony LC
1abbf0539f (backend) send email invitation when add user to doc
We send as well an email invitation to the user
when we add him to a document.
2024-08-16 15:17:27 +02:00
Anthony LC
2f8c5637f4 ♻️(backend) refacto email invitation
Remove email invitation from Invitation model
to be able to use it in other context.
We add it in utils.py instead, and it will be called
from the viewset.
We add the document_id to link to the document from
the mail.
2024-08-16 15:17:27 +02:00
Anthony LC
2391098aba 🔧(docker) frontend env build
Make the environment variables configurable
for the frontend app in the Dockerfile by setting
the arg variables.
2024-08-15 15:25:09 +02:00
Anthony LC
6fdf912d5d 🎨(frontend) get the brand logo from the theme
We want to get the brand logo from the theme.
This way, we will be able to change the
logo easily just by switching to another theme.
2024-08-15 15:25:09 +02:00
Anthony LC
c62a0afdf6 🔧(frontend) theme from env var
We want to be able to brand our website (theme and logo)
at build time, we use the dsfr theme but
the german will use their own theme with their
own logo.
We will use a env var to set the theme,
depend the environment we will be able to
use different theme at build time.
2024-08-15 15:25:09 +02:00
Anthony LC
d73e42b20b 🔥(crowdin) remove crowdin from CI workflow
Pushing to crowdin from the workflow has some side
effects, if 2 branches are pushing to crowdin it
can cause conflicts and delete translations on
Crowdin side.
Better to push to crowdin manually to keep good
control over the translations.
2024-08-15 12:06:17 +02:00
Anthony LC
5d98986402 🌐(i18n) translate the invitation email
Translate the invitation email.
Generate the .po and .mo files thanks to Crowdin.
2024-08-15 12:06:17 +02:00
Anthony LC
c7f7b0f7ad 🌐(frontend) add Content-Language on invitation endpoint
We want to adapt the email language depend the website
choosen language. We sent the language in the request
header, the backend will use this information to send
the email in the correct language.
2024-08-15 12:06:17 +02:00
Anthony LC
41a6ef9dfc 🌐(backend) user language from request Content-Language
We want to adapt the email language depend the website
choosen language. We get the website language
from the request Content-Language header.
We adapt the serializer to set the user language
from the request Content-Language header.
Thanks to that our email will be in the right language.
2024-08-15 12:06:17 +02:00
Anthony LC
ac58341984 (e2e) increase timeout doc export test
We have a test asserting lot of things on the doc
export, and it's failing on CI because of the
timeout.
We increase the timeout to make it pass.
2024-08-14 11:45:21 +02:00
Anthony LC
990de437ba 🚚(project) move scripts to bin
bin and scripts were similar folders,
we will move all scripts to bin folder
and remove scripts folder.
2024-08-14 11:45:21 +02:00
Anthony LC
b4d3f94ada 📝(project) add release document
Add a release document to the project.
It indicates the steps to follow to release
a new version of the project.
2024-08-14 11:45:21 +02:00
Anthony LC
c724ae0253 🔥(project) remove tsclient
We don't need the tsclient for the moment.
Better to remove it.
2024-08-14 11:45:21 +02:00
Anthony LC
e9796404cf 💬(helm) change EMAIL_FROM settings
Change EMAIL_FROM settings to fit our usecase.
2024-08-13 21:42:41 +02:00
Anthony LC
6481ce311d 🐛(backend) enable SSL when sending email
Email settings were wrongly configured. It leed to
unsent email and timeout response from the backend
server. This commit fixes the issue by enabling SSL
when sending email.
2024-08-13 21:42:41 +02:00
Anthony LC
af7e480d52 💬(mail) improve email text
- replace occurences of Impress with Docs in the email
- replace occurences of Impress with Docs in the email
subject
2024-08-13 21:42:41 +02:00
Anthony LC
c1566d98fe ♻️(frontend) export to docx
We can now export the document to docx format.
We adapted the frontend to be able to choose
between pdf or docx export.
2024-08-12 15:46:01 +02:00
Anthony LC
4280f0779e 🗃️(backend) export to docx
We can now export our document to a docx file.
This is done by converting the html to a docx
file using the pypandoc and pandoc library.
We added the "format" param to the
generate-document endpoint, "format" accept
"pdf" or "docx" as value.
2024-08-12 15:46:01 +02:00
Anthony LC
ffaccad014 🍱(project) add gouv logo img
Add a gouv logo image to the public assets folder.
We will be able to access it via the frontend url.
Usefull to get them from our templates on the
backend side.
2024-08-12 15:46:01 +02:00
renovate[bot]
c077ed8414 ⬆️(dependencies) update django to v5.0.8 [SECURITY] 2024-08-12 11:49:41 +02:00
Jacques ROUSSEL
6a13c5d4e1 🔐(secret) add spaccoud age key
Add spaccoud age key
2024-08-09 13:12:13 +02:00
Anthony LC
c42fd9be9c 🌐(frontend) add translations
Add french translations.
2024-08-06 16:23:07 +02:00
Anthony LC
2b824c0862 ️(service-worker) manage document versioning
- Cache correctly the document version to work
in offline mode.
- Restoring a version is possible even if offline.
- Better sync triggering.
2024-08-06 16:23:07 +02:00
Anthony LC
fb494c8c71 ️(frontend) improve select share stability
- keep email in search input after unfocus
- keep search in memory after unfocus
- fixed width to reduce flickering
- empty states after validation
2024-08-06 16:23:07 +02:00
Anthony LC
8d5648005f 🎨(frontend) stop limit layout height to screen size
The app was limiting the layout height to the screen size,
which was a bit annoying when the content was
bigger than the screen.
We stop doing that, and now the layout
will grow as needed.
2024-08-06 12:29:06 +02:00
Anthony LC
713d9e48c8 🐛(y-webrtc) fix prob connection
Sometimes the connection was not established correctly,
because multiple connections were created at the
same time. This commit fixes this issue by ensuring
that only one connection is created at a time.
2024-08-06 09:28:12 +02:00
Anthony LC
bef541f956 🧑‍💻(y-webrtc) use correctly the conf nodemon
The conf was not used correctly, the nodemon.json
was not used.
This commit fix this issue.
2024-08-06 09:28:12 +02:00
Anthony LC
4b61ffce01 🗃️(frontend) replace main version per another version
We can now replace the main version by another version.
Usefull either to come back to a previous version
or to update the main version with a new one.
2024-08-06 09:28:12 +02:00
Anthony LC
a9383212a3 (frontend) feature versioning
We can now see the version of the document
and navigate to different versions.
Collaboration is possible per version.
2024-08-06 09:28:12 +02:00
Anthony LC
1ed20c3896 🌐(frontend) make empty doc grid message translatable
We personalize the empty doc grid message and
make it translatable.
2024-08-06 09:28:12 +02:00
Anthony LC
b5443676c1 ♻️(frontend) replace setEditor per setStore
setStore is more generic and can be used to set
any store.
2024-08-06 09:28:12 +02:00
Anthony LC
424c100eeb 👔(frontend) create versions api endpoints
Create versions api endpoints:
- Add useDocVersion hook - to retrieve a version
- Add useDocVersions hook - to list versions with
pagination

We add an helper to type more easily the react-query
hooks.
2024-08-06 09:28:12 +02:00
Anthony LC
91be4f5a21 👔(backend) add document version serializer
Add document version serializer to get the pagination
with the document version list.
2024-08-06 09:28:12 +02:00
Anthony LC
6eb0ac99e4 🎨(frontend) better conversion editor to pdf
A recent update from Blocknote provides us the
alignment, the color and the background color of
the different editor texts. We adapt our converter
to adapt these new features to the pdf.
2024-08-02 17:34:02 +02:00
renovate[bot]
e79a74083a ⬆️(dependencies) update sentry-sdk to v2 [SECURITY] 2024-08-02 10:21:13 +02:00
Anthony LC
905000550d ⬇️(frontend) downgrade @typescript-eslint/eslint-plugin to 7.13.1
@typescript-eslint/eslint-plugin released the
version 8, but it is causing some issues
(@typescript-eslint/no-duplicate-enum-values).
We downgrade it to 7.13.1 in waiting for a fix.
2024-08-01 10:52:01 +02:00
renovate[bot]
76d623a89b ⬆️(dependencies) update js dependencies 2024-08-01 10:52:01 +02:00
Anthony LC
61cf8aae74 🔖(minor) minor release to 1.1.0
Added:
- 🤡(demo) generate dummy documents on dev users
- (frontend) create side modal component
- (frontend) Doc grid actions (update / delete)
- (frontend) Doc editor header information

Changed:
- ♻️(frontend) replace docs panel with docs grid
- ♻️(frontend) create a doc from a modal
- ♻️(frontend) manage members from the share modal
2024-07-15 18:13:28 +02:00
renovate[bot]
ccadd9567a ⬆️(dependencies) update django to v5.0.7 [SECURITY] 2024-07-15 17:24:18 +02:00
Anthony LC
2c9758eb30 🌐(frontend) translate last features
Translate:
- doc datagrid
- share feature
- remove translations not used anymore
2024-07-15 17:01:09 +02:00
Anthony LC
0f71a3fcfa 💄(frontend) force light theme on BlockNote editor
The BlockNote editor theme was not being forced to
light mode. Depend the user theme preference,
the blocknote theme could be dark.
We force it to light mode.
2024-07-15 10:58:44 +02:00
Anthony LC
839b78a6d0 📱(frontend) improve responsive share modal
Improved the responsiveness of the share modal
on small devices.
2024-07-15 10:58:44 +02:00
Anthony LC
69f2641159 ♻️(frontend) list members from side modal
We refactorize the members grid to display
it inside the share side modal.
It is not a member grid anymore but a member list
with infinite scroll. We can directly update the
role or delete a member from the each row of the
list.
2024-07-12 15:41:32 +02:00
Anthony LC
e5de5a4345 💄(frontend) create IconBG component
Create IconBG component, a premade Icon component
with a background color.
2024-07-12 15:41:32 +02:00
Anthony LC
c2d6e60ae8 ♻️(frontend) add user from side modal
We move the add user functionality to a side modal.
The side modal is opened from the share button.
2024-07-12 15:41:32 +02:00
Anthony LC
ff832239d3 (frontend) add doc editor panel header
Add doc editor panel header to show
the doc title and other info.
2024-07-10 14:11:27 +02:00
Anthony LC
a60399883b 🏷️(frontend) improve props currentDocRole
props of currentDocRole is now more accurate.
2024-07-10 14:11:27 +02:00
Anthony LC
12d32fe933 🛂(frontend) don't show action buttons
Don't show actions buttons if the user
doesn't have permission to edit or delete
the document.
2024-07-10 14:11:27 +02:00
Anthony LC
038d868d29 (frontend) create hook useDate
Create hook useDate, it is an date helper.
We use it for now to format date.
2024-07-10 14:11:27 +02:00
Anthony LC
bfde526361 🐛(i18n) fix key that contain colon
Keys that contains colon where not being
translated correctly. This was due to the
colon being used as a separator for the
key and the value. This was fixed by
replacing the colon with a different
character that is not used in the key
or the value.
2024-07-10 14:11:27 +02:00
Anthony LC
132da0837f 🐛(e2e) sync doc grid sorting check
The sorting check was not the same between
what django provide and what the e2e test
check, it was giving some flakiness.
Django seems to ignore the punctuation (space)
in its sorting.
We improve other test to be more robust
as well.
2024-07-09 14:14:27 +02:00
Anthony LC
164ed885ca 🎨(frontend) improve interface SideModal
Improve the interface of the SideModal
component.
Set the width and the side by default.
2024-07-09 14:14:27 +02:00
Anthony LC
f5a35c66bb (frontend) add doc grid actions button
Add document action buttons to the document grid:
- update document
- delete document
2024-07-09 14:14:27 +02:00
Anthony LC
8100422776 📝(github-actions) rename workflows
Change name workflows.
2024-07-09 14:14:27 +02:00
Anthony LC
f48e385d3d 🛂(docker) add user minio
Add a user on Minio container in docker-compose.
2024-07-09 14:14:27 +02:00
Anthony LC
b5895bf297 ⬆️(frontend) fix blocknote conflicts
Different versions of blocknote
were installed, we pint the same version
in all packages.json.
2024-07-08 17:10:54 +02:00
Anthony LC
0fb0c71cfa ⬆️(mail) bump mail dependencies
Lot of dependencies were outdated, so
we updated them.
2024-07-08 17:10:54 +02:00
Anthony LC
a0fea64630 (frontend) create side modal component
Create SideModal component for
displaying modal on the side of the screen.
This component is build above the Cunningham
component, so all the cunnighama props are
still available.
2024-07-08 16:52:22 +02:00
Anthony LC
de922e1c04 ♻️(frontend) create a doc from a modal
We refacto the create doc feature to use a modal
instead of a page and a card component.
It is more consistent with the other features.
2024-07-08 15:04:35 +02:00
Anthony LC
8007c45a35 👔(service-worker) add new field to create doc
We now display the creation and modification date
of the document in the document grid, so when we
create a new document in offline mode we need to
set the dates as well.
2024-07-08 13:41:08 +02:00
Anthony LC
3d370e5714 💄(frontend) darken the background as the mockup
Darken the background as the mockup to make the
elements more visible.
2024-07-05 19:02:01 +02:00
Anthony LC
03953f0fe7 ️(frontend) use resetQueries instead of invalidateQueries
With the list of documents, invalidateQueries doesn't
refresh as expected the list of documents. We
will prefer resetQueries, it seems to be more
appropriate for this case.
2024-07-05 19:02:01 +02:00
Anthony LC
67f4ddeef7 (e2e) adapt e2e test without the panel
The datagrid replace the panel. Lot of tests
need to be adapted to this new architecture.
2024-07-05 19:02:01 +02:00
Anthony LC
6f4b4bb7d3 🔥(frontend) remove docs-panel feature
Remove docs-panel feature, we will replace it
with a datagrid.
2024-07-05 19:02:01 +02:00
Anthony LC
c27e6c1b06 (frontend) create docs-grid feature
Create docs-grid feature. It will be used to display
the list of documents in a grid view.
Grid view are more useful to display lot of
information, we can easily sort the information.
2024-07-05 19:02:01 +02:00
Anthony LC
d2888d0b9d (frontend) create hook useTransRole
Create the hook useTransRole to get a translated
role.
2024-07-05 19:02:01 +02:00
Anthony LC
6b8af1f9ec ♻️(backend) add more doc sorting
Update the viewset to be able to sort by:
- created date
- updated date
- title
2024-07-05 19:02:01 +02:00
Anthony LC
35852dff0b ♻️(backend) add more info to doc
Update the serializer to include more info
about the doc:
- created date
- updated date
2024-07-05 19:02:01 +02:00
Anthony LC
be93598b2d 🌱(demo) create dev users and make them doc accesses
To be able to test with dummy data, we need to create
our dev users from the demo and to give them access to
the docs.
The sub is the unicity of the user for our oidc provider,
so we need to know the sub to be able to create
correctly the user, it is why we set the sub
as the email of the user in the realm.json file.
2024-07-05 19:02:01 +02:00
Anthony LC
7d3fd25c61 🤡(demo) demo generate dummy documents
The demo command will generate dummy documents
and dummy accesses.
2024-07-05 19:02:01 +02:00
385 changed files with 21304 additions and 11953 deletions

View File

@@ -1,4 +1,5 @@
name: Docker Hub Workflow
run-name: Docker Hub Workflow
on:
workflow_dispatch:
@@ -48,9 +49,15 @@ jobs:
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '--target backend-production -f Dockerfile'
docker-image-name: 'docker.io/lasuite/impress-backend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
target: backend-production
@@ -92,9 +99,15 @@ jobs:
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
file: ./src/frontend/Dockerfile
@@ -104,7 +117,7 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-and-push-y-webrtc-signaling:
build-and-push-y-provider:
runs-on: ubuntu-latest
steps:
-
@@ -132,18 +145,24 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/impress-y-webrtc-signaling
images: lasuite/impress-y-provider
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f src/frontend/Dockerfile --target y-provider'
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
file: ./src/frontend/Dockerfile
target: y-webrtc-signaling
target: y-provider
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}

22
.github/workflows/helmfile-linter.yaml vendored Normal file
View File

@@ -0,0 +1,22 @@
name: Helmfile lint
run-name: Helmfile lint
on:
pull_request:
branches:
- 'main'
jobs:
helmfile-lint:
runs-on: ubuntu-latest
container:
image: ghcr.io/helmfile/helmfile:latest
steps:
-
uses: numerique-gouv/action-helmfile-lint@main
with:
app-id: ${{ secrets.APP_ID }}
age-key: ${{ secrets.SOPS_PRIVATE }}
private-key: ${{ secrets.PRIVATE_KEY }}
helmfile-src: "src/helm"
repositories: "impress,secrets"

View File

@@ -1,4 +1,4 @@
name: impress Workflow
name: Frontend Workflow
on:
push:
@@ -39,29 +39,6 @@ jobs:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
build-front:
runs-on: ubuntu-latest
needs: install-front
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Build CI App
run: cd src/frontend/ && yarn ci:build
- name: Cache build frontend
uses: actions/cache@v4
with:
path: src/frontend/apps/impress/out/
key: build-front-${{ github.run_id }}
test-front:
runs-on: ubuntu-latest
needs: install-front
@@ -96,27 +73,13 @@ jobs:
- name: Check linting
run: cd src/frontend/ && yarn lint
test-e2e:
test-e2e-chromium:
runs-on: ubuntu-latest
needs: build-front
timeout-minutes: 15
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set services env variables
run: |
make data/media
make create-env-files
cat env.d/development/common.e2e.dist >> env.d/development/common
- name: Restore the mail templates
uses: actions/cache@v4
id: mail-templates
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
@@ -124,48 +87,55 @@ jobs:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Restore the build cache
uses: actions/cache@v4
id: cache-build
with:
path: src/frontend/apps/impress/out/
key: build-front-${{ github.run_id }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build the Docker images
uses: docker/bake-action@v4
with:
targets: |
app-dev
y-webrtc-signaling
load: true
set: |
*.cache-from=type=gha,scope=cached-stage
*.cache-to=type=gha,scope=cached-stage,mode=max
- name: Set e2e env variables
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
- name: Start Docker services
run: |
make run
- name: Apply DRF migrations
run: |
make migrate
- name: Add dummy data
run: |
make demo FLUSH_ARGS='--no-input'
run: make bootstrap FLUSH_ARGS='--no-input' cache=
- name: Install Playwright Browsers
run: cd src/frontend/apps/e2e && yarn install
run: cd src/frontend/apps/e2e && yarn install-playwright chromium
- name: Run e2e tests
run: cd src/frontend/ && yarn e2e:test
run: cd src/frontend/ && yarn e2e:test --project='chromium'
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report
name: playwright-chromium-report
path: src/frontend/apps/e2e/report/
retention-days: 7
test-e2e-other-browser:
runs-on: ubuntu-latest
needs: test-e2e-chromium
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
with:
path: "src/frontend/**/node_modules"
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Set e2e env variables
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
- name: Start Docker services
run: make bootstrap FLUSH_ARGS='--no-input' cache=
- name: Install Playwright Browsers
run: cd src/frontend/apps/e2e && yarn install-playwright firefox webkit chromium
- name: Run e2e tests
run: cd src/frontend/ && yarn e2e:test --project=firefox --project=webkit
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-other-report
path: src/frontend/apps/e2e/report/
retention-days: 7

View File

@@ -1,4 +1,4 @@
name: impress Workflow
name: Main Workflow
on:
push:
@@ -168,7 +168,7 @@ jobs:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
- name: Start Minio
- name: Start MinIO
run: |
docker pull minio/minio
docker run -d --name minio \
@@ -178,6 +178,15 @@ jobs:
-v /data/media:/data \
minio/minio server --console-address :9001 /data
# 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 MinIO to be ready
run: |
dockerize -wait tcp://localhost:9000 -timeout 10s
- name: Configure MinIO
run: |
MINIO=$(docker ps | grep minio/minio | sed -E 's/.*\s+([a-zA-Z0-9_-]+)$/\1/')
@@ -198,75 +207,10 @@ jobs:
- name: Install gettext (required to compile messages)
run: |
sudo apt-get update
sudo apt-get install -y gettext
sudo apt-get install -y gettext pandoc
- name: Generate a MO file from strings extracted from the project
run: python manage.py compilemessages
- name: Run tests
run: ~/.local/bin/pytest -n 2
i18n-crowdin:
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: "infrastructure,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: Install gettext (required to make messages)
run: |
sudo apt-get update
sudo apt-get install -y gettext
- name: Install Python
uses: actions/setup-python@v3
with:
python-version: "3.10"
- name: Install development dependencies
working-directory: src/backend
run: pip install --user .[dev]
- name: Generate the translation base file
run: ~/.local/bin/django-admin makemessages --keep-pot --all
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "18.x"
cache: "yarn"
cache-dependency-path: src/frontend/yarn.lock
- name: Install dependencies
run: cd src/frontend/ && yarn install --frozen-lockfile
- name: Extract the frontend translation
run: make frontend-i18n-extract
- name: Upload files to Crowdin
run: |
docker run \
--rm \
-e CROWDIN_API_TOKEN=$CROWDIN_API_TOKEN \
-e CROWDIN_PROJECT_ID=$CROWDIN_PROJECT_ID \
-e CROWDIN_BASE_PATH=$CROWDIN_BASE_PATH \
-v "${{ github.workspace }}:/app" \
crowdin/cli:3.16.0 \
crowdin upload sources -c /app/crowdin/config.yml

4
.gitignore vendored
View File

@@ -33,7 +33,6 @@ MANIFEST
*.pot
# Environments
.env
.venv
env/
venv/
@@ -50,9 +49,6 @@ node_modules
# Mails
src/backend/core/templates/mail/
# Typescript client
src/frontend/tsclient
# Swagger
**/swagger.json

View File

@@ -4,6 +4,8 @@ creation_rules:
- age:
- age15fyxdwmg5mvldtqqus87xspuws2u0cpvwheehrtvkexj4tnsqqysw6re2x # jacques
- age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7 # github-repo
- age1qy04neuzwpasmvljqrcvhwnf0kz5cpyteze38c8avp0czewskasszv9pyw # argocd
- age1plkp8td6zzfcavjusmsfrlk54t9vn8jjxm8zaz7cmnr7kzl2nfnsd54hwg # Anthony Le-Courric
- age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3 # Antoine Lebaud
- age1hnhuzj96ktkhpyygvmz0x9h8mfvssz7ss6emmukags644mdhf4msajk93r # Samuel Paccoud

View File

@@ -6,8 +6,158 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0),
and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## Added
- ✨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
## Changed
- ♻️(frontend) More multi theme friendly #325
- ♻️ Bootstrap frontend #257
- ♻️ Add username in email #314
## Fixed
- 🐛(frontend) invalidate queries after removing user #336
- 🐛(backend) Fix dysfunctional permissions on document create #329
- 🐛(backend) fix nginx docker container #340
## [1.5.1] - 2024-10-10
## Fixed
- 🐛(db) fix users duplicate #316
## [1.5.0] - 2024-10-09
## Added
- ✨(backend) add name fields to the user synchronized with OIDC #301
- ✨(ci) add security scan #291
- ♻️(frontend) Add versions #277
- ✨(frontend) one-click document creation #275
- ✨(frontend) edit title inline #275
- 📱(frontend) mobile responsive #304
- 🌐(frontend) Update translation #308
## Changed
- 💄(frontend) error alert closeable on editor #284
- ♻️(backend) Change email content #283
- 🛂(frontend) viewers and editors can access share modal #302
- ♻️(frontend) remove footer on doc editor #313
## Fixed
- 🛂(frontend) match email if no existing user matches the sub
- 🐛(backend) gitlab oicd userinfo endpoint #232
- 🛂(frontend) redirect to the OIDC when private doc and unauthentified #292
- ♻️(backend) getting list of document versions available for a user #258
- 🔧(backend) fix configuration to avoid different ssl warning #297
- 🐛(frontend) fix editor break line not working #302
## [1.4.0] - 2024-09-17
## Added
- ✨Add link public/authenticated/restricted access with read/editor roles #234
- ✨(frontend) add copy link button #235
- 🛂(frontend) access public docs without being logged #235
## Changed
- ♻️(backend) Allow null titles on documents for easier creation #234
- 🛂(backend) stop to list public doc to everyone #234
- 🚚(frontend) change visibility in share modal #235
- ⚡️(frontend) Improve summary #244
## Fixed
- 🐛(backend) Fix forcing ID when creating a document via API endpoint #234
- 🐛 Rebuild frontend dev container from makefile #248
## [1.3.0] - 2024-09-05
## Added
- ✨Add image attachments with access control
- ✨(frontend) Upload image to a document #211
- ✨(frontend) Summary #223
- ✨(frontend) update meta title for docs page #231
## Changed
- 💄(frontend) code background darkened on editor #214
- 🔥(frontend) hide markdown button if not text #213
## Fixed
- 🐛 Fix emoticon in pdf export #225
- 🐛 Fix collaboration on document #226
- 🐛 (docker) Fix compatibility with mac #230
## Removed
- 🔥(frontend) remove saving modal #213
## [1.2.1] - 2024-08-23
## Changed
- ♻️ Change ordering docs datagrid #195
- 🔥(helm) use scaleway email #194
## [1.2.0] - 2024-08-22
## Added
- 🎨(frontend) better conversion editor to pdf #151
- ✨Export docx (word) #161
- 🌐Internationalize invitation email #167
- ✨(frontend) White branding #164
- ✨Email invitation when add user to doc #171
- ✨Invitation management #174
## Fixed
- 🐛(y-webrtc) fix prob connection #147
- ⚡️(frontend) improve select share stability #159
- 🐛(backend) enable SSL when sending email #165
## Changed
- 🎨(frontend) stop limit layout height to screen size #158
- ⚡️(CI) only e2e chrome mandatory #177
## Removed
- 🔥(helm) remove htaccess #181
## [1.1.0] - 2024-07-15
## Added
- 🤡(demo) generate dummy documents on dev users #120
- ✨(frontend) create side modal component #134
- ✨(frontend) Doc grid actions (update / delete) #136
- ✨(frontend) Doc editor header information #137
## Changed
- ♻️(frontend) replace docs panel with docs grid #120
- ♻️(frontend) create a doc from a modal #132
- ♻️(frontend) manage members from the share modal #140
## [1.0.0] - 2024-07-02
## Added
@@ -46,6 +196,7 @@ and this project adheres to
- 💚(CI) Remove trigger workflow on push tags on CI (#68)
- 🔥(frontend) Remove coming soon page (#121)
## [0.1.0] - 2024-05-24
## Added
@@ -54,6 +205,13 @@ and this project adheres to
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.0.0...main
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.5.1...main
[1.5.1]: https://github.com/numerique-gouv/impress/releases/v1.5.1
[1.5.0]: https://github.com/numerique-gouv/impress/releases/v1.5.0
[1.4.0]: https://github.com/numerique-gouv/impress/releases/v1.4.0
[1.3.0]: https://github.com/numerique-gouv/impress/releases/v1.3.0
[1.2.1]: https://github.com/numerique-gouv/impress/releases/v1.2.1
[1.2.0]: https://github.com/numerique-gouv/impress/releases/v1.2.0
[1.1.0]: https://github.com/numerique-gouv/impress/releases/v1.1.0
[1.0.0]: https://github.com/numerique-gouv/impress/releases/v1.0.0
[0.1.0]: https://github.com/numerique-gouv/impress/releases/v0.1.0
[0.1.0]: https://github.com/numerique-gouv/impress/releases/v0.1.0

View File

@@ -1,18 +1,17 @@
# Django impress
# ---- base image to inherit from ----
FROM python:3.10-slim-bullseye as base
FROM python:3.12.6-alpine3.20 AS base
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip
RUN python -m pip install --upgrade pip setuptools
# Upgrade system packages to install security updates
RUN apt-get update && \
apt-get -y upgrade && \
rm -rf /var/lib/apt/lists/*
RUN apk update && \
apk upgrade
# ---- Back-end builder image ----
FROM base as back-builder
FROM base AS back-builder
WORKDIR /builder
@@ -24,7 +23,7 @@ RUN mkdir /install && \
# ---- mails ----
FROM node:20 as mail-builder
FROM node:20 AS mail-builder
COPY ./src/mail /mail/app
@@ -35,15 +34,13 @@ RUN yarn install --frozen-lockfile && \
# ---- static link collector ----
FROM base as link-collector
FROM base AS link-collector
ARG IMPRESS_STATIC_ROOT=/data/static
# Install libpangocairo & rdfind
RUN apt-get update && \
apt-get install -y \
libpangocairo-1.0-0 \
rdfind && \
rm -rf /var/lib/apt/lists/*
# Install pango & rdfind
RUN apk add \
pango \
rdfind
# Copy installed python dependencies
COPY --from=back-builder /install /usr/local
@@ -62,21 +59,22 @@ RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
RUN rdfind -makesymlinks true -followsymlinks true -makeresultsfile false ${IMPRESS_STATIC_ROOT}
# ---- Core application image ----
FROM base as core
FROM base AS core
ENV PYTHONUNBUFFERED=1
# Install required system libs
RUN apt-get update && \
apt-get install -y \
gettext \
libcairo2 \
libffi-dev \
libgdk-pixbuf2.0-0 \
libpango-1.0-0 \
libpangocairo-1.0-0 \
shared-mime-info && \
rm -rf /var/lib/apt/lists/*
RUN apk add \
cairo \
file \
font-noto \
font-noto-emoji \
gettext \
gdk-pixbuf \
libffi-dev \
pandoc \
pango \
shared-mime-info
# Copy entrypoint
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
@@ -100,15 +98,13 @@ WORKDIR /app
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
# ---- Development image ----
FROM core as backend-development
FROM core AS backend-development
# Switch back to the root user to install development dependencies
USER root:root
# Install psql
RUN apt-get update && \
apt-get install -y postgresql-client && \
rm -rf /var/lib/apt/lists/*
RUN apk add postgresql-client
# Uninstall impress and re-install it in editable mode along with development
# dependencies
@@ -128,7 +124,7 @@ ENV DB_HOST=postgresql \
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
# ---- Production image ----
FROM core as backend-production
FROM core AS backend-production
ARG IMPRESS_STATIC_ROOT=/data/static

View File

@@ -49,7 +49,6 @@ WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT
# -- Backend
MANAGE = $(COMPOSE_RUN_APP) python manage.py
MAIL_YARN = $(COMPOSE_RUN) -w /app/src/mail node yarn
TSCLIENT_YARN = $(COMPOSE_RUN) -w /app/src/tsclient node yarn
# -- Frontend
PATH_FRONT = ./src/frontend
@@ -82,7 +81,7 @@ bootstrap: \
data/static \
create-env-files \
build \
run-frontend-dev \
run-with-frontend \
migrate \
demo \
back-i18n-compile \
@@ -91,10 +90,28 @@ bootstrap: \
.PHONY: bootstrap
# -- Docker/compose
build: ## build the app-dev container
@$(COMPOSE) build app-dev --no-cache
build: cache ?= --no-cache
build: ## build the project containers
@$(MAKE) build-backend cache=$(cache)
@$(MAKE) build-yjs-provider cache=$(cache)
@$(MAKE) build-frontend cache=$(cache)
.PHONY: build
build-backend: cache ?=
build-backend: ## build the app-dev container
@$(COMPOSE) build app-dev $(cache)
.PHONY: build-backend
build-yjs-provider: cache ?=
build-yjs-provider: ## build the y-provider container
@$(COMPOSE) build y-provider $(cache)
.PHONY: build-yjs-provider
build-frontend: cache ?=
build-frontend: ## build the frontend container
@$(COMPOSE) build frontend-dev $(cache)
.PHONY: build-frontend
down: ## stop and remove containers, networks, images, and volumes
@$(COMPOSE) down
.PHONY: down
@@ -105,11 +122,17 @@ 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 y-webrtc-signaling
@$(COMPOSE) up --force-recreate -d nginx
@$(COMPOSE) up --force-recreate -d y-provider
@echo "Wait for postgresql to be up..."
@$(WAIT_DB)
.PHONY: run
run-with-frontend: ## Start all the containers needed (backend to frontend)
@$(MAKE) run
@$(COMPOSE) up --force-recreate -d frontend-dev
.PHONY: run-with-frontend
status: ## an alias for "docker compose ps"
@$(COMPOSE) ps
.PHONY: status
@@ -186,7 +209,7 @@ back-i18n-compile: ## compile the gettext files
.PHONY: back-i18n-compile
back-i18n-generate: ## create the .pot files used for i18n
@$(MANAGE) makemessages -a --keep-pot
@$(MANAGE) makemessages -a --keep-pot --all
.PHONY: back-i18n-generate
shell: ## connect to database shell
@@ -275,16 +298,6 @@ mails-install: ## install the mail generator
@$(MAIL_YARN) install
.PHONY: mails-install
# -- TS client generator
tsclient-install: ## Install the Typescript API client generator
@$(TSCLIENT_YARN) install
.PHONY: tsclient-install
tsclient: tsclient-install ## Generate a Typescript API client
@$(TSCLIENT_YARN) generate:api:client:local ../frontend/tsclient
.PHONY: tsclient-install
# -- Misc
clean: ## restore repository state as it was freshly cloned
git clean -idx
@@ -296,10 +309,15 @@ help:
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(firstword $(MAKEFILE_LIST)) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "$(GREEN)%-30s$(RESET) %s\n", $$1, $$2}'
.PHONY: help
# Front
run-frontend-dev: ## Install and run the frontend dev
@$(COMPOSE) up --force-recreate -d frontend-dev
.PHONY: run-frontend-dev
# Front
frontend-install: ## install the frontend locally
cd $(PATH_FRONT_IMPRESS) && yarn
.PHONY: frontend-install
run-frontend-development: ## Run the frontend in development mode
@$(COMPOSE) stop frontend-dev
cd $(PATH_FRONT_IMPRESS) && yarn dev
.PHONY: run-frontend-development
frontend-i18n-extract: ## Extract the frontend translation inside a json to be used for crowdin
cd $(PATH_FRONT) && yarn i18n:extract
@@ -324,3 +342,13 @@ start-tilt: ## start the kubernetes cluster using kind
tilt up -f ./bin/Tiltfile
.PHONY: build-k8s-cluster
bump-packages-version: VERSION_TYPE ?= minor
bump-packages-version: ## bump the version of the project - VERSION_TYPE can be "major", "minor", "patch"
cd ./src/mail && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/apps/e2e/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/apps/impress/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/servers/y-provider/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/packages/eslint-config-impress/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/packages/i18n/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
.PHONY: bump-packages-version

View File

@@ -1,9 +1,15 @@
# Impress
Impress prints your markdown to pdf from predefined templates with user and role based access rights.
Impress is a web application for real-time collaborative text editing with user and role based access rights.
Features include :
- User authentication through OIDC
- BlocNote.js text editing experience (markdown support, dynamic conversion, block structure, slash commands for block creation)
- Document export to pdf and docx from predefined templates
- Granular document permissions
- Public link sharing
- Offline mode
Impress is built on top of [Django Rest
Framework](https://www.django-rest-framework.org/) and [Next.js](https://nextjs.org/).
Impress is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/) and [BlocNote.js](https://www.blocknotejs.org/)
## Getting started
@@ -31,14 +37,6 @@ The easiest way to start working on the project is to use GNU Make:
$ make bootstrap FLUSH_ARGS='--no-input'
```
Then you can access to the project in development mode by going to http://localhost:3000.
You will be prompted to log in, the default credentials are:
```bash
username: impress
password: impress
```
---
This command builds the `app` container, installs dependencies, performs
database migrations and compile translations. It's a good idea to use this
command each time you are pulling code from the project repository to avoid
@@ -46,12 +44,41 @@ dependency-releated or migration-releated issues.
Your Docker services should now be up and running 🎉
Note that if you need to run them afterwards, you can use the eponym Make rule:
You can access to the project by going to http://localhost:3000.
You will be prompted to log in, the default credentials are:
```bash
username: impress
password: impress
```
📝 Note that if you need to run them afterwards, you can use the eponym Make rule:
```bash
$ make run-frontend-dev
$ make run-with-frontend
```
---
⚠️ For the frontend developper, it is often better to run the frontend in development mode locally.
To do so, install the frontend dependencies with the following command:
```bash
$ make frontend-install
```
And run the frontend locally in development mode with the following command:
```bash
$ make run-frontend-development
```
To start all the services, except the frontend container, you can use the following command:
```bash
$ make run
```
---
### Adding content
You can create a basic demo site by running:

View File

@@ -18,13 +18,13 @@ docker_build(
)
docker_build(
'localhost:5001/impress-y-webrtc-signaling:latest',
'localhost:5001/impress-y-provider:latest',
context='..',
dockerfile='../src/frontend/Dockerfile',
only=['./src/frontend/', './docker/', './.dockerignore'],
target = 'y-webrtc-signaling',
target = 'y-provider',
live_update=[
sync('../src/frontend/apps/y-webrtc-signaling/src', '/home/frontend/apps/y-webrtc-signaling/src'),
sync('../src/frontend/servers/y-provider/src', '/home/frontend/servers/y-provider/src'),
]
)

0
scripts/install-hooks.sh → bin/install-hooks.sh Executable file → Normal file
View File

View File

@@ -16,7 +16,7 @@ reg_name='kind-registry'
reg_port='5001'
if [ "$(docker inspect -f '{{.State.Running}}' "${reg_name}" 2>/dev/null || true)" != 'true' ]; then
docker run \
-d --restart=always -p "127.0.0.1:${reg_port}:5000" --network bridge --name "${reg_name}" \
-d --restart=unless-stopped -p "127.0.0.1:${reg_port}:5000" --network bridge --name "${reg_name}" \
registry:2
fi

View File

View File

@@ -1,5 +1,3 @@
version: '3.8'
services:
postgresql:
image: postgres:16
@@ -65,7 +63,6 @@ services:
- mailcatcher
- redis
- createbuckets
- nginx
celery-dev:
user: ${DOCKER_USER:-1000}
@@ -120,6 +117,22 @@ services:
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
depends_on:
- keycloak
- app-dev
frontend-dev:
user: "${DOCKER_USER:-1000}"
build:
context: .
dockerfile: ./src/frontend/Dockerfile
target: frontend-production
args:
API_ORIGIN: "http://localhost:8071"
Y_PROVIDER_URL: "ws://localhost:4444"
MEDIA_URL: "http://localhost:8083"
SW_DEACTIVATED: "true"
image: impress:frontend-development
ports:
- "3000:3000"
dockerize:
image: jwilder/dockerize
@@ -141,38 +154,22 @@ services:
volumes:
- ".:/app"
y-webrtc-signaling:
y-provider:
user: ${DOCKER_USER:-1000}
build:
context: .
dockerfile: ./src/frontend/Dockerfile
target: y-webrtc-signaling
target: y-provider
restart: unless-stopped
ports:
- "4444:4444"
volumes:
- ./src/frontend/apps/y-webrtc-signaling:/home/frontend/apps/y-webrtc-signaling
- /home/frontend/apps/y-webrtc-signaling/node_modules/
- /home/frontend/apps/y-webrtc-signaling/dist/
frontend-dev:
user: "${DOCKER_USER:-1000}"
build:
context: .
dockerfile: ./src/frontend/Dockerfile
target: impress-dev
ports:
- "3000:3000"
volumes:
- ./src/frontend/apps/impress:/home/frontend/apps/impress
- /home/frontend/node_modules/
depends_on:
- y-webrtc-signaling
- celery-dev
- ./src/frontend/servers/y-provider:/home/frontend/servers/y-provider
- /home/frontend/servers/y-provider/node_modules/
- /home/frontend/servers/y-provider/dist/
kc_postgresql:
image: postgres:14.3
platform: linux/amd64
ports:
- "5433:5432"
env_file:

View File

@@ -1339,6 +1339,21 @@
"jsonType.label": "String"
}
},
{
"id": "qb109597-e31e-46d7-7844-62e5fcf32ac8",
"name": "email sub",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"user.attribute": "email",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "sub",
"jsonType.label": "String"
}
},
{
"id": "61c135e5-2447-494b-bc70-9612f383be27",
"name": "email verified",

View File

@@ -4,6 +4,36 @@ server {
server_name localhost;
charset utf-8;
location /media/ {
# Auth request configuration
auth_request /auth;
auth_request_set $authHeader $upstream_http_authorization;
auth_request_set $authDate $upstream_http_x_amz_date;
auth_request_set $authContentSha256 $upstream_http_x_amz_content_sha256;
# Pass specific headers from the auth response
proxy_set_header Authorization $authHeader;
proxy_set_header X-Amz-Date $authDate;
proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
# Get resource from Minio
proxy_pass http://minio:9000/impress-media-storage/;
proxy_set_header Host minio:9000;
}
location /auth {
proxy_pass http://app-dev:8000/api/v1.0/documents/retrieve-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 / {
proxy_pass http://keycloak:8080;
proxy_set_header Host $host;

72
docs/release.md Normal file
View File

@@ -0,0 +1,72 @@
# Releasing a new version
Whenever we are cooking a new release (e.g. `4.18.1`) we should follow a standard procedure described below:
1. Create a new branch named: `release/4.18.1`.
2. Bump the release number for backend project, frontend projects, and Helm files:
- for backend, update the version number by hand in `pyproject.toml`,
- for each projects (`src/frontend`, `src/frontend/apps/*`, `src/frontend/packages/*`, `src/mail`), run `yarn version --new-version --no-git-tag-version 4.18.1` in their directory. This will update their `package.json` for you,
- for Helm, update Docker image tag in files located at `src/helm/env.d` for both `preprod` and `production` environments:
```yaml
image:
repository: lasuite/impress-backend
pullPolicy: Always
tag: "v4.18.1" # Replace with your new version number, without forgetting the "v" prefix
...
frontend:
image:
repository: lasuite/impress-frontend
pullPolicy: Always
tag: "v4.18.1"
y-provider:
image:
repository: lasuite/impress-y-provider
pullPolicy: Always
tag: "v4.18.1"
```
The new images don't exist _yet_: they will be created automatically later in the process.
3. Update the project's `Changelog` following the [keepachangelog](https://keepachangelog.com/en/0.3.0/) recommendations
4. Commit your changes with the following format: the 🔖 release emoji, the type of release (patch/minor/patch) and the release version:
```text
🔖(minor) bump release to 4.18.0
```
5. Open a pull request, wait for an approval from your peers and merge it.
6. Checkout and pull changes from the `main` branch to ensure you have the latest updates.
7. Tag and push your commit:
```bash
git tag v4.18.1 && git push origin tag v4.18.1
```
Doing this triggers the CI and tells it to build the new Docker image versions that you targeted earlier in the Helm files.
8. Ensure the new [backend](https://hub.docker.com/r/lasuite/impress-frontend/tags) and [frontend](https://hub.docker.com/r/lasuite/impress-frontend/tags) image tags are on Docker Hub.
9. The release is now done!
# Deploying
> [!TIP]
> The `staging` platform is deployed automatically with every update of the `main` branch.
Making a new release doesn't publish it automatically in production.
Deployment is done by ArgoCD. ArgoCD checks for the `production` tag and automatically deploys the production platform with the targeted commit.
To publish, we mark the commit we want with the `production` tag. ArgoCD is then notified that the tag has changed. It then deploys the Docker image tags specified in the Helm files of the targeted commit.
To publish the release you just made:
```bash
git tag --force production v4.18.1
git push --force origin production
```

View File

@@ -1,25 +0,0 @@
# Api client TypeScript
The backend application can automatically create a TypeScript client to be used in frontend
applications. It is used in the impress front application itself.
This client is made with [openapi-typescript-codegen](https://github.com/ferdikoomen/openapi-typescript-codegen)
and impress's backend OpenAPI schema (available [here](http://localhost:8071/v1.0/swagger/) if you have the backend running).
## Requirements
We'll need the online OpenAPI schema generated by swagger. Therefore you will first need to
install the backend application.
## Install openApiClientJs
```sh
$ cd src/tsclient
$ yarn install
```
## Generate the client
```sh
yarn generate:api:client:local <output_path_for_generated_client>
```

View File

@@ -39,3 +39,8 @@ LOGOUT_REDIRECT_URL=http://localhost:3000
OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
# AI
AI_BASE_URL=https://openaiendpoint.com
AI_API_KEY=password
AI_MODEL=llama

View File

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

View File

@@ -1,3 +0,0 @@
#!/bin/bash
find . -name "*.enc.*" -exec sops updatekeys -y {} \;

Submodule secrets updated: 1485c6dc9d...59590c285c

View File

@@ -447,10 +447,10 @@ max-bool-expr=5
max-branches=12
# Maximum number of locals for function / method body
max-locals=15
max-locals=20
# Maximum number of parents for a class (see R0901).
max-parents=7
max-parents=10
# Maximum number of public methods for a class (see R0904).
max-public-methods=20

View File

@@ -1,4 +1,5 @@
"""Admin classes and registrations for core app."""
from django.contrib import admin
from django.contrib.auth import admin as auth_admin
from django.utils.translation import gettext_lazy as _
@@ -28,7 +29,19 @@ class UserAdmin(auth_admin.UserAdmin):
)
},
),
(_("Personal info"), {"fields": ("sub", "email", "language", "timezone")}),
(
_("Personal info"),
{
"fields": (
"sub",
"email",
"full_name",
"short_name",
"language",
"timezone",
)
},
),
(
_("Permissions"),
{
@@ -57,6 +70,7 @@ class UserAdmin(auth_admin.UserAdmin):
list_display = (
"id",
"sub",
"full_name",
"admin_email",
"email",
"is_active",
@@ -67,9 +81,24 @@ class UserAdmin(auth_admin.UserAdmin):
"updated_at",
)
list_filter = ("is_staff", "is_superuser", "is_device", "is_active")
ordering = ("is_active", "-is_superuser", "-is_staff", "-is_device", "-updated_at")
readonly_fields = ("id", "sub", "email", "created_at", "updated_at")
search_fields = ("id", "sub", "admin_email", "email")
ordering = (
"is_active",
"-is_superuser",
"-is_staff",
"-is_device",
"-updated_at",
"full_name",
)
readonly_fields = (
"id",
"sub",
"email",
"full_name",
"short_name",
"created_at",
"updated_at",
)
search_fields = ("id", "sub", "admin_email", "email", "full_name")
@admin.register(models.Template)
@@ -91,6 +120,14 @@ class DocumentAdmin(admin.ModelAdmin):
"""Document admin interface declaration."""
inlines = (DocumentAccessInline,)
list_display = (
"id",
"title",
"link_reach",
"link_role",
"created_at",
"updated_at",
)
@admin.register(models.Invitation)

View File

@@ -1,4 +1,5 @@
"""Impress core API endpoints"""
from django.conf import settings
from django.core.exceptions import ValidationError
@@ -7,6 +8,8 @@ from rest_framework import views as drf_views
from rest_framework.decorators import api_view
from rest_framework.response import Response
from ..models import Document, User, RoleChoices, DocumentAccess
def exception_handler(exc, context):
"""Handle Django ValidationError as an accepted exception.
@@ -16,9 +19,9 @@ def exception_handler(exc, context):
https://gist.github.com/twidi/9d55486c36b6a51bdcb05ce3a763e79f
"""
if isinstance(exc, ValidationError):
if hasattr(exc, "message_dict"):
detail = exc.message_dict
elif hasattr(exc, "message"):
detail = exc.message_dict
if hasattr(exc, "message"):
detail = exc.message
elif hasattr(exc, "messages"):
detail = exc.messages
@@ -37,3 +40,32 @@ def get_frontend_configuration(request):
}
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
return Response(frontend_configuration)
@api_view(["POST"])
def create_summary(request):
"""Wip."""
data = request.data
document = Document(
title="Votre résumé",
link_reach="authenticated",
link_role="reader",
)
document.save()
owner_user = User.objects.get(email=data["owner"])
document_access = DocumentAccess(
user=owner_user,
document=document,
role=RoleChoices.OWNER
)
document_access.save()
document.content = data["content"]
document.save()
return Response({"id": document.id})

View File

@@ -1,4 +1,5 @@
"""A JSONField for DRF to handle serialization/deserialization."""
import json
from rest_framework import serializers

View File

@@ -1,4 +1,5 @@
"""Permission handlers for the impress core app."""
from django.core import exceptions
from rest_framework import permissions
@@ -61,6 +62,9 @@ class IsOwnedOrPublic(IsAuthenticated):
class AccessPermission(permissions.BasePermission):
"""Permission class for access objects."""
def has_permission(self, request, view):
return request.user.is_authenticated or view.action != "create"
def has_object_permission(self, request, view, obj):
"""Check permission for a given object."""
abilities = obj.get_abilities(request.user)

View File

@@ -1,10 +1,16 @@
"""Client serializers for the impress core app."""
import mimetypes
from django.conf import settings
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
import magic
from rest_framework import exceptions, serializers
from core import models
from core import enums, models
from core.services.ai_services import AI_ACTIONS
class UserSerializer(serializers.ModelSerializer):
@@ -12,8 +18,8 @@ class UserSerializer(serializers.ModelSerializer):
class Meta:
model = models.User
fields = ["id", "email"]
read_only_fields = ["id", "email"]
fields = ["id", "email", "full_name", "short_name"]
read_only_fields = ["id", "email", "full_name", "short_name"]
class BaseAccessSerializer(serializers.ModelSerializer):
@@ -62,10 +68,10 @@ class BaseAccessSerializer(serializers.ModelSerializer):
"You must set a resource ID in kwargs to create a new access."
) from exc
teams = user.get_teams()
if not self.Meta.model.objects.filter( # pylint: disable=no-member
Q(user=user) | Q(team__in=teams),
Q(user=user) | Q(team__in=user.teams),
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
**{self.Meta.resource_field_name: resource_id}, # pylint: disable=no-member
).exists():
raise exceptions.PermissionDenied(
"You are not allowed to manage accesses for this resource."
@@ -74,7 +80,7 @@ class BaseAccessSerializer(serializers.ModelSerializer):
if (
role == models.RoleChoices.OWNER
and not self.Meta.model.objects.filter( # pylint: disable=no-member
Q(user=user) | Q(team__in=teams),
Q(user=user) | Q(team__in=user.teams),
role=models.RoleChoices.OWNER,
**{self.Meta.resource_field_name: resource_id}, # pylint: disable=no-member
).exists()
@@ -139,8 +145,117 @@ class DocumentSerializer(BaseResourceSerializer):
class Meta:
model = models.Document
fields = ["id", "content", "title", "accesses", "abilities", "is_public"]
read_only_fields = ["id", "accesses", "abilities"]
fields = [
"id",
"content",
"title",
"accesses",
"abilities",
"link_role",
"link_reach",
"created_at",
"updated_at",
]
read_only_fields = [
"id",
"accesses",
"abilities",
"link_role",
"link_reach",
"created_at",
"updated_at",
]
def get_fields(self):
"""Dynamically make `id` read-only on PUT requests but writable on POST requests."""
fields = super().get_fields()
request = self.context.get("request")
if request and request.method == "POST":
fields["id"].read_only = False
return fields
def validate_id(self, value):
"""Ensure the provided ID does not already exist when creating a new document."""
request = self.context.get("request")
# Only check this on POST (creation)
if request and request.method == "POST":
if models.Document.objects.filter(id=value).exists():
raise serializers.ValidationError(
"A document with this ID already exists. You cannot override it."
)
return value
class LinkDocumentSerializer(BaseResourceSerializer):
"""
Serialize link configuration for documents.
We expose it separately from document in order to simplify and secure access control.
"""
class Meta:
model = models.Document
fields = [
"link_role",
"link_reach",
]
# Suppress the warning about not implementing `create` and `update` methods
# since we don't use a model and only rely on the serializer for validation
# pylint: disable=abstract-method
class FileUploadSerializer(serializers.Serializer):
"""Receive file upload requests."""
file = serializers.FileField()
def validate_file(self, file):
"""Add file size and type constraints as defined in settings."""
# Validate file size
if file.size > settings.DOCUMENT_IMAGE_MAX_SIZE:
max_size = settings.DOCUMENT_IMAGE_MAX_SIZE // (1024 * 1024)
raise serializers.ValidationError(
f"File size exceeds the maximum limit of {max_size:d} MB."
)
extension = file.name.rpartition(".")[-1] if "." in file.name else None
# Read the first few bytes to determine the MIME type accurately
mime = magic.Magic(mime=True)
magic_mime_type = mime.from_buffer(file.read(1024))
file.seek(0) # Reset file pointer to the beginning after reading
self.context["is_unsafe"] = (
magic_mime_type in settings.DOCUMENT_UNSAFE_MIME_TYPES
)
extension_mime_type, _ = mimetypes.guess_type(file.name)
# Try guessing a coherent extension from the mimetype
if extension_mime_type != magic_mime_type:
self.context["is_unsafe"] = True
guessed_ext = mimetypes.guess_extension(magic_mime_type)
# Missing extensions or extensions longer than 5 characters (it's as long as an extension
# can be) are replaced by the extension we eventually guessed from mimetype.
if (extension is None or len(extension) > 5) and guessed_ext:
extension = guessed_ext[1:]
if extension is None:
raise serializers.ValidationError("Could not determine file extension.")
self.context["expected_extension"] = extension
return file
def validate(self, attrs):
"""Override validate to add the computed extension to validated_data."""
attrs["expected_extension"] = self.context["expected_extension"]
attrs["is_unsafe"] = self.context["is_unsafe"]
return attrs
class TemplateSerializer(BaseResourceSerializer):
@@ -171,6 +286,12 @@ class DocumentGenerationSerializer(serializers.Serializer):
required=False,
default="html",
)
format = serializers.ChoiceField(
choices=["pdf", "docx"],
label=_("Format"),
required=False,
default="pdf",
)
class InvitationSerializer(serializers.ModelSerializer):
@@ -225,9 +346,8 @@ class InvitationSerializer(serializers.ModelSerializer):
"Anonymous users are not allowed to create invitations."
)
teams = user.get_teams()
if not models.DocumentAccess.objects.filter(
Q(user=user) | Q(team__in=teams),
Q(user=user) | Q(team__in=user.teams),
document=document_id,
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
).exists():
@@ -238,7 +358,7 @@ class InvitationSerializer(serializers.ModelSerializer):
if (
role == models.RoleChoices.OWNER
and not models.DocumentAccess.objects.filter(
Q(user=user) | Q(team__in=teams),
Q(user=user) | Q(team__in=user.teams),
document=document_id,
role=models.RoleChoices.OWNER,
).exists()
@@ -250,3 +370,42 @@ class InvitationSerializer(serializers.ModelSerializer):
attrs["document_id"] = document_id
attrs["issuer"] = user
return attrs
class VersionFilterSerializer(serializers.Serializer):
"""Validate version filters applied to the list endpoint."""
version_id = serializers.CharField(required=False, allow_blank=True)
page_size = serializers.IntegerField(
required=False, min_value=1, max_value=50, default=20
)
class AITransformSerializer(serializers.Serializer):
"""Serializer for AI transform requests."""
action = serializers.ChoiceField(choices=AI_ACTIONS, required=True)
text = serializers.CharField(required=True)
def validate_text(self, value):
"""Ensure the text field is not empty."""
if len(value.strip()) == 0:
raise serializers.ValidationError("Text field cannot be empty.")
return value
class AITranslateSerializer(serializers.Serializer):
"""Serializer for AI translate requests."""
language = serializers.ChoiceField(
choices=tuple(enums.ALL_LANGUAGES.items()), required=True
)
text = serializers.CharField(required=True)
def validate_text(self, value):
"""Ensure the text field is not empty."""
if len(value.strip()) == 0:
raise serializers.ValidationError("Text field cannot be empty.")
return value

View File

@@ -0,0 +1,129 @@
"""Util to generate S3 authorization headers for object storage access control"""
import time
from abc import ABC, abstractmethod
from django.conf import settings
from django.core.cache import cache
from django.core.files.storage import default_storage
import botocore
from rest_framework.throttling import BaseThrottle
def generate_s3_authorization_headers(key):
"""
Generate authorization headers for an s3 object.
These headers can be used as an alternative to signed urls with many benefits:
- the urls of our files never expire and can be stored in our documents' content
- we don't leak authorized urls that could be shared (file access can only be done
with cookies)
- access control is truly realtime
- the object storage service does not need to be exposed on internet
"""
url = default_storage.unsigned_connection.meta.client.generate_presigned_url(
"get_object",
ExpiresIn=0,
Params={"Bucket": default_storage.bucket_name, "Key": key},
)
request = botocore.awsrequest.AWSRequest(method="get", url=url)
s3_client = default_storage.connection.meta.client
# pylint: disable=protected-access
credentials = s3_client._request_signer._credentials # noqa: SLF001
frozen_credentials = credentials.get_frozen_credentials()
region = s3_client.meta.region_name
auth = botocore.auth.S3SigV4Auth(frozen_credentials, "s3", region)
auth.add_auth(request)
return request
class AIBaseRateThrottle(BaseThrottle, ABC):
"""Base throttle class for AI-related rate limiting with backoff."""
def __init__(self, rates):
"""Initialize instance attributes with configurable rates."""
super().__init__()
self.rates = rates
self.cache_key = None
self.recent_requests_minute = 0
self.recent_requests_hour = 0
self.recent_requests_day = 0
@abstractmethod
def get_cache_key(self, request, view):
"""Abstract method to generate cache key for throttling."""
def allow_request(self, request, view):
"""Check if the request is allowed based on rate limits."""
self.cache_key = self.get_cache_key(request, view)
if not self.cache_key:
return True # Allow if no cache key is generated
now = time.time()
history = cache.get(self.cache_key, [])
# Keep requests within the last 24 hours
history = [req for req in history if req > now - 86400]
# Calculate recent requests
self.recent_requests_minute = len([req for req in history if req > now - 60])
self.recent_requests_hour = len([req for req in history if req > now - 3600])
self.recent_requests_day = len(history)
# Check rate limits
if self.recent_requests_minute >= self.rates["minute"]:
return False
if self.recent_requests_hour >= self.rates["hour"]:
return False
if self.recent_requests_day >= self.rates["day"]:
return False
# Log the request
history.append(now)
cache.set(self.cache_key, history, timeout=86400)
return True
def wait(self):
"""Implement a backoff strategy by increasing wait time based on limits hit."""
if self.recent_requests_day >= self.rates["day"]:
return 86400
if self.recent_requests_hour >= self.rates["hour"]:
return 3600
if self.recent_requests_minute >= self.rates["minute"]:
return 60
return None
class AIDocumentRateThrottle(AIBaseRateThrottle):
"""Throttle for limiting AI requests per document with backoff."""
def __init__(self, *args, **kwargs):
super().__init__(settings.AI_DOCUMENT_RATE_THROTTLE_RATES)
def get_cache_key(self, request, view):
"""Include document ID in the cache key."""
document_id = view.kwargs["pk"]
return f"document_{document_id}_throttle_ai"
class AIUserRateThrottle(AIBaseRateThrottle):
"""Throttle that limits requests per user or IP with backoff and rate limits."""
def __init__(self, *args, **kwargs):
super().__init__(settings.AI_USER_RATE_THROTTLE_RATES)
def get_cache_key(self, request, view=None):
"""Generate a cache key based on the user ID or IP for anonymous users."""
if request.user.is_authenticated:
return f"user_{request.user.id!s}_throttle_ai"
return f"anonymous_{self.get_ident(request)}_throttle_ai"
def get_ident(self, request):
"""Return the request IP address."""
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
return (
x_forwarded_for.split(",")[0]
if x_forwarded_for
else request.META.get("REMOTE_ADDR")
)

View File

@@ -1,19 +1,27 @@
"""API endpoints"""
from io import BytesIO
import re
import uuid
from urllib.parse import urlparse
from django.conf import settings
from django.contrib.postgres.aggregates import ArrayAgg
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.db.models import (
Min,
OuterRef,
Q,
Subquery,
)
from django.http import FileResponse, Http404
from django.http import Http404
from botocore.exceptions import ClientError
from rest_framework import (
decorators,
exceptions,
filters,
metadata,
mixins,
pagination,
status,
@@ -23,12 +31,25 @@ from rest_framework import (
response as drf_response,
)
from core import models
from core import enums, models
from core.services.ai_services import AIService
from . import permissions, serializers
from . import permissions, serializers, utils
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})$"
)
# pylint: disable=too-many-ancestors
ATTACHMENTS_FOLDER = "attachments"
class NestedGenericViewSet(viewsets.GenericViewSet):
"""
@@ -160,34 +181,27 @@ class ResourceViewsetMixin:
"""Mixin with methods common to all resource viewsets that are managed with accesses."""
filter_backends = [filters.OrderingFilter]
ordering_fields = ["created_at"]
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()
if not self.request.user.is_authenticated:
return queryset.filter(is_public=True)
user = self.request.user
teams = user.get_teams()
if not user.is_authenticated:
return queryset
user_roles_query = (
self.access_model_class.objects.filter(
Q(user=user) | Q(team__in=teams),
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.filter(
Q(accesses__user=user) | Q(accesses__team__in=teams) | Q(is_public=True)
)
.annotate(user_roles=Subquery(user_roles_query))
.distinct()
)
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."""
@@ -226,8 +240,7 @@ class ResourceAccessViewsetMixin:
if self.action == "list":
user = self.request.user
teams = user.get_teams()
teams = user.teams
user_roles_query = (
queryset.filter(
Q(user=user) | Q(team__in=teams),
@@ -265,7 +278,7 @@ class ResourceAccessViewsetMixin:
):
return drf_response.Response(
{"detail": "Cannot delete the last owner access for the resource."},
status=403,
status=status.HTTP_403_FORBIDDEN,
)
return super().destroy(request, *args, **kwargs)
@@ -291,38 +304,88 @@ class ResourceAccessViewsetMixin:
serializer.save()
class DocumentMetadata(metadata.SimpleMetadata):
"""Custom metadata class to add information"""
def determine_metadata(self, request, view):
"""Add language choices only for the list endpoint."""
simple_metadata = super().determine_metadata(request, view)
if request.path.endswith("/documents/"):
simple_metadata["actions"]["POST"]["language"] = {
"choices": [
{"value": code, "display_name": name}
for code, name in enums.ALL_LANGUAGES.items()
]
}
return simple_metadata
class DocumentViewSet(
ResourceViewsetMixin,
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""Document ViewSet"""
permission_classes = [
permissions.IsAuthenticatedOrSafe,
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
def perform_create(self, serializer):
"""
Override perform_create to use the provided ID in the payload if it exists
"""
document_id = self.request.data.get("id")
document = serializer.save(id=document_id) if document_id else serializer.save()
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)
| (
Q(link_traces__user=user)
& ~Q(link_reach=models.LinkReachChoices.RESTRICTED)
)
)
else:
queryset = queryset.none()
self.access_model_class.objects.create(
user=self.request.user,
role=models.RoleChoices.OWNER,
**{self.resource_field_name: document},
)
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return drf_response.Response(serializer.data)
def retrieve(self, request, *args, **kwargs):
"""
Add a trace that the document was accessed by a user. This is used to list documents
on a user's list view even though the user has no specific role in the document (link
access when the link reach configuration of the document allows it).
"""
instance = self.get_object()
serializer = self.get_serializer(instance)
if self.request.user.is_authenticated:
try:
# Add a trace that the user visited the document (this is needed to include
# the document in the user's list view)
models.LinkTrace.objects.create(
document=instance,
user=self.request.user,
)
except ValidationError:
# The trace already exists, so we just pass without doing anything
pass
return drf_response.Response(serializer.data)
@decorators.action(detail=True, methods=["get"], url_path="versions")
def versions_list(self, request, *args, **kwargs):
@@ -330,16 +393,36 @@ class DocumentViewSet(
Return the document's versions but only those created after the user got access
to the document
"""
user = request.user
if not user.is_authenticated:
raise exceptions.PermissionDenied("Authentication required.")
# Validate query parameters using dedicated serializer
serializer = serializers.VersionFilterSerializer(data=request.query_params)
serializer.is_valid(raise_exception=True)
document = self.get_object()
from_datetime = min(
access.created_at
for access in document.accesses.filter(
Q(user=request.user) | Q(team__in=request.user.get_teams()),
# 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"))
# Handle the case where the user has no accesses
min_datetime = access_queryset["min_date"]
if not min_datetime:
return exceptions.PermissionDenied(
"Only users with specific access can see version history"
)
versions_data = document.get_versions_slice(
from_version_id=serializer.validated_data.get("version_id"),
min_datetime=min_datetime,
page_size=serializer.validated_data.get("page_size"),
)
return drf_response.Response(
document.get_versions_slice(from_datetime=from_datetime)
)
return drf_response.Response(versions_data)
@decorators.action(
detail=True,
@@ -358,13 +441,14 @@ class DocumentViewSet(
# Don't let users access versions that were created before they were given access
# to the document
from_datetime = min(
user = request.user
min_datetime = min(
access.created_at
for access in document.accesses.filter(
Q(user=request.user) | Q(team__in=request.user.get_teams()),
Q(user=user) | Q(team__in=user.teams),
)
)
if response["LastModified"] < from_datetime:
if response["LastModified"] < min_datetime:
raise Http404
if request.method == "DELETE":
@@ -377,9 +461,153 @@ class DocumentViewSet(
{
"content": response["Body"].read().decode("utf-8"),
"last_modified": response["LastModified"],
"id": version_id,
}
)
@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
document = self.get_object()
# Deserialize and validate the data
serializer = serializers.LinkDocumentSerializer(
document, data=request.data, partial=True
)
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")
def attachment_upload(self, request, *args, **kwargs):
"""Upload a file related to a given document"""
# Check permissions first
document = self.get_object()
# Validate metadata in payload
serializer = serializers.FileUploadSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
# Generate a generic yet unique filename to store the image in object storage
file_id = uuid.uuid4()
extension = serializer.validated_data["expected_extension"]
key = f"{document.key_base}/{ATTACHMENTS_FOLDER:s}/{file_id!s}.{extension:s}"
# Prepare metadata for storage
extra_args = {"Metadata": {"owner": str(request.user.id)}}
if serializer.validated_data["is_unsafe"]:
extra_args["Metadata"]["is_unsafe"] = "true"
file = serializer.validated_data["file"]
default_storage.connection.meta.client.upload_fileobj(
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
)
@decorators.action(detail=False, methods=["get"], url_path="retrieve-auth")
def retrieve_auth(self, request, *args, **kwargs):
"""
This view is used by an Nginx subrequest to control access to a document's
attachment file.
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
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
is configured to do this.
Based on the original url and the logged in user, we must decide if we authorize Nginx
to let this request go through (by returning a 200 code) or if we block it (by returning
a 403 error). Note that we return 403 errors without any further details for security
reasons.
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)
try:
pk, attachment_key = match.groups()
except AttributeError as excpt:
raise exceptions.PermissionDenied() from excpt
# Check permission
try:
document = models.Document.objects.get(pk=pk)
except models.Document.DoesNotExist as excpt:
raise exceptions.PermissionDenied() from excpt
if not document.get_abilities(request.user).get("retrieve", False):
raise exceptions.PermissionDenied()
# 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)
@decorators.action(
detail=True,
methods=["post"],
name="Apply a transformation action on a piece of text with AI",
url_path="ai-transform",
throttle_classes=[utils.AIDocumentRateThrottle, utils.AIUserRateThrottle],
)
def ai_transform(self, request, *args, **kwargs):
"""
POST /api/v1.0/documents/<resource_id>/ai-transform
with expected data:
- text: str
- action: str [prompt, correct, rephrase, summarize]
Return JSON response with the processed text.
"""
# Check permissions first
self.get_object()
serializer = serializers.AITransformSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
text = serializer.validated_data["text"]
action = serializer.validated_data["action"]
response = AIService().transform(text, action)
return drf_response.Response(response, status=status.HTTP_200_OK)
@decorators.action(
detail=True,
methods=["post"],
name="Translate a piece of text with AI",
serializer_class=serializers.AITranslateSerializer,
url_path="ai-translate",
throttle_classes=[utils.AIDocumentRateThrottle, utils.AIUserRateThrottle],
)
def ai_translate(self, request, *args, **kwargs):
"""
POST /api/v1.0/documents/<resource_id>/ai-translate
with expected data:
- text: str
- language: str [settings.LANGUAGES]
Return JSON response with the translated text.
"""
# Check permissions first
self.get_object()
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
text = serializer.validated_data["text"]
language = serializer.validated_data["language"]
response = AIService().translate(text, language)
return drf_response.Response(response, status=status.HTTP_200_OK)
class DocumentAccessViewSet(
ResourceAccessViewsetMixin,
@@ -421,12 +649,23 @@ class DocumentAccessViewSet(
resource_field_name = "document"
serializer_class = serializers.DocumentAccessSerializer
def perform_create(self, serializer):
"""Add a new access to the document and send an email to the new added user."""
access = serializer.save()
language = self.request.headers.get("Content-Language", "en-us")
access.document.email_invitation(
language,
access.user.email,
access.role,
self.request.user,
)
class TemplateViewSet(
ResourceViewsetMixin,
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
@@ -442,6 +681,27 @@ class TemplateViewSet(
resource_field_name = "template"
queryset = models.Template.objects.all()
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)
)
else:
queryset = queryset.filter(is_public=True)
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return drf_response.Response(serializer.data)
@decorators.action(
detail=True,
methods=["post"],
@@ -451,7 +711,16 @@ class TemplateViewSet(
# pylint: disable=unused-argument
def generate_document(self, request, pk=None):
"""
Generate and return pdf for this template with the content passed.
Generate and return a document for this template around the
body passed as argument.
2 types of body are accepted:
- HTML: body_type = "html"
- Markdown: body_type = "markdown"
2 types of documents can be generated:
- PDF: format = "pdf"
- Docx: format = "docx"
"""
serializer = serializers.DocumentGenerationSerializer(data=request.data)
@@ -462,13 +731,10 @@ class TemplateViewSet(
body = serializer.validated_data["body"]
body_type = serializer.validated_data["body_type"]
export_format = serializer.validated_data["format"]
template = self.get_object()
pdf_content = template.generate_document(body, body_type)
response = FileResponse(BytesIO(pdf_content), content_type="application/pdf")
response["Content-Disposition"] = f"attachment; filename={template.title}.pdf"
return response
return template.generate_document(body, body_type, export_format)
class TemplateAccessViewSet(
@@ -517,6 +783,7 @@ class InvitationViewset(
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""API ViewSet for user invitations to document.
@@ -530,8 +797,9 @@ class InvitationViewset(
- role: str [administrator|editor|reader]
Return newly created invitation (issuer and document are automatically set)
PUT / PATCH : Not permitted. Instead of updating your invitation,
delete and create a new one.
PATCH /api/v1.0/documents/<document_id>/invitations/:<invitation_id>/ with expected data:
- role: str [owner|admin|editor|reader]
Return partially updated document invitation
DELETE /api/v1.0/documents/<document_id>/invitations/<invitation_id>/
Delete targeted invitation
@@ -560,7 +828,7 @@ class InvitationViewset(
if self.action == "list":
user = self.request.user
teams = user.get_teams()
teams = user.teams
# Determine which role the logged-in user has in the document
user_roles_query = (
@@ -585,3 +853,13 @@ class InvitationViewset(
.distinct()
)
return queryset
def perform_create(self, serializer):
"""Save invitation to a document then send an email to the invited user."""
invitation = serializer.save()
language = self.request.headers.get("Content-Language", "en-us")
invitation.document.email_invitation(
language, invitation.email, invitation.role, self.request.user
)

View File

@@ -1,5 +1,6 @@
"""Authentication Backends for the Impress core app."""
from django.conf import settings
from django.core.exceptions import SuspiciousOperation
from django.utils.translation import gettext_lazy as _
@@ -45,56 +46,75 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
proxies=self.get_settings("OIDC_PROXY", None),
)
user_response.raise_for_status()
userinfo = self.verify_token(user_response.text)
try:
userinfo = user_response.json()
except ValueError:
try:
userinfo = self.verify_token(user_response.text)
except Exception as e:
raise SuspiciousOperation(
_("Invalid response format or token verification failed")
) from e
return userinfo
def get_or_create_user(self, access_token, id_token, payload):
"""Return a User based on userinfo. Get or create a new user if no user matches the Sub.
Parameters:
- access_token (str): The access token.
- id_token (str): The ID token.
- payload (dict): The user payload.
Returns:
- User: An existing or newly created User instance.
Raises:
- Exception: Raised when user creation is not allowed and no existing user is found.
"""
"""Return a User based on userinfo. Create a new user if no match is found."""
user_info = self.get_userinfo(access_token, id_token, payload)
sub = user_info.get("sub")
email = user_info.get("email")
if sub is None:
# Get user's full name from OIDC fields defined in settings
full_name = self.compute_full_name(user_info)
short_name = user_info.get(settings.USER_OIDC_FIELD_TO_SHORTNAME)
claims = {
"email": email,
"full_name": full_name,
"short_name": short_name,
}
sub = user_info.get("sub")
if not sub:
raise SuspiciousOperation(
_("User info contained no recognizable user identification")
)
try:
user = User.objects.get(sub=sub)
except User.DoesNotExist:
if self.get_settings("OIDC_CREATE_USER", True):
user = self.create_user(user_info)
else:
user = None
user = self.get_existing_user(sub, email)
if user:
self.update_user_if_needed(user, claims)
elif self.get_settings("OIDC_CREATE_USER", True):
user = User.objects.create(sub=sub, password="!", **claims) # noqa: S106
return user
def create_user(self, claims):
"""Return a newly created User instance."""
sub = claims.get("sub")
if sub is None:
raise SuspiciousOperation(
_("Claims contained no recognizable user identification")
)
user = User.objects.create(
sub=sub,
email=claims.get("email"),
password="!", # noqa: S106
def compute_full_name(self, user_info):
"""Compute user's full name based on OIDC fields in settings."""
name_fields = settings.USER_OIDC_FIELDS_TO_FULLNAME
full_name = " ".join(
user_info[field] for field in name_fields if user_info.get(field)
)
return full_name or None
return user
def get_existing_user(self, sub, email):
"""Fetch existing user by sub or email."""
try:
return User.objects.get(sub=sub, is_active=True)
except User.DoesNotExist:
if email and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
try:
return User.objects.get(email=email, is_active=True)
except User.DoesNotExist:
pass
return None
def update_user_if_needed(self, user, claims):
"""Update user claims if they have changed."""
has_changed = any(
value and value != getattr(user, key) for key, value in claims.items()
)
if has_changed:
updated_claims = {key: value for key, value in claims.items() if value}
self.UserModel.objects.filter(sub=user.sub).update(**updated_claims)

View File

@@ -1,15 +1,12 @@
"""
Core application enums declaration
"""
from django.conf import global_settings, settings
from django.conf import global_settings
from django.utils.translation import gettext_lazy as _
# Django sets `LANGUAGES` by default with all supported languages. We can use it for
# the choice of languages which should not be limited to the few languages active in
# the app.
# In Django's code base, `LANGUAGES` is set by default with all supported languages.
# We can use it for the choice of languages which should not be limited to the few languages
# active in the app.
# pylint: disable=no-member
ALL_LANGUAGES = getattr(
settings,
"ALL_LANGUAGES",
[(language, _(name)) for language, name in global_settings.LANGUAGES],
)
ALL_LANGUAGES = {language: _(name) for language, name in global_settings.LANGUAGES}

View File

@@ -2,6 +2,7 @@
"""
Core application factories
"""
from django.conf import settings
from django.contrib.auth.hashers import make_password
@@ -21,9 +22,29 @@ class UserFactory(factory.django.DjangoModelFactory):
sub = factory.Sequence(lambda n: f"user{n!s}")
email = factory.Faker("email")
full_name = factory.Faker("name")
short_name = factory.Faker("first_name")
language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES])
password = make_password("password")
@factory.post_generation
def with_owned_document(self, create, extracted, **kwargs):
"""
Create a document for which the user is owner to check
that there is no interference
"""
if create and (extracted is True):
UserDocumentAccessFactory(user=self, role="owner")
@factory.post_generation
def with_owned_template(self, create, extracted, **kwargs):
"""
Create a template for which the user is owner to check
that there is no interference
"""
if create and (extracted is True):
UserTemplateAccessFactory(user=self, role="owner")
class DocumentFactory(factory.django.DjangoModelFactory):
"""A factory to create documents"""
@@ -34,8 +55,13 @@ class DocumentFactory(factory.django.DjangoModelFactory):
skip_postgeneration_save = True
title = factory.Sequence(lambda n: f"document{n}")
is_public = factory.Faker("boolean")
content = factory.Sequence(lambda n: f"content{n}")
link_reach = factory.fuzzy.FuzzyChoice(
[a[0] for a in models.LinkReachChoices.choices]
)
link_role = factory.fuzzy.FuzzyChoice(
[r[0] for r in models.LinkRoleChoices.choices]
)
@factory.post_generation
def users(self, create, extracted, **kwargs):
@@ -47,6 +73,13 @@ class DocumentFactory(factory.django.DjangoModelFactory):
else:
UserDocumentAccessFactory(document=self, user=item[0], role=item[1])
@factory.post_generation
def link_traces(self, create, extracted, **kwargs):
"""Add link traces to document from a given list of users."""
if create and extracted:
for item in extracted:
models.LinkTrace.objects.create(document=self, user=item)
class UserDocumentAccessFactory(factory.django.DjangoModelFactory):
"""Create fake document user accesses for testing."""

View File

@@ -0,0 +1,52 @@
# Generated by Django 5.1 on 2024-09-08 16:55
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0002_create_pg_trgm_extension'),
]
operations = [
migrations.AddField(
model_name='document',
name='link_reach',
field=models.CharField(choices=[('restricted', 'Restricted'), ('authenticated', 'Authenticated'), ('public', 'Public')], default='authenticated', max_length=20),
),
migrations.AddField(
model_name='document',
name='link_role',
field=models.CharField(choices=[('reader', 'Reader'), ('editor', 'Editor')], default='reader', max_length=20),
),
migrations.AlterField(
model_name='document',
name='is_public',
field=models.BooleanField(null=True),
),
migrations.AlterField(
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", 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='LinkTrace',
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='link_traces', to='core.document')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='link_traces', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Document/user link trace',
'verbose_name_plural': 'Document/user link traces',
'db_table': 'impress_link_trace',
'constraints': [models.UniqueConstraint(fields=('user', 'document'), name='unique_link_trace_document_user', violation_error_message='A link trace already exists for this document/user.')],
},
),
]

View File

@@ -0,0 +1,35 @@
# Generated by Django 5.1 on 2024-09-08 17:04
from django.db import migrations
def migrate_is_public_to_link_reach(apps, schema_editor):
"""
Forward migration: Migrate 'is_public' to 'link_reach'.
If is_public == True, set link_reach to 'public'
"""
Document = apps.get_model('core', 'Document')
Document.objects.filter(is_public=True).update(link_reach='public')
def reverse_migrate_link_reach_to_is_public(apps, schema_editor):
"""
Reverse migration: Migrate 'link_reach' back to 'is_public'.
- If link_reach == 'public', set is_public to True
- Else set is_public to False
"""
Document = apps.get_model('core', 'Document')
Document.objects.filter(link_reach='public').update(is_public=True)
Document.objects.filter(link_reach__in=['restricted', "authenticated"]).update(is_public=False)
class Migration(migrations.Migration):
dependencies = [
('core', '0003_document_link_reach_document_link_role_and_more'),
]
operations = [
migrations.RunPython(
migrate_is_public_to_link_reach,
reverse_migrate_link_reach_to_is_public
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1 on 2024-09-09 17:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0004_migrate_is_public_to_link_reach'),
]
operations = [
migrations.AlterField(
model_name='document',
name='title',
field=models.CharField(blank=True, max_length=255, null=True, verbose_name='title'),
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 5.1.1 on 2024-09-29 03:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0005_remove_document_is_public_alter_document_link_reach_and_more'),
]
operations = [
migrations.AddField(
model_name='user',
name='full_name',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='full name'),
),
migrations.AddField(
model_name='user',
name='short_name',
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='short name'),
),
migrations.AlterField(
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
]

View File

@@ -0,0 +1,128 @@
# Generated by Django 5.1.1 on 2024-10-10 11:45
from django.db import migrations
procedure = """
DO $$
DECLARE
user_email TEXT;
BEGIN
-- Step 1: Create a temporary table (without the unique constraint)
-- impress_document_access
DROP TABLE IF EXISTS impress_document_access_tmp;
CREATE TEMP TABLE impress_document_access_tmp AS
SELECT * FROM impress_document_access;
-- impress_link_trace
DROP TABLE IF EXISTS impress_link_trace_tmp;
CREATE TEMP TABLE impress_link_trace_tmp AS
SELECT * FROM impress_link_trace;
-- Step 2: Loop through each email that appears more than once
FOR user_email IN
SELECT email
FROM impress_user
GROUP BY email
HAVING COUNT(email) > 1
LOOP
-- Step 3: Update user_id in the temporary table based on email
-- For impress_document_access
UPDATE impress_document_access_tmp
SET user_id = (
SELECT id
FROM impress_user
WHERE email = user_email
LIMIT 1
)
WHERE user_id IN (
SELECT id
FROM impress_user
WHERE email = user_email
);
-- For impress_link_trace
UPDATE impress_link_trace_tmp
SET user_id = (
SELECT id
FROM impress_user
WHERE email = user_email
LIMIT 1
)
WHERE user_id IN (
SELECT id
FROM impress_user
WHERE email = user_email
);
-- update impress_invitation
UPDATE impress_invitation
SET issuer_id = (
SELECT id
FROM impress_user
WHERE email = user_email
LIMIT 1
)
WHERE issuer_id IN (
SELECT id
FROM impress_user
WHERE email = user_email
);
DELETE FROM impress_user
WHERE id IN (
SELECT id
FROM impress_user
WHERE email = user_email
)
AND id != (
SELECT id
FROM impress_user
WHERE email = user_email
LIMIT 1
);
RAISE NOTICE 'Processed updates for email: %', user_email;
END LOOP;
-- Step 4: Remove duplicate rows from the temporary table, keeping only one row per (document_id, user_id)
-- For impress_document_access
DELETE FROM impress_document_access_tmp a
USING impress_document_access_tmp b
WHERE a.ctid < b.ctid -- Keep one row
AND a.document_id = b.document_id
AND a.user_id = b.user_id;
-- Step 5: Replace the original table with the cleaned-up temporary table
TRUNCATE TABLE impress_document_access;
-- Insert cleaned-up data back into the original table
INSERT INTO impress_document_access
SELECT * FROM impress_document_access_tmp;
-- For impress_link_trace
DELETE FROM impress_link_trace_tmp a
USING impress_link_trace_tmp b
WHERE a.ctid < b.ctid -- Keep one row
AND a.document_id = b.document_id
AND a.user_id = b.user_id;
-- Step 5: Replace the original table with the cleaned-up temporary table
TRUNCATE TABLE impress_link_trace;
-- Insert cleaned-up data back into the original table
INSERT INTO impress_link_trace
SELECT * FROM impress_link_trace_tmp;
RAISE NOTICE 'Update and deduplication process completed.';
END $$;
"""
class Migration(migrations.Migration):
dependencies = [
('core', '0006_add_user_full_name_and_short_name'),
]
operations = [
migrations.RunSQL(procedure),
]

View File

@@ -1,11 +1,14 @@
"""
Declare and configure the models for the impress core application
"""
import hashlib
import smtplib
import tempfile
import textwrap
import uuid
from datetime import timedelta
from io import BytesIO
from logging import getLogger
from django.conf import settings
@@ -15,44 +18,53 @@ from django.contrib.sites.models import Site
from django.core import exceptions, mail, validators
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.core.mail import send_mail
from django.db import models
from django.http import FileResponse
from django.template.base import Template as DjangoTemplate
from django.template.context import Context
from django.template.loader import render_to_string
from django.utils import html, timezone
from django.utils.functional import lazy
from django.utils.functional import cached_property, lazy
from django.utils.translation import gettext_lazy as _
from django.utils.translation import override
import frontmatter
import markdown
import pypandoc
import weasyprint
from botocore.exceptions import ClientError
from timezone_field import TimeZoneField
from weasyprint import CSS, HTML
from weasyprint.text.fonts import FontConfiguration
logger = getLogger(__name__)
def get_resource_roles(resource, user):
"""Compute the roles a user has on a resource."""
roles = []
if user.is_authenticated:
if not user.is_authenticated:
return []
try:
roles = resource.user_roles or []
except AttributeError:
try:
roles = resource.user_roles or []
except AttributeError:
teams = user.get_teams()
try:
roles = resource.accesses.filter(
models.Q(user=user) | models.Q(team__in=teams),
).values_list("role", flat=True)
except (models.ObjectDoesNotExist, IndexError):
roles = []
roles = resource.accesses.filter(
models.Q(user=user) | models.Q(team__in=user.teams),
).values_list("role", flat=True)
except (models.ObjectDoesNotExist, IndexError):
roles = []
return roles
class LinkRoleChoices(models.TextChoices):
"""Defines the possible roles a link can offer on a document."""
READER = "reader", _("Reader") # Can read
EDITOR = "editor", _("Editor") # Can read and edit
class RoleChoices(models.TextChoices):
"""Defines the possible roles a user can have in a template."""
"""Defines the possible roles a user can have in a resource."""
READER = "reader", _("Reader") # Can read
EDITOR = "editor", _("Editor") # Can read and edit
@@ -60,6 +72,20 @@ class RoleChoices(models.TextChoices):
OWNER = "owner", _("Owner")
class LinkReachChoices(models.TextChoices):
"""Defines types of access for links"""
RESTRICTED = (
"restricted",
_("Restricted"),
) # Only users with a specific access can read/edit the document
AUTHENTICATED = (
"authenticated",
_("Authenticated"),
) # Any authenticated user can access the document
PUBLIC = "public", _("Public") # Even anonymous users can access the document
class BaseModel(models.Model):
"""
Serves as an abstract base model for other models, ensuring that records are validated
@@ -119,6 +145,10 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
blank=True,
null=True,
)
full_name = models.CharField(_("full name"), max_length=100, null=True, blank=True)
short_name = models.CharField(_("short name"), max_length=20, null=True, blank=True)
email = models.EmailField(_("identity email address"), blank=True, null=True)
# Unlike the "email" field which stores the email coming from the OIDC token, this field
@@ -214,7 +244,8 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
raise ValueError("User has no email address.")
mail.send_mail(subject, message, from_email, [self.email], **kwargs)
def get_teams(self):
@cached_property
def teams(self):
"""
Get list of teams in which the user is, as a list of strings.
Must be cached if retrieved remotely.
@@ -246,7 +277,7 @@ class BaseAccess(BaseModel):
"""
roles = []
if user.is_authenticated:
teams = user.get_teams()
teams = user.teams
try:
roles = self.user_roles or []
except AttributeError:
@@ -298,11 +329,14 @@ class BaseAccess(BaseModel):
class Document(BaseModel):
"""Pad document carrying the content."""
title = models.CharField(_("title"), max_length=255)
is_public = models.BooleanField(
_("public"),
default=False,
help_text=_("Whether this document is public for anyone to use."),
title = models.CharField(_("title"), max_length=255, null=True, blank=True)
link_reach = models.CharField(
max_length=20,
choices=LinkReachChoices.choices,
default=LinkReachChoices.AUTHENTICATED,
)
link_role = models.CharField(
max_length=20, choices=LinkRoleChoices.choices, default=LinkRoleChoices.READER
)
_content = None
@@ -314,7 +348,37 @@ class Document(BaseModel):
verbose_name_plural = _("Documents")
def __str__(self):
return self.title
return str(self.title) if self.title else str(_("Untitled Document"))
def save(self, *args, **kwargs):
"""Write content to object storage only if _content has changed."""
super().save(*args, **kwargs)
if self._content:
file_key = self.file_key
bytes_content = self._content.encode("utf-8")
# Attempt to directly check if the object exists using the storage client.
try:
response = default_storage.connection.meta.client.head_object(
Bucket=default_storage.bucket_name, Key=file_key
)
except ClientError as excpt:
# If the error is a 404, the object doesn't exist, so we should create it.
if excpt.response["Error"]["Code"] == "404":
has_changed = True
else:
raise
else:
# Compare the existing ETag with the MD5 hash of the new content.
has_changed = (
response["ETag"].strip('"')
!= hashlib.md5(bytes_content).hexdigest() # noqa: S324
)
if has_changed:
content_file = ContentFile(bytes_content)
default_storage.save(file_key, content_file)
@property
def key_base(self):
@@ -356,95 +420,62 @@ class Document(BaseModel):
Bucket=default_storage.bucket_name, Key=self.file_key, VersionId=version_id
)
def save(self, *args, **kwargs):
"""Write content to object storage only if _content has changed."""
super().save(*args, **kwargs)
if self._content:
file_key = self.file_key
bytes_content = self._content.encode("utf-8")
if default_storage.exists(file_key):
response = default_storage.connection.meta.client.head_object(
Bucket=default_storage.bucket_name, Key=file_key
)
has_changed = (
response["ETag"].strip('"')
!= hashlib.md5(bytes_content).hexdigest() # noqa
)
else:
has_changed = True
if has_changed:
content_file = ContentFile(bytes_content)
default_storage.save(file_key, content_file)
def get_versions_slice(
self, from_version_id="", from_datetime=None, page_size=None
):
def get_versions_slice(self, from_version_id="", min_datetime=None, page_size=None):
"""Get document versions from object storage with pagination and starting conditions"""
# /!\ Trick here /!\
# The "KeyMarker" and "VersionIdMarker" fields must either be both set or both not set.
# The error we get otherwise is not helpful at all.
token = {}
markers = {}
if from_version_id:
token.update(
markers.update(
{"KeyMarker": self.file_key, "VersionIdMarker": from_version_id}
)
if from_datetime:
response = default_storage.connection.meta.client.list_object_versions(
Bucket=default_storage.bucket_name,
Prefix=self.file_key,
MaxKeys=settings.S3_VERSIONS_PAGE_SIZE,
**token,
)
# Find the first version after the given datetime
version = None
for version in response.get("Versions", []):
if version["LastModified"] >= from_datetime:
token = {
"KeyMarker": self.file_key,
"VersionIdMarker": version["VersionId"],
}
break
else:
if version is None or version["LastModified"] < from_datetime:
if response["NextVersionIdMarker"]:
return self.get_versions_slice(
from_version_id=response["NextVersionIdMarker"],
page_size=settings.S3_VERSIONS_PAGE_SIZE,
from_datetime=from_datetime,
)
return {
"next_version_id_marker": "",
"is_truncated": False,
"versions": [],
}
real_page_size = (
min(page_size, settings.DOCUMENT_VERSIONS_PAGE_SIZE)
if page_size
else settings.DOCUMENT_VERSIONS_PAGE_SIZE
)
response = default_storage.connection.meta.client.list_object_versions(
Bucket=default_storage.bucket_name,
Prefix=self.file_key,
MaxKeys=min(page_size, settings.S3_VERSIONS_PAGE_SIZE)
if page_size
else settings.S3_VERSIONS_PAGE_SIZE,
**token,
# compensate the latest version that we exclude below and get one more to
# know if there are more pages
MaxKeys=real_page_size + 2,
**markers,
)
min_last_modified = min_datetime or self.created_at
versions = [
{
key_snake: version[key_camel]
for key_snake, key_camel in [
("etag", "ETag"),
("is_latest", "IsLatest"),
("last_modified", "LastModified"),
("version_id", "VersionId"),
]
}
for version in response.get("Versions", [])
if version["LastModified"] >= min_last_modified
and version["IsLatest"] is False
]
results = versions[:real_page_size]
count = len(results)
if count == len(versions):
is_truncated = False
next_version_id_marker = ""
else:
is_truncated = True
next_version_id_marker = versions[count - 1]["version_id"]
return {
"next_version_id_marker": response["NextVersionIdMarker"],
"is_truncated": response["IsTruncated"],
"versions": [
{
key_snake: version[key_camel]
for key_camel, key_snake in [
("ETag", "etag"),
("IsLatest", "is_latest"),
("LastModified", "last_modified"),
("VersionId", "version_id"),
]
}
for version in response.get("Versions", [])
],
"next_version_id_marker": next_version_id_marker,
"is_truncated": is_truncated,
"versions": results,
"count": count,
}
def delete_version(self, version_id):
@@ -457,25 +488,111 @@ class Document(BaseModel):
"""
Compute and return abilities for a given user on the document.
"""
roles = get_resource_roles(self, user)
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)
roles = set(get_resource_roles(self, user))
# 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)
# Add role provided by the document link
if self.link_reach == LinkReachChoices.PUBLIC or (
self.link_reach == LinkReachChoices.AUTHENTICATED and user.is_authenticated
):
roles.add(self.link_role)
is_owner_or_admin = bool(
roles.intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
is_editor = bool(RoleChoices.EDITOR in roles)
can_get = bool(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,
"destroy": RoleChoices.OWNER in roles,
"link_configuration": is_owner_or_admin,
"manage_accesses": is_owner_or_admin,
"partial_update": is_owner_or_admin or is_editor,
"retrieve": can_get,
"update": is_owner_or_admin or is_editor,
"versions_destroy": is_owner_or_admin,
"versions_list": can_get_versions,
"versions_retrieve": can_get_versions,
"manage_accesses": is_owner_or_admin,
"update": is_owner_or_admin or is_editor,
"partial_update": is_owner_or_admin or is_editor,
"retrieve": can_get,
}
def email_invitation(self, language, email, role, sender):
"""Send email invitation."""
sender_name = sender.full_name or sender.email
domain = Site.objects.get_current().domain
try:
with override(language):
title = _(
"%(sender_name)s shared a document with you: %(document)s"
) % {
"sender_name": sender_name,
"document": self.title,
}
template_vars = {
"title": title,
"domain": domain,
"document": self,
"link": f"{domain}/docs/{self.id}/",
"sender_name": sender_name,
"sender_name_email": f"{sender.full_name} ({sender.email})"
if sender.full_name
else sender.email,
"role": RoleChoices(role).label.lower(),
}
msg_html = render_to_string("mail/html/invitation.html", template_vars)
msg_plain = render_to_string("mail/text/invitation.txt", template_vars)
send_mail(
title,
msg_plain,
settings.EMAIL_FROM,
[email],
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as exception:
logger.error("invitation to %s was not sent: %s", email, exception)
class LinkTrace(BaseModel):
"""
Relation model to trace accesses to a document via a link by a logged-in user.
This is necessary to show the document in the user's list of documents even
though the user does not have a role on the document.
"""
document = models.ForeignKey(
Document,
on_delete=models.CASCADE,
related_name="link_traces",
)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="link_traces")
class Meta:
db_table = "impress_link_trace"
verbose_name = _("Document/user link trace")
verbose_name_plural = _("Document/user link traces")
constraints = [
models.UniqueConstraint(
fields=["user", "document"],
name="unique_link_trace_document_user",
violation_error_message=_(
"A link trace already exists for this document/user."
),
),
]
def __str__(self):
return f"{self.user!s} trace 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."""
@@ -564,10 +681,90 @@ class Template(BaseModel):
"retrieve": can_get,
}
def generate_document(self, body, body_type):
def generate_pdf(self, body_html, metadata):
"""
Generate and return a PDF document for this template around the
Generate and return a pdf document wrapped around the current template
"""
document_html = weasyprint.HTML(
string=DjangoTemplate(self.code).render(
Context({"body": html.format_html(body_html), **metadata})
)
)
css = weasyprint.CSS(
string=self.css,
font_config=weasyprint.text.fonts.FontConfiguration(),
)
pdf_content = document_html.write_pdf(stylesheets=[css], zoom=1)
response = FileResponse(BytesIO(pdf_content), content_type="application/pdf")
response["Content-Disposition"] = f"attachment; filename={self.title}.pdf"
return response
def generate_word(self, body_html, metadata):
"""
Generate and return a docx document wrapped around the current template
"""
template_string = DjangoTemplate(self.code).render(
Context({"body": html.format_html(body_html), **metadata})
)
html_string = f"""
<!DOCTYPE html>
<html>
<head>
<style>
{self.css}
</style>
</head>
<body>
{template_string}
</body>
</html>
"""
reference_docx = "core/static/reference.docx"
output = BytesIO()
# Convert the HTML to a temporary docx file
with tempfile.NamedTemporaryFile(suffix=".docx", prefix="docx_") as tmp_file:
output_path = tmp_file.name
pypandoc.convert_text(
html_string,
"docx",
format="html",
outputfile=output_path,
extra_args=["--reference-doc", reference_docx],
)
# Create a BytesIO object to store the output of the temporary docx file
with open(output_path, "rb") as f:
output = BytesIO(f.read())
# Ensure the pointer is at the beginning
output.seek(0)
response = FileResponse(
output,
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
response["Content-Disposition"] = f"attachment; filename={self.title}.docx"
return response
def generate_document(self, body, body_type, export_format):
"""
Generate and return a document for this template around the
body passed as argument.
2 types of body are accepted:
- HTML: body_type = "html"
- Markdown: body_type = "markdown"
2 types of documents can be generated:
- PDF: export_format = "pdf"
- Docx: export_format = "docx"
"""
document = frontmatter.loads(body)
metadata = document.metadata
@@ -580,16 +777,10 @@ class Template(BaseModel):
markdown.markdown(textwrap.dedent(strip_body)) if strip_body else ""
)
document_html = HTML(
string=DjangoTemplate(self.code).render(
Context({"body": html.format_html(body_html), **metadata})
)
)
css = CSS(
string=self.css,
font_config=FontConfiguration(),
)
return document_html.write_pdf(stylesheets=[css], zoom=1)
if export_format == "pdf":
return self.generate_pdf(body_html, metadata)
return self.generate_word(body_html, metadata)
class TemplateAccess(BaseAccess):
@@ -668,14 +859,6 @@ class Invitation(BaseModel):
def __str__(self):
return f"{self.email} invited to {self.document}"
def save(self, *args, **kwargs):
"""Make invitations read-only."""
if self.created_at:
raise exceptions.PermissionDenied()
super().save(*args, **kwargs)
self.email_invitation()
def clean(self):
"""Validate fields."""
super().clean()
@@ -698,10 +881,11 @@ 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:
teams = user.get_teams()
teams = user.teams
try:
roles = self.user_roles or []
except AttributeError:
@@ -716,29 +900,13 @@ class Invitation(BaseModel):
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
can_update = bool(
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
return {
"destroy": can_delete,
"update": False,
"partial_update": False,
"update": can_update,
"partial_update": can_update,
"retrieve": bool(roles),
}
def email_invitation(self):
"""Email invitation to the user."""
try:
with override(self.issuer.language):
title = _("Invitation to join Impress!")
template_vars = {"title": title, "site": Site.objects.get_current()}
msg_html = render_to_string("mail/html/invitation.html", template_vars)
msg_plain = render_to_string("mail/text/invitation.txt", template_vars)
mail.send_mail(
title,
msg_plain,
settings.EMAIL_FROM,
[self.email],
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as exception:
logger.error("invitation to %s was not sent: %s", self.email, exception)

View File

View File

@@ -0,0 +1,89 @@
"""AI services."""
import json
import re
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from openai import OpenAI
from core import enums
AI_ACTIONS = {
"prompt": (
"Answer the prompt in markdown format. Return JSON: "
'{"answer": "Your markdown answer"}. '
"Do not provide any other information."
),
"correct": (
"Correct grammar and spelling of the markdown text, "
"preserving language and markdown formatting. "
'Return JSON: {"answer": "your corrected markdown text"}. '
"Do not provide any other information."
),
"rephrase": (
"Rephrase the given markdown text, "
"preserving language and markdown formatting. "
'Return JSON: {"answer": "your rephrased markdown text"}. '
"Do not provide any other information."
),
"summarize": (
"Summarize the markdown text, preserving language and markdown formatting. "
'Return JSON: {"answer": "your markdown summary"}. '
"Do not provide any other information."
),
}
AI_TRANSLATE = (
"Translate the markdown text to {language:s}, preserving markdown formatting. "
'Return JSON: {{"answer": "your translated markdown text in {language:s}"}}. '
"Do not provide any other information."
)
class AIService:
"""Service class for AI-related operations."""
def __init__(self):
"""Ensure that the AI configuration is set properly."""
if (
settings.AI_BASE_URL is None
or settings.AI_API_KEY is None
or settings.AI_MODEL is None
):
raise ImproperlyConfigured("AI configuration not set")
self.client = OpenAI(base_url=settings.AI_BASE_URL, api_key=settings.AI_API_KEY)
def call_ai_api(self, system_content, text):
"""Helper method to call the OpenAI API and process the response."""
response = self.client.chat.completions.create(
model=settings.AI_MODEL,
response_format={"type": "json_object"},
messages=[
{"role": "system", "content": system_content},
{"role": "user", "content": json.dumps({"markdown_input": text})},
],
)
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)
if "answer" not in json_response:
raise RuntimeError("AI response does not contain an answer")
return json_response
def transform(self, text, action):
"""Transform text based on specified action."""
system_content = AI_ACTIONS[action]
return self.call_ai_api(system_content, text)
def translate(self, text, language):
"""Translate text to a specified language."""
language_display = enums.ALL_LANGUAGES.get(language, language)
system_content = AI_TRANSLATE.format(language=language_display)
return self.call_ai_api(system_content, text)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

View File

@@ -1,8 +1,12 @@
"""Unit tests for the Authentication Backends."""
import re
from django.core.exceptions import SuspiciousOperation
from django.test.utils import override_settings
import pytest
import responses
from core import models
from core.authentication.backends import OIDCAuthenticationBackend
@@ -34,6 +38,130 @@ def test_authentication_getter_existing_user_no_email(
assert user == db_user
def test_authentication_getter_existing_user_via_email(
django_assert_num_queries, monkeypatch
):
"""
If an existing user doesn't match the sub but matches the email,
the user should be returned.
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory()
def get_userinfo_mocked(*args):
return {"sub": "123", "email": db_user.email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(2):
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == db_user
def test_authentication_getter_existing_user_no_fallback_to_email(
settings, monkeypatch
):
"""
When the "OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION" setting is set to False,
the system should not match users by email, even if the email matches.
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory()
# Set the setting to False
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
def get_userinfo_mocked(*args):
return {"sub": "123", "email": db_user.email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
# Since the sub doesn't match, it should create a new user
assert models.User.objects.count() == 2
assert user != db_user
assert user.sub == "123"
def test_authentication_getter_existing_user_with_email(
django_assert_num_queries, monkeypatch
):
"""
When the user's info contains an email and targets an existing user,
"""
klass = OIDCAuthenticationBackend()
user = UserFactory(full_name="John Doe", short_name="John")
def get_userinfo_mocked(*args):
return {
"sub": user.sub,
"email": user.email,
"first_name": "John",
"last_name": "Doe",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# Only 1 query because email and names have not changed
with django_assert_num_queries(1):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == authenticated_user
@pytest.mark.parametrize(
"first_name, last_name, email",
[
("Jack", "Doe", "john.doe@example.com"),
("John", "Duy", "john.doe@example.com"),
("John", "Doe", "jack.duy@example.com"),
("Jack", "Duy", "jack.duy@example.com"),
],
)
def test_authentication_getter_existing_user_change_fields(
first_name, last_name, email, django_assert_num_queries, monkeypatch
):
"""
It should update the email or name fields on the user when they change.
"""
klass = OIDCAuthenticationBackend()
user = UserFactory(
full_name="John Doe", short_name="John", email="john.doe@example.com"
)
def get_userinfo_mocked(*args):
return {
"sub": user.sub,
"email": email,
"first_name": first_name,
"last_name": last_name,
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed
with django_assert_num_queries(2):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == authenticated_user
user.refresh_from_db()
assert user.email == email
assert user.full_name == f"{first_name:s} {last_name:s}"
assert user.short_name == first_name
def test_authentication_getter_new_user_no_email(monkeypatch):
"""
If no user matches the user's info sub, a user should be created.
@@ -52,6 +180,8 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
assert user.sub == "123"
assert user.email is None
assert user.full_name is None
assert user.short_name is None
assert user.password == "!"
assert models.User.objects.count() == 1
@@ -77,11 +207,13 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
assert user.sub == "123"
assert user.email == email
assert user.full_name == "John Doe"
assert user.short_name == "John"
assert user.password == "!"
assert models.User.objects.count() == 1
def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkeypatch):
def test_authentication_getter_invalid_token(django_assert_num_queries, monkeypatch):
"""The user's info doesn't contain a sub."""
klass = OIDCAuthenticationBackend()
@@ -92,10 +224,84 @@ def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkey
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(0), pytest.raises(
SuspiciousOperation,
match="User info contained no recognizable user identification",
with (
django_assert_num_queries(0),
pytest.raises(
SuspiciousOperation,
match="User info contained no recognizable user identification",
),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.exists() is False
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
@responses.activate
def test_authentication_get_userinfo_json_response():
"""Test get_userinfo method with a JSON response."""
responses.add(
responses.GET,
re.compile(r".*/userinfo"),
json={
"first_name": "John",
"last_name": "Doe",
"email": "john.doe@example.com",
},
status=200,
)
oidc_backend = OIDCAuthenticationBackend()
result = oidc_backend.get_userinfo("fake_access_token", None, None)
assert result["first_name"] == "John"
assert result["last_name"] == "Doe"
assert result["email"] == "john.doe@example.com"
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
@responses.activate
def test_authentication_get_userinfo_token_response(monkeypatch):
"""Test get_userinfo method with a token response."""
responses.add(
responses.GET, re.compile(r".*/userinfo"), body="fake.jwt.token", status=200
)
def mock_verify_token(self, token): # pylint: disable=unused-argument
return {
"first_name": "Jane",
"last_name": "Doe",
"email": "jane.doe@example.com",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "verify_token", mock_verify_token)
oidc_backend = OIDCAuthenticationBackend()
result = oidc_backend.get_userinfo("fake_access_token", None, None)
assert result["first_name"] == "Jane"
assert result["last_name"] == "Doe"
assert result["email"] == "jane.doe@example.com"
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
@responses.activate
def test_authentication_get_userinfo_invalid_response():
"""
Test get_userinfo method with an invalid JWT response that
causes verify_token to raise an error.
"""
responses.add(
responses.GET, re.compile(r".*/userinfo"), body="fake.jwt.token", status=200
)
oidc_backend = OIDCAuthenticationBackend()
with pytest.raises(
SuspiciousOperation,
match="Invalid response format or token verification failed",
):
oidc_backend.get_userinfo("fake_access_token", None, None)

View File

@@ -1,4 +1,5 @@
"""Fixtures for tests in the impress core application"""
from unittest import mock
import pytest
@@ -9,7 +10,9 @@ VIA = [USER, TEAM]
@pytest.fixture
def mock_user_get_teams():
"""Mock for the "get_teams" method on the User model."""
with mock.patch("core.models.User.get_teams") as mock_get_teams:
yield mock_get_teams
def mock_user_teams():
"""Mock for the "teams" property on the User model."""
with mock.patch(
"core.models.User.teams", new_callable=mock.PropertyMock
) as mock_teams:
yield mock_teams

View File

@@ -1,6 +1,7 @@
"""
Test document accesses API endpoints for users in impress's core app.
"""
import random
from uuid import uuid4
@@ -56,7 +57,7 @@ def test_api_document_accesses_list_authenticated_unrelated():
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_list_authenticated_related(via, mock_user_get_teams):
def test_api_document_accesses_list_authenticated_related(via, mock_user_teams):
"""
Authenticated users should be able to list document accesses for a document
to which they are directly related, whatever their role in the document.
@@ -67,6 +68,7 @@ def test_api_document_accesses_list_authenticated_related(via, mock_user_get_tea
client.force_login(user)
document = factories.DocumentFactory()
user_access = None
if via == USER:
user_access = models.DocumentAccess.objects.create(
document=document,
@@ -74,7 +76,7 @@ def test_api_document_accesses_list_authenticated_related(via, mock_user_get_tea
role=random.choice(models.RoleChoices.choices)[0],
)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
user_access = models.DocumentAccess.objects.create(
document=document,
team="lasuite",
@@ -147,7 +149,7 @@ def test_api_document_accesses_retrieve_authenticated_unrelated():
Authenticated users should not be allowed to retrieve a document access for
a document to which they are not related.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -173,11 +175,13 @@ def test_api_document_accesses_retrieve_authenticated_unrelated():
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
assert response.json() == {
"detail": "No DocumentAccess matches the given query."
}
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_retrieve_authenticated_related(via, mock_user_get_teams):
def test_api_document_accesses_retrieve_authenticated_related(via, mock_user_teams):
"""
A user who is related to a document should be allowed to retrieve the
associated document user accesses.
@@ -191,7 +195,7 @@ def test_api_document_accesses_retrieve_authenticated_related(via, mock_user_get
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
access = factories.UserDocumentAccessFactory(document=document)
@@ -212,203 +216,6 @@ def test_api_document_accesses_retrieve_authenticated_related(via, mock_user_get
}
def test_api_document_accesses_create_anonymous():
"""Anonymous users should not be allowed to create document accesses."""
user = factories.UserFactory()
document = factories.DocumentFactory()
response = APIClient().post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user": str(user.id),
"document": str(document.id),
"role": random.choice(models.RoleChoices.choices)[0],
},
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert models.DocumentAccess.objects.exists() is False
def test_api_document_accesses_create_authenticated_unrelated():
"""
Authenticated users should not be allowed to create document accesses for a document to
which they are not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
document = factories.DocumentFactory()
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user": str(other_user.id),
},
format="json",
)
assert response.status_code == 403
assert not models.DocumentAccess.objects.filter(user=other_user).exists()
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_create_authenticated_reader_or_editor(
via, role, mock_user_get_teams
):
"""Readers or editors of a document should not be allowed to create document accesses."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
other_user = factories.UserFactory()
for new_role in [role[0] for role in models.RoleChoices.choices]:
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user": str(other_user.id),
"role": new_role,
},
format="json",
)
assert response.status_code == 403
assert not models.DocumentAccess.objects.filter(user=other_user).exists()
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_create_authenticated_administrator(
via, mock_user_get_teams
):
"""
Administrators of a document should be able to create document accesses
except for the "owner" role.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(
document=document, user=user, role="administrator"
)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="administrator"
)
other_user = factories.UserFactory()
# It should not be allowed to create an owner access
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user": str(other_user.id),
"role": "owner",
},
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "Only owners of a resource can assign other users as owners."
}
# It should be allowed to create a lower access
role = random.choice(
[role[0] for role in models.RoleChoices.choices if role[0] != "owner"]
)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user_id": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.DocumentAccess.objects.filter(user=other_user).count() == 1
new_document_access = models.DocumentAccess.objects.filter(user=other_user).get()
other_user = serializers.UserSerializer(instance=other_user).data
assert response.json() == {
"abilities": new_document_access.get_abilities(user),
"id": str(new_document_access.id),
"team": "",
"role": role,
"user": other_user,
}
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_create_authenticated_owner(via, mock_user_get_teams):
"""
Owners of a document should be able to create document accesses whatever the role.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="owner"
)
other_user = factories.UserFactory()
role = random.choice([role[0] for role in models.RoleChoices.choices])
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user_id": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.DocumentAccess.objects.filter(user=other_user).count() == 1
new_document_access = models.DocumentAccess.objects.filter(user=other_user).get()
other_user = serializers.UserSerializer(instance=other_user).data
assert response.json() == {
"id": str(new_document_access.id),
"user": other_user,
"team": "",
"role": role,
"abilities": new_document_access.get_abilities(user),
}
def test_api_document_accesses_update_anonymous():
"""Anonymous users should not be allowed to update a document access."""
access = factories.UserDocumentAccessFactory()
@@ -439,7 +246,7 @@ def test_api_document_accesses_update_authenticated_unrelated():
Authenticated users should not be allowed to update a document access for a document to which
they are not related.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -469,10 +276,10 @@ def test_api_document_accesses_update_authenticated_unrelated():
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_update_authenticated_reader_or_editor(
via, role, mock_user_get_teams
via, role, mock_user_teams
):
"""Readers or editors of a document should not be allowed to update its accesses."""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -481,7 +288,7 @@ def test_api_document_accesses_update_authenticated_reader_or_editor(
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
@@ -509,14 +316,12 @@ 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_get_teams
):
def test_api_document_accesses_update_administrator_except_owner(via, mock_user_teams):
"""
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.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -527,7 +332,7 @@ def test_api_document_accesses_update_administrator_except_owner(
document=document, user=user, role="administrator"
)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="administrator"
)
@@ -568,14 +373,12 @@ def test_api_document_accesses_update_administrator_except_owner(
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_update_administrator_from_owner(
via, mock_user_get_teams
):
def test_api_document_accesses_update_administrator_from_owner(via, mock_user_teams):
"""
A user who is an administrator in a document, should not be allowed to update
the user access of an "owner" for this document.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -586,7 +389,7 @@ def test_api_document_accesses_update_administrator_from_owner(
document=document, user=user, role="administrator"
)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="administrator"
)
@@ -617,12 +420,12 @@ def test_api_document_accesses_update_administrator_from_owner(
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_update_administrator_to_owner(via, mock_user_get_teams):
def test_api_document_accesses_update_administrator_to_owner(via, mock_user_teams):
"""
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.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -633,7 +436,7 @@ def test_api_document_accesses_update_administrator_to_owner(via, mock_user_get_
document=document, user=user, role="administrator"
)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="administrator"
)
@@ -671,12 +474,12 @@ def test_api_document_accesses_update_administrator_to_owner(via, mock_user_get_
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_update_owner(via, mock_user_get_teams):
def test_api_document_accesses_update_owner(via, mock_user_teams):
"""
A user who is an owner in a document should be allowed to update
a user access for this document whatever the role.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -685,7 +488,7 @@ def test_api_document_accesses_update_owner(via, mock_user_get_teams):
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="owner"
)
@@ -727,23 +530,24 @@ def test_api_document_accesses_update_owner(via, mock_user_get_teams):
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_update_owner_self(via, mock_user_get_teams):
def test_api_document_accesses_update_owner_self(via, mock_user_teams):
"""
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.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
access = None
if via == USER:
access = factories.UserDocumentAccessFactory(
document=document, user=user, role="owner"
)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
access = factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="owner"
)
@@ -801,7 +605,7 @@ def test_api_document_accesses_delete_authenticated():
Authenticated users should not be allowed to delete a document access for a
document to which they are not related.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -813,17 +617,17 @@ def test_api_document_accesses_delete_authenticated():
)
assert response.status_code == 403
assert models.DocumentAccess.objects.count() == 1
assert models.DocumentAccess.objects.count() == 2
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_delete_reader_or_editor(via, role, mock_user_get_teams):
def test_api_document_accesses_delete_reader_or_editor(via, role, mock_user_teams):
"""
Authenticated users should not be allowed to delete a document access for a
document in which they are a simple reader or editor.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -832,14 +636,14 @@ def test_api_document_accesses_delete_reader_or_editor(via, role, mock_user_get_
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
access = factories.UserDocumentAccessFactory(document=document)
assert models.DocumentAccess.objects.count() == 2
assert models.DocumentAccess.objects.count() == 3
assert models.DocumentAccess.objects.filter(user=access.user).exists()
response = client.delete(
@@ -847,12 +651,12 @@ def test_api_document_accesses_delete_reader_or_editor(via, role, mock_user_get_
)
assert response.status_code == 403
assert models.DocumentAccess.objects.count() == 2
assert models.DocumentAccess.objects.count() == 3
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_delete_administrators_except_owners(
via, mock_user_get_teams
via, mock_user_teams
):
"""
Users who are administrators in a document should be allowed to delete an access
@@ -869,7 +673,7 @@ def test_api_document_accesses_delete_administrators_except_owners(
document=document, user=user, role="administrator"
)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="administrator"
)
@@ -890,12 +694,12 @@ def test_api_document_accesses_delete_administrators_except_owners(
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_delete_administrator_on_owners(via, mock_user_get_teams):
def test_api_document_accesses_delete_administrator_on_owners(via, mock_user_teams):
"""
Users who are administrators in a document should not be allowed to delete an ownership
access from the document.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -906,14 +710,14 @@ def test_api_document_accesses_delete_administrator_on_owners(via, mock_user_get
document=document, user=user, role="administrator"
)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="administrator"
)
access = factories.UserDocumentAccessFactory(document=document, role="owner")
assert models.DocumentAccess.objects.count() == 2
assert models.DocumentAccess.objects.count() == 3
assert models.DocumentAccess.objects.filter(user=access.user).exists()
response = client.delete(
@@ -921,11 +725,11 @@ def test_api_document_accesses_delete_administrator_on_owners(via, mock_user_get
)
assert response.status_code == 403
assert models.DocumentAccess.objects.count() == 2
assert models.DocumentAccess.objects.count() == 3
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_delete_owners(via, mock_user_get_teams):
def test_api_document_accesses_delete_owners(via, mock_user_teams):
"""
Users should be able to delete the document access of another user
for a document of which they are owner.
@@ -939,7 +743,7 @@ def test_api_document_accesses_delete_owners(via, mock_user_get_teams):
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="owner"
)
@@ -958,30 +762,31 @@ def test_api_document_accesses_delete_owners(via, mock_user_get_teams):
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_delete_owners_last_owner(via, mock_user_get_teams):
def test_api_document_accesses_delete_owners_last_owner(via, mock_user_teams):
"""
It should not be possible to delete the last owner access from a document
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
access = None
if via == USER:
access = factories.UserDocumentAccessFactory(
document=document, user=user, role="owner"
)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
access = factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="owner"
)
assert models.DocumentAccess.objects.count() == 1
assert models.DocumentAccess.objects.count() == 2
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.DocumentAccess.objects.count() == 1
assert models.DocumentAccess.objects.count() == 2

View File

@@ -0,0 +1,235 @@
"""
Test document accesses API endpoints for users in impress's core app.
"""
import random
from django.core import mail
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.api import serializers
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
def test_api_document_accesses_create_anonymous():
"""Anonymous users should not be allowed to create document accesses."""
document = factories.DocumentFactory()
other_user = factories.UserFactory()
response = APIClient().post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user_id": str(other_user.id),
"document": str(document.id),
"role": random.choice(models.RoleChoices.choices)[0],
},
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert models.DocumentAccess.objects.exists() is False
def test_api_document_accesses_create_authenticated_unrelated():
"""
Authenticated users should not be allowed to create document accesses for a document to
which they are not related.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
document = factories.DocumentFactory()
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user_id": str(other_user.id),
},
format="json",
)
assert response.status_code == 403
assert not models.DocumentAccess.objects.filter(user=other_user).exists()
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_create_authenticated_reader_or_editor(
via, role, mock_user_teams
):
"""Readers or editors of a document should not be allowed to create document accesses."""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
other_user = factories.UserFactory()
for new_role in [role[0] for role in models.RoleChoices.choices]:
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user_id": str(other_user.id),
"role": new_role,
},
format="json",
)
assert response.status_code == 403
assert not models.DocumentAccess.objects.filter(user=other_user).exists()
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_create_authenticated_administrator(via, mock_user_teams):
"""
Administrators of a document should be able to create document accesses
except for the "owner" role.
An email should be sent to the accesses to notify them of the adding.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(
document=document, user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="administrator"
)
other_user = factories.UserFactory()
# It should not be allowed to create an owner access
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user_id": str(other_user.id),
"role": "owner",
},
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "Only owners of a resource can assign other users as owners."
}
# It should be allowed to create a lower access
role = random.choice(
[role[0] for role in models.RoleChoices.choices if role[0] != "owner"]
)
assert len(mail.outbox) == 0
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user_id": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.DocumentAccess.objects.filter(user=other_user).count() == 1
new_document_access = models.DocumentAccess.objects.filter(user=other_user).get()
other_user = serializers.UserSerializer(instance=other_user).data
assert response.json() == {
"abilities": new_document_access.get_abilities(user),
"id": str(new_document_access.id),
"team": "",
"role": role,
"user": other_user,
}
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == [other_user["email"]]
email_content = " ".join(email.body.split())
assert (
f"{user.full_name} shared a document with you: {document.title}"
in email_content
)
assert "docs/" + str(document.id) + "/" in email_content
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_create_authenticated_owner(via, mock_user_teams):
"""
Owners of a document should be able to create document accesses whatever the role.
An email should be sent to the accesses to notify them of the adding.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="owner"
)
other_user = factories.UserFactory()
role = random.choice([role[0] for role in models.RoleChoices.choices])
assert len(mail.outbox) == 0
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user_id": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.DocumentAccess.objects.filter(user=other_user).count() == 1
new_document_access = models.DocumentAccess.objects.filter(user=other_user).get()
other_user = serializers.UserSerializer(instance=other_user).data
assert response.json() == {
"id": str(new_document_access.id),
"user": other_user,
"team": "",
"role": role,
"abilities": new_document_access.get_abilities(user),
}
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == [other_user["email"]]
email_content = " ".join(email.body.split())
assert (
f"{user.full_name} shared a document with you: {document.title}"
in email_content
)
assert "docs/" + str(document.id) + "/" in email_content

View File

@@ -1,9 +1,12 @@
"""
Unit tests for the Invitation model
"""
import random
import time
from django.core import mail
import pytest
from rest_framework import status
from rest_framework.test import APIClient
@@ -77,7 +80,7 @@ def test_api_document_invitations__create__authenticated_outsider():
)
@pytest.mark.parametrize("via", VIA)
def test_api_document_invitations__create__privileged_members(
via, inviting, invited, is_allowed, mock_user_get_teams
via, inviting, invited, is_allowed, mock_user_teams
):
"""
Only owners and administrators should be able to invite new users.
@@ -88,7 +91,7 @@ def test_api_document_invitations__create__privileged_members(
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=inviting)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=inviting
)
@@ -98,6 +101,8 @@ def test_api_document_invitations__create__privileged_members(
"role": invited,
}
assert len(mail.outbox) == 0
client = APIClient()
client.force_login(user)
response = client.post(
@@ -108,11 +113,142 @@ def test_api_document_invitations__create__privileged_members(
if is_allowed:
assert response.status_code == status.HTTP_201_CREATED
assert models.Invitation.objects.count() == 1
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == ["guest@example.com"]
email_content = " ".join(email.body.split())
assert (
f"{user.full_name} shared a document with you: {document.title}"
in email_content
)
else:
assert response.status_code == status.HTTP_403_FORBIDDEN
assert models.Invitation.objects.exists() is False
def test_api_document_invitations__create__email_from_content_language():
"""
The email generated is from the language set in the Content-Language header
"""
user = factories.UserFactory()
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
invitation_values = {
"email": "guest@example.com",
"role": "reader",
}
assert len(mail.outbox) == 0
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/invitations/",
invitation_values,
format="json",
headers={"Content-Language": "fr-fr"},
)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["email"] == "guest@example.com"
assert models.Invitation.objects.count() == 1
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == ["guest@example.com"]
email_content = " ".join(email.body.split())
assert (
f"{user.full_name} a partagé un document avec vous: {document.title}"
in email_content
)
def test_api_document_invitations__create__email_from_content_language_not_supported():
"""
If the language from the Content-Language is not supported
it will display the default language, English.
"""
user = factories.UserFactory()
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
invitation_values = {
"email": "guest@example.com",
"role": "reader",
}
assert len(mail.outbox) == 0
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/invitations/",
invitation_values,
format="json",
headers={"Content-Language": "not-supported"},
)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["email"] == "guest@example.com"
assert models.Invitation.objects.count() == 1
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == ["guest@example.com"]
email_content = " ".join(email.body.split())
assert (
f"{user.full_name} shared a document with you: {document.title}"
in email_content
)
def test_api_document_invitations__create__email__full_name_empty():
"""
If the full name of the user is empty, it will display the email address.
"""
user = factories.UserFactory(full_name="")
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
invitation_values = {
"email": "guest@example.com",
"role": "reader",
}
assert len(mail.outbox) == 0
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id}/invitations/",
invitation_values,
format="json",
headers={"Content-Language": "not-supported"},
)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["email"] == "guest@example.com"
assert models.Invitation.objects.count() == 1
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == ["guest@example.com"]
email_content = " ".join(email.body.split())
assert f"{user.email} shared a document with you: {document.title}" in email_content
assert (
f'{user.email} invited you with the role "reader" on the '
f"following document : {document.title}" in email_content
)
def test_api_document_invitations__create__issuer_and_document_override():
"""It should not be possible to set the "document" and "issuer" fields."""
user = factories.UserFactory()
@@ -165,7 +301,7 @@ def test_api_document_invitations__create__cannot_duplicate_invitation():
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["__all__"] == [
assert response.json() == [
"Document invitation with this Email address and Document already exists."
]
@@ -193,9 +329,7 @@ def test_api_document_invitations__create__cannot_invite_existing_users():
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["email"] == [
"This email is already associated to a registered user."
]
assert response.json() == ["This email is already associated to a registered user."]
def test_api_document_invitations__list__anonymous_user():
@@ -207,7 +341,7 @@ def test_api_document_invitations__list__anonymous_user():
@pytest.mark.parametrize("via", VIA)
def test_api_document_invitations__list__authenticated(
via, mock_user_get_teams, django_assert_num_queries
via, mock_user_teams, django_assert_num_queries
):
"""
Authenticated users should be able to list invitations for documents to which they are
@@ -220,7 +354,7 @@ def test_api_document_invitations__list__authenticated(
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
@@ -256,8 +390,8 @@ def test_api_document_invitations__list__authenticated(
"is_expired": False,
"abilities": {
"destroy": role in ["administrator", "owner"],
"update": False,
"partial_update": False,
"update": role in ["administrator", "owner"],
"partial_update": role in ["administrator", "owner"],
"retrieve": True,
},
}
@@ -308,8 +442,8 @@ def test_api_document_invitations__list__expired_invitations_still_listed(settin
"is_expired": True,
"abilities": {
"destroy": True,
"update": False,
"partial_update": False,
"update": True,
"partial_update": True,
"retrieve": True,
},
},
@@ -348,7 +482,7 @@ def test_api_document_invitations__retrieve__unrelated_user():
@pytest.mark.parametrize("via", VIA)
def test_api_document_invitations__retrieve__document_member(via, mock_user_get_teams):
def test_api_document_invitations__retrieve__document_member(via, mock_user_teams):
"""
Authenticated users related to the document should be able to retrieve invitations
whatever their role in the document.
@@ -361,7 +495,7 @@ def test_api_document_invitations__retrieve__document_member(via, mock_user_get_
document=invitation.document, user=user, role=role
)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=invitation.document, team="lasuite", role=role
)
@@ -383,21 +517,17 @@ def test_api_document_invitations__retrieve__document_member(via, mock_user_get_
"is_expired": False,
"abilities": {
"destroy": role in ["administrator", "owner"],
"update": False,
"partial_update": False,
"update": role in ["administrator", "owner"],
"partial_update": role in ["administrator", "owner"],
"retrieve": True,
},
}
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize(
"method",
["put", "patch"],
)
def test_api_document_invitations__update__forbidden(method, via, mock_user_get_teams):
def test_api_document_invitations__put_authenticated(via, mock_user_teams):
"""
Update of invitations is currently forbidden.
Authenticated user can put invitations.
"""
user = factories.UserFactory()
invitation = factories.InvitationFactory()
@@ -406,7 +536,7 @@ def test_api_document_invitations__update__forbidden(method, via, mock_user_get_
document=invitation.document, user=user, role="owner"
)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=invitation.document, team="lasuite", role="owner"
)
@@ -414,13 +544,89 @@ def test_api_document_invitations__update__forbidden(method, via, mock_user_get_
client = APIClient()
client.force_login(user)
url = f"/api/v1.0/documents/{invitation.document.id}/invitations/{invitation.id}/"
if method == "put":
response = client.put(url)
response = client.patch(url, {"email": "test@test.test"}, format="json")
assert response.status_code == status.HTTP_200_OK
invitation.refresh_from_db()
assert invitation.email == "test@test.test"
@pytest.mark.parametrize("via", VIA)
def test_api_document_invitations__patch_authenticated(via, mock_user_teams):
"""
Authenticated user can patch invitations.
"""
user = factories.UserFactory()
invitation = factories.InvitationFactory(role="owner")
if via == USER:
factories.UserDocumentAccessFactory(
document=invitation.document, user=user, role="owner"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=invitation.document, team="lasuite", role="owner"
)
assert invitation.role == "owner"
client = APIClient()
client.force_login(user)
url = f"/api/v1.0/documents/{invitation.document.id}/invitations/{invitation.id}/"
response = client.patch(
url,
{"role": "reader"},
format="json",
)
assert response.status_code == status.HTTP_200_OK
invitation.refresh_from_db()
assert invitation.role == "reader"
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize(
"method",
["put", "patch"],
)
@pytest.mark.parametrize(
"role",
["editor", "reader"],
)
def test_api_document_invitations__update__forbidden__not_authenticated(
method, via, role, mock_user_teams
):
"""
Update of invitations is currently forbidden.
"""
user = factories.UserFactory(with_owned_document=True)
invitation = factories.InvitationFactory()
if via == USER:
factories.UserDocumentAccessFactory(
document=invitation.document, user=user, role=role
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=invitation.document, team="lasuite", role=role
)
client = APIClient()
client.force_login(user)
url = f"/api/v1.0/documents/{invitation.document.id}/invitations/{invitation.id}/"
response = client.put(url)
if method == "patch":
response = client.patch(url)
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
assert response.json()["detail"] == f'Method "{method.upper()}" not allowed.'
assert response.status_code == status.HTTP_403_FORBIDDEN
assert (
response.json()["detail"]
== "You do not have permission to perform this action."
)
def test_api_document_invitations__delete__anonymous():
@@ -435,7 +641,7 @@ def test_api_document_invitations__delete__anonymous():
def test_api_document_invitations__delete__authenticated_outsider():
"""Members unrelated to a document should not be allowed to cancel invitations."""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
document = factories.DocumentFactory()
invitation = factories.InvitationFactory(document=document)
@@ -451,7 +657,7 @@ def test_api_document_invitations__delete__authenticated_outsider():
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize("role", ["owner", "administrator"])
def test_api_document_invitations__delete__privileged_members(
role, via, mock_user_get_teams
role, via, mock_user_teams
):
"""Privileged member should be able to cancel invitation."""
user = factories.UserFactory()
@@ -459,7 +665,7 @@ def test_api_document_invitations__delete__privileged_members(
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
@@ -476,16 +682,14 @@ def test_api_document_invitations__delete__privileged_members(
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_document_invitations_delete_readers_or_editors(
via, role, mock_user_get_teams
):
def test_api_document_invitations_delete_readers_or_editors(via, role, mock_user_teams):
"""Readers or editors should not be able to cancel invitation."""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)

View File

@@ -1,6 +1,7 @@
"""
Test document versions API endpoints for users in impress's core app.
"""
import random
import time
@@ -13,45 +14,37 @@ from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
def test_api_document_versions_list_anonymous_public():
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
def test_api_document_versions_list_anonymous(role, reach):
"""
Anonymous users should not be allowed to list document versions for a public document.
Anonymous users should not be allowed to list document versions for a document
whatever the reach and role.
"""
document = factories.DocumentFactory(is_public=True)
factories.UserDocumentAccessFactory.create_batch(2, document=document)
document = factories.DocumentFactory(link_role=role, link_reach=reach)
# Accesses and traces for other users should not interfere
factories.UserDocumentAccessFactory(document=document)
models.LinkTrace.objects.create(document=document, user=factories.UserFactory())
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/versions/")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert response.status_code == 403
assert response.json() == {"detail": "Authentication required."}
def test_api_document_versions_list_anonymous_private():
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_api_document_versions_list_authenticated_unrelated(reach):
"""
Anonymous users should not be allowed to find document versions for a private document.
"""
document = factories.DocumentFactory(is_public=False)
factories.UserDocumentAccessFactory.create_batch(2, document=document)
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/versions/")
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
def test_api_document_versions_list_authenticated_unrelated_public():
"""
Authenticated users should not be allowed to list document versions for a public document
Authenticated users should not be allowed to list document versions for a document
to which they are not related.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(is_public=True)
document = factories.DocumentFactory(link_reach=reach)
factories.UserDocumentAccessFactory.create_batch(3, document=document)
# The versions of another document to which the user is related should not be listed either
@@ -66,31 +59,8 @@ def test_api_document_versions_list_authenticated_unrelated_public():
}
def test_api_document_versions_list_authenticated_unrelated_private():
"""
Authenticated users should not be allowed to find document versions for a private document
to which they are not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(is_public=False)
factories.UserDocumentAccessFactory.create_batch(3, document=document)
# The versions of another document to which the user is related should not be listed either
factories.UserDocumentAccessFactory(user=user)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/",
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
@pytest.mark.parametrize("via", VIA)
def test_api_document_versions_list_authenticated_related(via, mock_user_get_teams):
def test_api_document_versions_list_authenticated_related_success(via, mock_user_teams):
"""
Authenticated users should be able to list document versions for a document
to which they are directly related, whatever their role in the document.
@@ -108,7 +78,7 @@ def test_api_document_versions_list_authenticated_related(via, mock_user_get_tea
role=random.choice(models.RoleChoices.choices)[0],
)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
models.DocumentAccess.objects.create(
document=document,
team="lasuite",
@@ -125,11 +95,12 @@ def test_api_document_versions_list_authenticated_related(via, mock_user_get_tea
assert response.status_code == 200
content = response.json()
assert len(content["versions"]) == 0
assert content["count"] == 0
# Add a new version to the document
document.content = "new content"
document.save()
for i in range(3):
document.content = f"new content {i:d}"
document.save()
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/",
@@ -137,16 +108,112 @@ def test_api_document_versions_list_authenticated_related(via, mock_user_get_tea
assert response.status_code == 200
content = response.json()
assert len(content["versions"]) == 1
assert content["next_version_id_marker"] == ""
# The current version is not listed
assert content["count"] == 2
@pytest.mark.parametrize("via", VIA)
def test_api_document_versions_list_authenticated_related_pagination(
via, mock_user_teams
):
"""
The list of versions should be paginated and exclude versions that were created prior to the
user gaining access to the document.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
for i in range(3):
document.content = f"before {i:d}"
document.save()
if via == USER:
models.DocumentAccess.objects.create(
document=document,
user=user,
role=random.choice(models.RoleChoices.choices)[0],
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
models.DocumentAccess.objects.create(
document=document,
team="lasuite",
role=random.choice(models.RoleChoices.choices)[0],
)
for i in range(4):
document.content = f"after {i:d}"
document.save()
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/",
)
content = response.json()
assert content["is_truncated"] is False
# The current version is not listed
assert content["count"] == 3
assert content["next_version_id_marker"] == ""
all_version_ids = [version["version_id"] for version in content["versions"]]
# - set page size
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/?page_size=2",
)
content = response.json()
assert content["count"] == 2
assert content["is_truncated"] is True
marker = content["next_version_id_marker"]
assert marker == all_version_ids[1]
assert [
version["version_id"] for version in content["versions"]
] == all_version_ids[:2]
# - get page 2
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/?page_size=2&version_id={marker:s}",
)
content = response.json()
assert content["count"] == 1
assert content["is_truncated"] is False
assert content["next_version_id_marker"] == ""
assert content["versions"][0]["version_id"] == all_version_ids[2]
def test_api_document_versions_retrieve_anonymous_public():
def test_api_document_versions_list_exceeds_max_page_size():
"""Page size should not exceed the limit set on the serializer"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[user])
document.content = "version 2"
document.save()
response = client.get(f"/api/v1.0/documents/{document.id!s}/versions/?page_size=51")
assert response.status_code == 400
assert response.json() == {
"page_size": ["Ensure this value is less than or equal to 50."]
}
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_api_document_versions_retrieve_anonymous(reach):
"""
Anonymous users should not be allowed to retrieve specific versions for a public document.
Anonymous users should not be allowed to find specific versions for a document with
restricted or authenticated link reach.
"""
document = factories.DocumentFactory(is_public=True)
document = factories.DocumentFactory(link_reach=reach)
document.content = "new content"
document.save()
version_id = document.get_versions_slice()["versions"][0]["version_id"]
url = f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/"
@@ -158,31 +225,21 @@ def test_api_document_versions_retrieve_anonymous_public():
}
def test_api_document_versions_retrieve_anonymous_private():
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_api_document_versions_retrieve_authenticated_unrelated(reach):
"""
Anonymous users should not be allowed to find specific versions for a private document.
"""
document = factories.DocumentFactory(is_public=False)
version_id = document.get_versions_slice()["versions"][0]["version_id"]
url = f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/"
response = APIClient().get(url)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
def test_api_document_versions_retrieve_authenticated_unrelated_public():
"""
Authenticated users should not be allowed to retrieve specific versions for a public
Authenticated users should not be allowed to retrieve specific versions for a
document to which they are not related.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(is_public=True)
document = factories.DocumentFactory(link_reach=reach)
document.content = "new content"
document.save()
version_id = document.get_versions_slice()["versions"][0]["version_id"]
response = client.get(
@@ -194,31 +251,11 @@ def test_api_document_versions_retrieve_authenticated_unrelated_public():
}
def test_api_document_versions_retrieve_authenticated_unrelated_private():
"""
Authenticated users should not be allowed to find specific versions for a private document
to which they are not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(is_public=False)
version_id = document.get_versions_slice()["versions"][0]["version_id"]
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
@pytest.mark.parametrize("via", VIA)
def test_api_document_versions_retrieve_authenticated_related(via, mock_user_get_teams):
def test_api_document_versions_retrieve_authenticated_related(via, mock_user_teams):
"""
A user who is related to a document should be allowed to retrieve the
associated document user accesses.
associated document versions.
"""
user = factories.UserFactory()
@@ -226,26 +263,47 @@ def test_api_document_versions_retrieve_authenticated_related(via, mock_user_get
client.force_login(user)
document = factories.DocumentFactory()
document.content = "new content"
document.save()
assert len(document.get_versions_slice()["versions"]) == 1
version_id = document.get_versions_slice()["versions"][0]["version_id"]
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
# Versions created before the document was shared should not be available to the user
time.sleep(1) # minio stores datetimes with the precision of a second
# Versions created before the document was shared should not be seen by the user
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
)
assert response.status_code == 404
# Create a new version should make it available to the user
time.sleep(1) # minio stores datetimes with the precision of a second
document.content = "new content"
# Create a new version should not make it available to the user because
# only the current version is available to the user but it is excluded
# from the list
document.content = "new content 1"
document.save()
assert len(document.get_versions_slice()["versions"]) == 2
version_id = document.get_versions_slice()["versions"][0]["version_id"]
response = client.get(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
)
assert response.status_code == 404
# Adding one more version should make the previous version available to the user
document.content = "new content 2"
document.save()
assert len(document.get_versions_slice()["versions"]) == 3
version_id = document.get_versions_slice()["versions"][0]["version_id"]
response = client.get(
@@ -253,7 +311,7 @@ def test_api_document_versions_retrieve_authenticated_related(via, mock_user_get
)
assert response.status_code == 200
assert response.json()["content"] == "new content"
assert response.json()["content"] == "new content 1"
def test_api_document_versions_create_anonymous():
@@ -266,10 +324,8 @@ def test_api_document_versions_create_anonymous():
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert response.status_code == 405
assert response.json() == {"detail": 'Method "POST" not allowed.'}
def test_api_document_versions_create_authenticated_unrelated():
@@ -294,7 +350,7 @@ def test_api_document_versions_create_authenticated_unrelated():
@pytest.mark.parametrize("via", VIA)
def test_api_document_versions_create_authenticated_related(via, mock_user_get_teams):
def test_api_document_versions_create_authenticated_related(via, mock_user_teams):
"""
Authenticated users related to a document should not be allowed to create document versions
whatever their role.
@@ -308,7 +364,7 @@ def test_api_document_versions_create_authenticated_related(via, mock_user_get_t
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
response = client.post(
@@ -323,14 +379,19 @@ def test_api_document_versions_create_authenticated_related(via, mock_user_get_t
def test_api_document_versions_update_anonymous():
"""Anonymous users should not be allowed to update a document version."""
access = factories.UserDocumentAccessFactory()
version_id = access.document.get_versions_slice()["versions"][0]["version_id"]
document = access.document
document.content = "new content"
document.save()
assert len(document.get_versions_slice()["versions"]) == 1
version_id = document.get_versions_slice()["versions"][0]["version_id"]
response = APIClient().put(
f"/api/v1.0/documents/{access.document_id!s}/versions/{version_id:s}/",
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
{"foo": "bar"},
format="json",
)
assert response.status_code == 401
assert response.status_code == 405
def test_api_document_versions_update_authenticated_unrelated():
@@ -344,7 +405,12 @@ def test_api_document_versions_update_authenticated_unrelated():
client.force_login(user)
access = factories.UserDocumentAccessFactory()
version_id = access.document.get_versions_slice()["versions"][0]["version_id"]
document = access.document
document.content = "new content"
document.save()
assert len(document.get_versions_slice()["versions"]) == 1
version_id = document.get_versions_slice()["versions"][0]["version_id"]
response = client.put(
f"/api/v1.0/documents/{access.document_id!s}/versions/{version_id:s}/",
@@ -355,7 +421,7 @@ def test_api_document_versions_update_authenticated_unrelated():
@pytest.mark.parametrize("via", VIA)
def test_api_document_versions_update_authenticated_related(via, mock_user_get_teams):
def test_api_document_versions_update_authenticated_related(via, mock_user_teams):
"""
Authenticated users with access to a document should not be able to update its versions
whatever their role.
@@ -366,14 +432,21 @@ def test_api_document_versions_update_authenticated_related(via, mock_user_get_t
client.force_login(user)
document = factories.DocumentFactory()
version_id = document.get_versions_slice()["versions"][0]["version_id"]
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
time.sleep(1) # minio stores datetimes with the precision of a second
document.content = "new content"
document.save()
assert len(document.get_versions_slice()["versions"]) == 1
version_id = document.get_versions_slice()["versions"][0]["version_id"]
response = client.put(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id!s}/",
{"foo": "bar"},
@@ -396,17 +469,21 @@ def test_api_document_versions_delete_anonymous():
assert response.status_code == 401
def test_api_document_versions_delete_authenticated_public():
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_api_document_versions_delete_authenticated(reach):
"""
Authenticated users should not be allowed to delete a document version for a
public document to which they are not related.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(is_public=True)
document = factories.DocumentFactory(link_reach=reach)
document.content = "new content"
document.save()
version_id = document.get_versions_slice()["versions"][0]["version_id"]
response = client.delete(
@@ -416,35 +493,14 @@ def test_api_document_versions_delete_authenticated_public():
assert response.status_code == 403
def test_api_document_versions_delete_authenticated_private():
"""
Authenticated users should not be allowed to find a document version to delete it
for a private document to which they are not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(is_public=False)
version_id = document.get_versions_slice()["versions"][0]["version_id"]
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_document_versions_delete_reader_or_editor(via, role, mock_user_get_teams):
def test_api_document_versions_delete_reader_or_editor(via, role, mock_user_teams):
"""
Authenticated users should not be allowed to delete a document version for a
document in which they are a simple reader or editor.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -453,7 +509,7 @@ def test_api_document_versions_delete_reader_or_editor(via, role, mock_user_get_
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
@@ -464,13 +520,7 @@ def test_api_document_versions_delete_reader_or_editor(via, role, mock_user_get_
document.save()
versions = document.get_versions_slice()["versions"]
assert len(versions) == 2
version_id = versions[1]["version_id"]
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
)
assert response.status_code == 403
assert len(versions) == 1
version_id = versions[0]["version_id"]
response = client.delete(
@@ -479,11 +529,11 @@ def test_api_document_versions_delete_reader_or_editor(via, role, mock_user_get_
assert response.status_code == 403
versions = document.get_versions_slice()["versions"]
assert len(versions) == 2
assert len(versions) == 1
@pytest.mark.parametrize("via", VIA)
def test_api_document_versions_delete_administrator_or_owner(via, mock_user_get_teams):
def test_api_document_versions_delete_administrator_or_owner(via, mock_user_teams):
"""
Users who are administrator or owner of a document should be allowed to delete a version.
"""
@@ -497,26 +547,32 @@ def test_api_document_versions_delete_administrator_or_owner(via, mock_user_get_
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
# Create a new version should make it available to the user
time.sleep(1) # minio stores datetimes with the precision of a second
document.content = "new content"
document.content = "new content 1"
document.save()
versions = document.get_versions_slice()["versions"]
assert len(versions) == 2
assert len(versions) == 1
version_id = versions[1]["version_id"]
version_id = versions[0]["version_id"]
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",
)
# 404 because the version was created before the user was given access to the document
assert response.status_code == 404
document.content = "new content 2"
document.save()
versions = document.get_versions_slice()["versions"]
assert len(versions) == 2
version_id = versions[0]["version_id"]
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/",

View File

@@ -0,0 +1,346 @@
"""
Test AI transform API endpoint for users in impress's core app.
"""
from unittest.mock import MagicMock, patch
from django.core.cache import cache
from django.test import override_settings
import pytest
from rest_framework.test import APIClient
from core import factories
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
@pytest.fixture(autouse=True)
def clear_cache():
"""Fixture to clear the cache before each test."""
cache.clear()
@pytest.fixture
def ai_settings():
"""Fixture to set AI settings."""
with override_settings(
AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="llama"
):
yield
@pytest.mark.parametrize(
"reach, role",
[
("restricted", "reader"),
("restricted", "editor"),
("authenticated", "reader"),
("authenticated", "editor"),
("public", "reader"),
],
)
def test_api_documents_ai_transform_anonymous_forbidden(reach, role):
"""
Anonymous users should not be able to request AI transform if the link reach
and role don't allow it.
"""
document = factories.DocumentFactory(link_reach=reach, link_role=role)
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
response = APIClient().post(url, {"text": "hello", "action": "prompt"})
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_transform_anonymous_success(mock_create):
"""
Anonymous users should be able to request AI transform to a document
if the link reach and role permit it.
"""
document = factories.DocumentFactory(link_reach="public", link_role="editor")
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
response = APIClient().post(url, {"text": "Hello", "action": "summarize"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
mock_create.assert_called_once_with(
model="llama",
response_format={"type": "json_object"},
messages=[
{
"role": "system",
"content": (
"Summarize the markdown text, preserving language and markdown formatting. "
'Return JSON: {"answer": "your markdown summary"}. Do not provide any other '
"information."
),
},
{"role": "user", "content": '{"markdown_input": "Hello"}'},
],
)
@pytest.mark.parametrize(
"reach, role",
[
("restricted", "reader"),
("restricted", "editor"),
("authenticated", "reader"),
("public", "reader"),
],
)
def test_api_documents_ai_transform_authenticated_forbidden(reach, role):
"""
Users who are not related to a document can't request AI transform if the
link reach and role don't allow it.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach, link_role=role)
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
response = client.post(url, {"text": "Hello", "action": "prompt"})
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
@pytest.mark.parametrize(
"reach, role",
[
("authenticated", "editor"),
("public", "editor"),
],
)
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_transform_authenticated_success(mock_create, reach, role):
"""
Autenticated who are not related to a document should be able to request AI transform
if the link reach and role permit it.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach, link_role=role)
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
response = client.post(url, {"text": "Hello", "action": "prompt"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
mock_create.assert_called_once_with(
model="llama",
response_format={"type": "json_object"},
messages=[
{
"role": "system",
"content": (
'Answer the prompt in markdown format. Return JSON: {"answer": '
'"Your markdown answer"}. Do not provide any other information.'
),
},
{"role": "user", "content": '{"markdown_input": "Hello"}'},
],
)
@pytest.mark.parametrize("via", VIA)
def test_api_documents_ai_transform_reader(via, mock_user_teams):
"""
Users who are simple readers on a document should not be allowed to request AI transform.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_role="reader")
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="reader")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="reader"
)
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
response = client.post(url, {"text": "Hello", "action": "prompt"})
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
@pytest.mark.parametrize("via", VIA)
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_transform_success(mock_create, via, role, mock_user_teams):
"""
Editors, administrators and owners of a document should be able to request AI transform.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
response = client.post(url, {"text": "Hello", "action": "prompt"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
mock_create.assert_called_once_with(
model="llama",
response_format={"type": "json_object"},
messages=[
{
"role": "system",
"content": (
'Answer the prompt in markdown format. Return JSON: {"answer": '
'"Your markdown answer"}. Do not provide any other information.'
),
},
{"role": "user", "content": '{"markdown_input": "Hello"}'},
],
)
def test_api_documents_ai_transform_empty_text():
"""The text should not be empty when requesting AI transform."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="public", link_role="editor")
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
response = client.post(url, {"text": " ", "action": "prompt"})
assert response.status_code == 400
assert response.json() == {"text": ["This field may not be blank."]}
def test_api_documents_ai_transform_invalid_action():
"""The action should valid when requesting AI transform."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="public", link_role="editor")
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
response = client.post(url, {"text": "Hello", "action": "invalid"})
assert response.status_code == 400
assert response.json() == {"action": ['"invalid" is not a valid choice.']}
@override_settings(AI_DOCUMENT_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_transform_throttling_document(mock_create):
"""
Throttling per document should be triggered on the AI transform endpoint.
For full throttle class test see: `test_api_utils_ai_document_rate_throttles`
"""
client = APIClient()
document = factories.DocumentFactory(link_reach="public", link_role="editor")
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
for _ in range(3):
user = factories.UserFactory()
client.force_login(user)
response = client.post(url, {"text": "Hello", "action": "summarize"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
user = factories.UserFactory()
client.force_login(user)
response = client.post(url, {"text": "Hello", "action": "summarize"})
assert response.status_code == 429
assert response.json() == {
"detail": "Request was throttled. Expected available in 60 seconds."
}
@override_settings(AI_USER_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_transform_throttling_user(mock_create):
"""
Throttling per user should be triggered on the AI transform endpoint.
For full throttle class test see: `test_api_utils_ai_user_rate_throttles`
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
for _ in range(3):
document = factories.DocumentFactory(link_reach="public", link_role="editor")
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
response = client.post(url, {"text": "Hello", "action": "summarize"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
document = factories.DocumentFactory(link_reach="public", link_role="editor")
url = f"/api/v1.0/documents/{document.id!s}/ai-transform/"
response = client.post(url, {"text": "Hello", "action": "summarize"})
assert response.status_code == 429
assert response.json() == {
"detail": "Request was throttled. Expected available in 60 seconds."
}

View File

@@ -0,0 +1,370 @@
"""
Test AI translate API endpoint for users in impress's core app.
"""
from unittest.mock import MagicMock, patch
from django.core.cache import cache
from django.test import override_settings
import pytest
from rest_framework.test import APIClient
from core import factories
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
@pytest.fixture(autouse=True)
def clear_cache():
"""Fixture to clear the cache before each test."""
cache.clear()
@pytest.fixture
def ai_settings():
"""Fixture to set AI settings."""
with override_settings(
AI_BASE_URL="http://example.com", AI_API_KEY="test-key", AI_MODEL="llama"
):
yield
def test_api_documents_ai_translate_viewset_options_metadata():
"""The documents endpoint should give us the list of available languages."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.DocumentFactory(link_reach="public", link_role="editor")
response = APIClient().options("/api/v1.0/documents/")
assert response.status_code == 200
metadata = response.json()
assert metadata["name"] == "Document List"
assert metadata["actions"]["POST"]["language"]["choices"][0] == {
"value": "af",
"display_name": "Afrikaans",
}
@pytest.mark.parametrize(
"reach, role",
[
("restricted", "reader"),
("restricted", "editor"),
("authenticated", "reader"),
("authenticated", "editor"),
("public", "reader"),
],
)
def test_api_documents_ai_translate_anonymous_forbidden(reach, role):
"""
Anonymous users should not be able to request AI translate if the link reach
and role don't allow it.
"""
document = factories.DocumentFactory(link_reach=reach, link_role=role)
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
response = APIClient().post(url, {"text": "hello", "language": "es"})
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_translate_anonymous_success(mock_create):
"""
Anonymous users should be able to request AI translate to a document
if the link reach and role permit it.
"""
document = factories.DocumentFactory(link_reach="public", link_role="editor")
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
response = APIClient().post(url, {"text": "Hello", "language": "es"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
mock_create.assert_called_once_with(
model="llama",
response_format={"type": "json_object"},
messages=[
{
"role": "system",
"content": (
"Translate the markdown text to Spanish, preserving markdown formatting. "
'Return JSON: {"answer": "your translated markdown text in Spanish"}. '
"Do not provide any other information."
),
},
{"role": "user", "content": '{"markdown_input": "Hello"}'},
],
)
@pytest.mark.parametrize(
"reach, role",
[
("restricted", "reader"),
("restricted", "editor"),
("authenticated", "reader"),
("public", "reader"),
],
)
def test_api_documents_ai_translate_authenticated_forbidden(reach, role):
"""
Users who are not related to a document can't request AI translate if the
link reach and role don't allow it.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach, link_role=role)
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
response = client.post(url, {"text": "Hello", "language": "es"})
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
@pytest.mark.parametrize(
"reach, role",
[
("authenticated", "editor"),
("public", "editor"),
],
)
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_translate_authenticated_success(mock_create, reach, role):
"""
Autenticated who are not related to a document should be able to request AI translate
if the link reach and role permit it.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach, link_role=role)
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
response = client.post(url, {"text": "Hello", "language": "es-co"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
mock_create.assert_called_once_with(
model="llama",
response_format={"type": "json_object"},
messages=[
{
"role": "system",
"content": (
"Translate the markdown text to Colombian Spanish, "
"preserving markdown formatting. Return JSON: "
'{"answer": "your translated markdown text in Colombian Spanish"}. '
"Do not provide any other information."
),
},
{"role": "user", "content": '{"markdown_input": "Hello"}'},
],
)
@pytest.mark.parametrize("via", VIA)
def test_api_documents_ai_translate_reader(via, mock_user_teams):
"""
Users who are simple readers on a document should not be allowed to request AI translate.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_role="reader")
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="reader")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="reader"
)
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
response = client.post(url, {"text": "Hello", "language": "es"})
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
@pytest.mark.parametrize("via", VIA)
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_translate_success(mock_create, via, role, mock_user_teams):
"""
Editors, administrators and owners of a document should be able to request AI translate.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
response = client.post(url, {"text": "Hello", "language": "es-co"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
mock_create.assert_called_once_with(
model="llama",
response_format={"type": "json_object"},
messages=[
{
"role": "system",
"content": (
"Translate the markdown text to Colombian Spanish, "
"preserving markdown formatting. Return JSON: "
'{"answer": "your translated markdown text in Colombian Spanish"}. '
"Do not provide any other information."
),
},
{"role": "user", "content": '{"markdown_input": "Hello"}'},
],
)
def test_api_documents_ai_translate_empty_text():
"""The text should not be empty when requesting AI translate."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="public", link_role="editor")
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
response = client.post(url, {"text": " ", "language": "es"})
assert response.status_code == 400
assert response.json() == {"text": ["This field may not be blank."]}
def test_api_documents_ai_translate_invalid_action():
"""The action should valid when requesting AI translate."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="public", link_role="editor")
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
response = client.post(url, {"text": "Hello", "language": "invalid"})
assert response.status_code == 400
assert response.json() == {"language": ['"invalid" is not a valid choice.']}
@override_settings(AI_DOCUMENT_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_translate_throttling_document(mock_create):
"""
Throttling per document should be triggered on the AI translate endpoint.
For full throttle class test see: `test_api_utils_ai_document_rate_throttles`
"""
client = APIClient()
document = factories.DocumentFactory(link_reach="public", link_role="editor")
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
for _ in range(3):
user = factories.UserFactory()
client.force_login(user)
response = client.post(url, {"text": "Hello", "language": "es"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
user = factories.UserFactory()
client.force_login(user)
response = client.post(url, {"text": "Hello", "language": "es"})
assert response.status_code == 429
assert response.json() == {
"detail": "Request was throttled. Expected available in 60 seconds."
}
@override_settings(AI_USER_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_translate_throttling_user(mock_create):
"""
Throttling per user should be triggered on the AI translate endpoint.
For full throttle class test see: `test_api_utils_ai_user_rate_throttles`
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
for _ in range(3):
document = factories.DocumentFactory(link_reach="public", link_role="editor")
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
response = client.post(url, {"text": "Hello", "language": "es"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
document = factories.DocumentFactory(link_reach="public", link_role="editor")
url = f"/api/v1.0/documents/{document.id!s}/ai-translate/"
response = client.post(url, {"text": "Hello", "language": "es"})
assert response.status_code == 429
assert response.json() == {
"detail": "Request was throttled. Expected available in 60 seconds."
}

View File

@@ -0,0 +1,337 @@
"""
Test file uploads API endpoint for users in impress's core app.
"""
import re
import uuid
from django.core.files.storage import default_storage
from django.core.files.uploadedfile import SimpleUploadedFile
import pytest
from rest_framework.test import APIClient
from core import factories
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
PIXEL = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00"
b"\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\xf8\xff\xff?\x00\x05\xfe\x02\xfe"
b"\xa7V\xbd\xfa\x00\x00\x00\x00IEND\xaeB`\x82"
)
@pytest.mark.parametrize(
"reach, role",
[
("restricted", "reader"),
("restricted", "editor"),
("authenticated", "reader"),
("authenticated", "editor"),
("public", "reader"),
],
)
def test_api_documents_attachment_upload_anonymous_forbidden(reach, role):
"""
Anonymous users should not be able to upload attachments if the link reach
and role don't allow it.
"""
document = factories.DocumentFactory(link_reach=reach, link_role=role)
file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png")
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
response = APIClient().post(url, {"file": file}, format="multipart")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_documents_attachment_upload_anonymous_success():
"""
Anonymous users should be able to upload attachments to a document
if the link reach and role permit it.
"""
document = factories.DocumentFactory(link_reach="public", link_role="editor")
file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png")
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
response = APIClient().post(url, {"file": file}, format="multipart")
assert response.status_code == 201
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.png")
match = pattern.search(response.json()["file"])
file_id = match.group(1)
# Validate that file_id is a valid UUID
uuid.UUID(file_id)
@pytest.mark.parametrize(
"reach, role",
[
("restricted", "reader"),
("restricted", "editor"),
("authenticated", "reader"),
("public", "reader"),
],
)
def test_api_documents_attachment_upload_authenticated_forbidden(reach, role):
"""
Users who are not related to a document can't upload attachments if the
link reach and role don't allow it.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach, link_role=role)
file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png")
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
response = client.post(url, {"file": file}, format="multipart")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
@pytest.mark.parametrize(
"reach, role",
[
("authenticated", "editor"),
("public", "editor"),
],
)
def test_api_documents_attachment_upload_authenticated_success(reach, role):
"""
Autenticated who are not related to a document should be able to upload a file
if the link reach and role permit it.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach, link_role=role)
file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png")
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
response = client.post(url, {"file": file}, format="multipart")
assert response.status_code == 201
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.png")
match = pattern.search(response.json()["file"])
file_id = match.group(1)
# Validate that file_id is a valid UUID
uuid.UUID(file_id)
@pytest.mark.parametrize("via", VIA)
def test_api_documents_attachment_upload_reader(via, mock_user_teams):
"""
Users who are simple readers on a document should not be allowed to upload an attachment.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_role="reader")
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="reader")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="reader"
)
file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png")
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
response = client.post(url, {"file": file}, format="multipart")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
@pytest.mark.parametrize("via", VIA)
def test_api_documents_attachment_upload_success(via, role, mock_user_teams):
"""
Editors, administrators and owners of a document should be able to upload an attachment.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png")
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
response = client.post(url, {"file": file}, format="multipart")
assert response.status_code == 201
file_path = response.json()["file"]
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.png")
match = pattern.search(file_path)
file_id = match.group(1)
# Validate that file_id is a valid UUID
uuid.UUID(file_id)
# Now, check the metadata of the uploaded file
key = file_path.replace("/media", "")
file_head = default_storage.connection.meta.client.head_object(
Bucket=default_storage.bucket_name, Key=key
)
assert file_head["Metadata"] == {"owner": str(user.id)}
def test_api_documents_attachment_upload_invalid(client):
"""Attempt to upload without a file should return an explicit error."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "owner")])
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
response = client.post(url, {}, format="multipart")
assert response.status_code == 400
assert response.json() == {"file": ["No file was submitted."]}
def test_api_documents_attachment_upload_size_limit_exceeded(settings):
"""The uploaded file should not exceeed the maximum size in settings."""
settings.DOCUMENT_IMAGE_MAX_SIZE = 1048576 # 1 MB for test
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "owner")])
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
# Create a temporary file larger than the allowed size
file = SimpleUploadedFile(
name="test.txt", content=b"a" * (1048576 + 1), content_type="text/plain"
)
response = client.post(url, {"file": file}, format="multipart")
assert response.status_code == 400
assert response.json() == {"file": ["File size exceeds the maximum limit of 1 MB."]}
@pytest.mark.parametrize(
"name,content,extension",
[
("test.exe", b"text", "exe"),
("test", b"text", "txt"),
("test.aaaaaa", b"test", "txt"),
("test.txt", PIXEL, "txt"),
("test.py", b"#!/usr/bin/python", "py"),
],
)
def test_api_documents_attachment_upload_fix_extension(name, content, extension):
"""
A file with no extension or a wrong extension is accepted and the extension
is corrected in storage.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "owner")])
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
file = SimpleUploadedFile(name=name, content=content)
response = client.post(url, {"file": file}, format="multipart")
assert response.status_code == 201
file_path = response.json()["file"]
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.{extension:s}")
match = pattern.search(file_path)
file_id = match.group(1)
# Validate that file_id is a valid UUID
uuid.UUID(file_id)
# Now, check the metadata of the uploaded file
key = file_path.replace("/media", "")
file_head = default_storage.connection.meta.client.head_object(
Bucket=default_storage.bucket_name, Key=key
)
assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"}
def test_api_documents_attachment_upload_empty_file():
"""An empty file should be rejected."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "owner")])
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
file = SimpleUploadedFile(name="test.png", content=b"")
response = client.post(url, {"file": file}, format="multipart")
assert response.status_code == 400
assert response.json() == {"file": ["The submitted file is empty."]}
def test_api_documents_attachment_upload_unsafe():
"""A file with an unsafe mime type should be tagged as such."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "owner")])
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
file = SimpleUploadedFile(
name="script.exe", content=b"\x4d\x5a\x90\x00\x03\x00\x00\x00"
)
response = client.post(url, {"file": file}, format="multipart")
assert response.status_code == 201
file_path = response.json()["file"]
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.exe")
match = pattern.search(file_path)
file_id = match.group(1)
# Validate that file_id is a valid UUID
uuid.UUID(file_id)
# Now, check the metadata of the uploaded file
key = file_path.replace("/media", "")
file_head = default_storage.connection.meta.client.head_object(
Bucket=default_storage.bucket_name, Key=key
)
assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"}

View File

@@ -1,7 +1,8 @@
"""
Tests for Documents API endpoint in impress's core app: create
"""
import uuid
from uuid import uuid4
import pytest
from rest_framework.test import APIClient
@@ -25,7 +26,7 @@ def test_api_documents_create_anonymous():
assert not Document.objects.exists()
def test_api_documents_create_authenticated():
def test_api_documents_create_authenticated_success():
"""
Authenticated users should be able to create documents and should automatically be declared
as the owner of the newly created document.
@@ -49,24 +50,64 @@ def test_api_documents_create_authenticated():
assert document.accesses.filter(role="owner", user=user).exists()
def test_api_documents_create_with_id_from_payload():
"""
We should be able to create a document with an ID from the payload.
"""
def test_api_documents_create_authenticated_title_null():
"""It should be possible to create several documents with a null title."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
doc_id = uuid.uuid4()
factories.DocumentFactory(title=None)
response = client.post("/api/v1.0/documents/", {}, format="json")
assert response.status_code == 201
assert Document.objects.filter(title__isnull=True).count() == 2
def test_api_documents_create_force_id_success():
"""It should be possible to force the document ID when creating a document."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
forced_id = uuid4()
response = client.post(
"/api/v1.0/documents/",
{"title": "my document", "id": str(doc_id)},
{
"id": str(forced_id),
"title": "my document",
},
format="json",
)
assert response.status_code == 201
document = Document.objects.get()
assert document.title == "my document"
assert document.id == doc_id
assert document.accesses.filter(role="owner", user=user).exists()
documents = Document.objects.all()
assert len(documents) == 1
assert documents[0].id == forced_id
def test_api_documents_create_force_id_existing():
"""
It should not be possible to use the ID of an existing document when forcing ID on creation.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
response = client.post(
"/api/v1.0/documents/",
{
"id": str(document.id),
"title": "my document",
},
format="json",
)
assert response.status_code == 400
assert response.json() == {
"id": ["A document with this ID already exists. You cannot override it."]
}

View File

@@ -1,7 +1,6 @@
"""
Tests for Documents API endpoint in impress's core app: delete
"""
import random
import pytest
from rest_framework.test import APIClient
@@ -24,35 +23,36 @@ def test_api_documents_delete_anonymous():
assert models.Document.objects.count() == 1
def test_api_documents_delete_authenticated_unrelated():
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
def test_api_documents_delete_authenticated_unrelated(reach, role):
"""
Authenticated users should not be allowed to delete a document to which they are not
related.
Authenticated users should not be allowed to delete a document to which
they are not related.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
is_public = random.choice([True, False])
document = factories.DocumentFactory(is_public=is_public)
document = factories.DocumentFactory(link_reach=reach, link_role=role)
response = client.delete(
f"/api/v1.0/documents/{document.id!s}/",
)
assert response.status_code == 403 if is_public else 404
assert models.Document.objects.count() == 1
assert response.status_code == 403
assert models.Document.objects.count() == 2
@pytest.mark.parametrize("role", ["reader", "editor", "administrator"])
@pytest.mark.parametrize("via", VIA)
def test_api_documents_delete_authenticated_not_owner(via, role, mock_user_get_teams):
def test_api_documents_delete_authenticated_not_owner(via, role, mock_user_teams):
"""
Authenticated users should not be allowed to delete a document for which they are
only a reader, editor or administrator.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -61,7 +61,7 @@ def test_api_documents_delete_authenticated_not_owner(via, role, mock_user_get_t
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
@@ -74,11 +74,11 @@ def test_api_documents_delete_authenticated_not_owner(via, role, mock_user_get_t
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert models.Document.objects.count() == 1
assert models.Document.objects.count() == 2
@pytest.mark.parametrize("via", VIA)
def test_api_documents_delete_authenticated_owner(via, mock_user_get_teams):
def test_api_documents_delete_authenticated_owner(via, mock_user_teams):
"""
Authenticated users should be able to delete a document they own.
"""
@@ -91,7 +91,7 @@ def test_api_documents_delete_authenticated_owner(via, mock_user_get_teams):
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="owner"
)

View File

@@ -0,0 +1,152 @@
"""Tests for link configuration of documents on API endpoint"""
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.api import serializers
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_api_documents_link_configuration_update_anonymous(reach, role):
"""Anonymous users should not be allowed to update a link configuration."""
document = factories.DocumentFactory(link_reach=reach, link_role=role)
old_document_values = serializers.LinkDocumentSerializer(instance=document).data
new_document_values = serializers.LinkDocumentSerializer(
instance=factories.DocumentFactory()
).data
response = APIClient().put(
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
new_document_values,
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
document.refresh_from_db()
document_values = serializers.LinkDocumentSerializer(instance=document).data
assert document_values == old_document_values
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_api_documents_link_configuration_update_authenticated_unrelated(reach, role):
"""
Authenticated users should not be allowed to update the link configuration for
a document to which they are not related.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach, link_role=role)
old_document_values = serializers.LinkDocumentSerializer(instance=document).data
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 == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
document.refresh_from_db()
document_values = serializers.LinkDocumentSerializer(instance=document).data
assert document_values == old_document_values
@pytest.mark.parametrize("role", ["editor", "reader"])
@pytest.mark.parametrize("via", VIA)
def test_api_documents_link_configuration_update_authenticated_related_forbidden(
via, role, mock_user_teams
):
"""
Users who are readers or editors of a document should not be allowed to update
the link configuration.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
old_document_values = serializers.LinkDocumentSerializer(instance=document).data
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 == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
document.refresh_from_db()
document_values = serializers.LinkDocumentSerializer(instance=document).data
assert document_values == old_document_values
@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
):
"""
A user who is administrator or owner of a document should be allowed to update
the link configuration.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
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]

View File

@@ -1,66 +1,72 @@
"""
Tests for Documents API endpoint in impress's core app: list
"""
import operator
from unittest import mock
import pytest
from faker import Faker
from rest_framework.pagination import PageNumberPagination
from rest_framework.status import HTTP_200_OK
from rest_framework.test import APIClient
from core import factories
from core import factories, models
fake = Faker()
pytestmark = pytest.mark.django_db
def test_api_documents_list_anonymous():
"""Anonymous users should only be able to list public documents."""
factories.DocumentFactory.create_batch(2, is_public=False)
documents = factories.DocumentFactory.create_batch(2, is_public=True)
expected_ids = {str(document.id) for document in documents}
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_api_documents_list_anonymous(reach, role):
"""
Anonymous users should not be allowed to list documents whatever the
link reach and the role
"""
factories.DocumentFactory(link_reach=reach, link_role=role)
response = APIClient().get("/api/v1.0/documents/")
assert response.status_code == HTTP_200_OK
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 2
results_id = {result["id"] for result in results}
assert expected_ids == results_id
assert len(results) == 0
def test_api_documents_list_authenticated_direct():
"""
Authenticated users should be able to list documents they are a direct
owner/administrator/member of.
owner/administrator/member of or documents that have a link reach other
than restricted.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
related_documents = [
documents = [
access.document
for access in factories.UserDocumentAccessFactory.create_batch(5, user=user)
for access in factories.UserDocumentAccessFactory.create_batch(2, user=user)
]
public_documents = factories.DocumentFactory.create_batch(2, is_public=True)
factories.DocumentFactory.create_batch(2, is_public=False)
expected_ids = {
str(document.id) for document in related_documents + public_documents
}
# Unrelated and untraced documents
for reach in models.LinkReachChoices:
for role in models.LinkRoleChoices:
factories.DocumentFactory(link_reach=reach, link_role=role)
expected_ids = {str(document.id) for document in documents}
response = client.get(
"/api/v1.0/documents/",
)
assert response.status_code == HTTP_200_OK
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 7
assert len(results) == 2
results_id = {result["id"] for result in results}
assert expected_ids == results_id
def test_api_documents_list_authenticated_via_team(mock_user_get_teams):
def test_api_documents_list_authenticated_via_team(mock_user_teams):
"""
Authenticated users should be able to list documents they are a
owner/administrator/member of via a team.
@@ -70,7 +76,7 @@ def test_api_documents_list_authenticated_via_team(mock_user_get_teams):
client = APIClient()
client.force_login(user)
mock_user_get_teams.return_value = ["team1", "team2", "unknown"]
mock_user_teams.return_value = ["team1", "team2", "unknown"]
documents_team1 = [
access.document
@@ -80,19 +86,71 @@ def test_api_documents_list_authenticated_via_team(mock_user_get_teams):
access.document
for access in factories.TeamDocumentAccessFactory.create_batch(3, team="team2")
]
public_documents = factories.DocumentFactory.create_batch(2, is_public=True)
factories.DocumentFactory.create_batch(2, is_public=False)
expected_ids = {
str(document.id)
for document in documents_team1 + documents_team2 + public_documents
}
expected_ids = {str(document.id) for document in documents_team1 + documents_team2}
response = client.get("/api/v1.0/documents/")
assert response.status_code == HTTP_200_OK
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 7
assert len(results) == 5
results_id = {result["id"] for result in results}
assert expected_ids == results_id
def test_api_documents_list_authenticated_link_reach_restricted():
"""
An authenticated user who has link traces to a document that is restricted should not
see it on the list view
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_traces=[user], link_reach="restricted")
# Link traces for other documents or other users should not interfere
models.LinkTrace.objects.create(document=document, user=factories.UserFactory())
other_document = factories.DocumentFactory(link_reach="public")
models.LinkTrace.objects.create(document=other_document, user=user)
response = client.get(
"/api/v1.0/documents/",
)
assert response.status_code == 200
results = response.json()["results"]
# Only the other document is returned but not the restricted document even though the user
# visited it earlier (probably b/c it previously had public or authenticated reach...)
assert len(results) == 1
assert results[0]["id"] == str(other_document.id)
def test_api_documents_list_authenticated_link_reach_public_or_authenticated():
"""
An authenticated user who has link traces to a document with public or authenticated
link reach should see it on the list view.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
documents = [
factories.DocumentFactory(link_traces=[user], link_reach=reach)
for reach in models.LinkReachChoices
if reach != "restricted"
]
expected_ids = {str(document.id) for document in documents}
response = client.get(
"/api/v1.0/documents/",
)
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 2
results_id = {result["id"] for result in results}
assert expected_ids == results_id
@@ -117,7 +175,7 @@ def test_api_documents_list_pagination(
"/api/v1.0/documents/",
)
assert response.status_code == HTTP_200_OK
assert response.status_code == 200
content = response.json()
assert content["count"] == 3
@@ -133,7 +191,7 @@ def test_api_documents_list_pagination(
"/api/v1.0/documents/?page=2",
)
assert response.status_code == HTTP_200_OK
assert response.status_code == 200
content = response.json()
assert content["count"] == 3
@@ -154,69 +212,63 @@ def test_api_documents_list_authenticated_distinct():
other_user = factories.UserFactory()
document = factories.DocumentFactory(users=[user, other_user], is_public=True)
document = factories.DocumentFactory(users=[user, other_user])
response = client.get(
"/api/v1.0/documents/",
)
assert response.status_code == HTTP_200_OK
assert response.status_code == 200
content = response.json()
assert len(content["results"]) == 1
assert content["results"][0]["id"] == str(document.id)
def test_api_documents_order():
"""
Test that the endpoint GET documents is sorted in 'created_at' descending order by default.
"""
def test_api_documents_list_ordering_default():
"""Documents should be ordered by descending "updated_at" by default"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document_ids = [
str(document.id)
for document in factories.DocumentFactory.create_batch(5, is_public=True)
]
factories.DocumentFactory.create_batch(5, users=[user])
response = client.get(
"/api/v1.0/documents/",
)
response = client.get("/api/v1.0/documents/")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 5
response_data = response.json()
response_document_ids = [document["id"] for document in response_data["results"]]
document_ids.reverse()
assert (
response_document_ids == document_ids
), "created_at values are not sorted from newest to oldest"
# Check that results are sorted by descending "updated_at" as expected
for i in range(4):
assert operator.ge(results[i]["updated_at"], results[i + 1]["updated_at"])
def test_api_documents_order_param():
"""
Test that the 'created_at' field is sorted in ascending order
when the 'ordering' query parameter is set.
"""
def test_api_documents_list_ordering_by_fields():
"""It should be possible to order by several fields"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
documents_ids = [
str(document.id)
for document in factories.DocumentFactory.create_batch(5, is_public=True)
]
factories.DocumentFactory.create_batch(5, users=[user])
response = APIClient().get(
"/api/v1.0/documents/?ordering=created_at",
)
assert response.status_code == 200
for parameter in [
"created_at",
"-created_at",
"updated_at",
"-updated_at",
"title",
"-title",
]:
is_descending = parameter.startswith("-")
field = parameter.lstrip("-")
querystring = f"?ordering={parameter}"
response_data = response.json()
response = client.get(f"/api/v1.0/documents/{querystring:s}")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 5
response_document_ids = [document["id"] for document in response_data["results"]]
assert (
response_document_ids == documents_ids
), "created_at values are not sorted from oldest to newest"
# Check that results are sorted by the field in querystring as expected
compare = operator.ge if is_descending else operator.le
for i in range(4):
assert compare(results[i][field], results[i + 1][field])

View File

@@ -1,10 +1,11 @@
"""
Tests for Documents API endpoint in impress's core app: retrieve
"""
import pytest
from rest_framework.test import APIClient
from core import factories
from core import factories, models
from core.api import serializers
pytestmark = pytest.mark.django_db
@@ -12,7 +13,7 @@ pytestmark = pytest.mark.django_db
def test_api_documents_retrieve_anonymous_public():
"""Anonymous users should be allowed to retrieve public documents."""
document = factories.DocumentFactory(is_public=True)
document = factories.DocumentFactory(link_reach="public")
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/")
@@ -20,33 +21,44 @@ def test_api_documents_retrieve_anonymous_public():
assert response.json() == {
"id": str(document.id),
"abilities": {
"ai_transform": document.link_role == "editor",
"ai_translate": document.link_role == "editor",
"attachment_upload": document.link_role == "editor",
"destroy": False,
"link_configuration": False,
"manage_accesses": False,
"partial_update": False,
"partial_update": document.link_role == "editor",
"retrieve": True,
"update": False,
"update": document.link_role == "editor",
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
},
"accesses": [],
"link_reach": "public",
"link_role": document.link_role,
"title": document.title,
"is_public": True,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
def test_api_documents_retrieve_anonymous_not_public():
@pytest.mark.parametrize("reach", ["restricted", "authenticated"])
def test_api_documents_retrieve_anonymous_restricted_or_authenticated(reach):
"""Anonymous users should not be able to retrieve a document that is not public."""
document = factories.DocumentFactory(is_public=False)
document = factories.DocumentFactory(link_reach=reach)
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_documents_retrieve_authenticated_unrelated_public():
@pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(reach):
"""
Authenticated users should be able to retrieve a public document to which they are
not related.
@@ -56,7 +68,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public():
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(is_public=True)
document = factories.DocumentFactory(link_reach=reach)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/",
@@ -65,39 +77,80 @@ def test_api_documents_retrieve_authenticated_unrelated_public():
assert response.json() == {
"id": str(document.id),
"abilities": {
"ai_transform": document.link_role == "editor",
"ai_translate": document.link_role == "editor",
"attachment_upload": document.link_role == "editor",
"link_configuration": False,
"destroy": False,
"manage_accesses": False,
"partial_update": False,
"partial_update": document.link_role == "editor",
"retrieve": True,
"update": False,
"update": document.link_role == "editor",
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
},
"accesses": [],
"link_reach": reach,
"link_role": document.link_role,
"title": document.title,
"is_public": True,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
assert (
models.LinkTrace.objects.filter(document=document, user=user).exists() is True
)
def test_api_documents_retrieve_authenticated_unrelated_not_public():
@pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_retrieve_authenticated_trace_twice(reach):
"""
Authenticated users should not be allowed to retrieve a document that is not public and
to which they are not related.
Accessing a document several times should not raise any error even though the
trace already exists for this document and user.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(is_public=False)
document = factories.DocumentFactory(link_reach=reach)
assert (
models.LinkTrace.objects.filter(document=document, user=user).exists() is False
)
client.get(
f"/api/v1.0/documents/{document.id!s}/",
)
assert (
models.LinkTrace.objects.filter(document=document, user=user).exists() is True
)
# A second visit should not raise any error
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 200
def test_api_documents_retrieve_authenticated_unrelated_restricted():
"""
Authenticated users should not be allowed to retrieve a document that is restricted and
to which they are not related.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach="restricted")
response = client.get(
f"/api/v1.0/documents/{document.id!s}/",
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
def test_api_documents_retrieve_authenticated_related_direct():
@@ -145,23 +198,26 @@ def test_api_documents_retrieve_authenticated_related_direct():
"title": document.title,
"content": document.content,
"abilities": document.get_abilities(user),
"is_public": document.is_public,
"link_reach": document.link_reach,
"link_role": document.link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
def test_api_documents_retrieve_authenticated_related_team_none(mock_user_get_teams):
def test_api_documents_retrieve_authenticated_related_team_none(mock_user_teams):
"""
Authenticated users should not be able to retrieve a document related to teams in
which the user is not.
Authenticated users should not be able to retrieve a restricted document related to
teams in which the user is not.
"""
mock_user_get_teams.return_value = []
mock_user_teams.return_value = []
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(is_public=False)
document = factories.DocumentFactory(link_reach="restricted")
factories.TeamDocumentAccessFactory(
document=document, team="readers", role="reader"
@@ -177,8 +233,10 @@ def test_api_documents_retrieve_authenticated_related_team_none(mock_user_get_te
factories.TeamDocumentAccessFactory()
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
@pytest.mark.parametrize(
@@ -191,20 +249,20 @@ def test_api_documents_retrieve_authenticated_related_team_none(mock_user_get_te
],
)
def test_api_documents_retrieve_authenticated_related_team_members(
teams, mock_user_get_teams
teams, mock_user_teams
):
"""
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.
"""
mock_user_get_teams.return_value = teams
mock_user_teams.return_value = teams
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(is_public=False)
document = factories.DocumentFactory(link_reach="restricted")
access_reader = factories.TeamDocumentAccessFactory(
document=document, team="readers", role="reader"
@@ -222,6 +280,8 @@ def test_api_documents_retrieve_authenticated_related_team_members(
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 = {
@@ -276,7 +336,10 @@ def test_api_documents_retrieve_authenticated_related_team_members(
"title": document.title,
"content": document.content,
"abilities": document.get_abilities(user),
"is_public": False,
"link_reach": "restricted",
"link_role": document.link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -289,20 +352,20 @@ def test_api_documents_retrieve_authenticated_related_team_members(
],
)
def test_api_documents_retrieve_authenticated_related_team_administrators(
teams, mock_user_get_teams
teams, mock_user_teams
):
"""
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.
"""
mock_user_get_teams.return_value = teams
mock_user_teams.return_value = teams
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(is_public=False)
document = factories.DocumentFactory(link_reach="restricted")
access_reader = factories.TeamDocumentAccessFactory(
document=document, team="readers", role="reader"
@@ -393,7 +456,10 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
"title": document.title,
"content": document.content,
"abilities": document.get_abilities(user),
"is_public": False,
"link_reach": "restricted",
"link_role": document.link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -407,20 +473,20 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
],
)
def test_api_documents_retrieve_authenticated_related_team_owners(
teams, mock_user_get_teams
teams, mock_user_teams
):
"""
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.
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.
"""
mock_user_get_teams.return_value = teams
mock_user_teams.return_value = teams
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(is_public=False)
document = factories.DocumentFactory(link_reach="restricted")
access_reader = factories.TeamDocumentAccessFactory(
document=document, team="readers", role="reader"
@@ -514,5 +580,8 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
"title": document.title,
"content": document.content,
"abilities": document.get_abilities(user),
"is_public": False,
"link_reach": "restricted",
"link_role": document.link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}

View File

@@ -0,0 +1,214 @@
"""
Test file uploads API endpoint for users in impress's core app.
"""
import uuid
from io import BytesIO
from urllib.parse import urlparse
from django.conf import settings
from django.core.files.storage import default_storage
from django.utils import timezone
import pytest
import requests
from rest_framework.test import APIClient
from core import factories
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
def test_api_documents_retrieve_auth_anonymous_public():
"""Anonymous users should be able to retrieve attachments linked to a public document"""
document = factories.DocumentFactory(link_reach="public")
filename = f"{uuid.uuid4()!s}.jpg"
key = f"{document.pk!s}/attachments/{filename:s}"
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=key,
Body=BytesIO(b"my prose"),
ContentType="text/plain",
)
original_url = f"http://localhost/media/{key:s}"
response = APIClient().get(
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
authorization = response["Authorization"]
assert "AWS4-HMAC-SHA256 Credential=" in authorization
assert (
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
response = requests.get(
file_url,
headers={
"authorization": authorization,
"x-amz-date": response["x-amz-date"],
"x-amz-content-sha256": response["x-amz-content-sha256"],
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
},
timeout=1,
)
assert response.content.decode("utf-8") == "my prose"
@pytest.mark.parametrize("reach", ["authenticated", "restricted"])
def test_api_documents_retrieve_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.
"""
document = factories.DocumentFactory(link_reach=reach)
filename = f"{uuid.uuid4()!s}.jpg"
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
)
assert response.status_code == 403
assert "Authorization" not in response
@pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_retrieve_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.
"""
document = factories.DocumentFactory(link_reach=reach)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
filename = f"{uuid.uuid4()!s}.jpg"
key = f"{document.pk!s}/attachments/{filename:s}"
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=key,
Body=BytesIO(b"my prose"),
ContentType="text/plain",
)
original_url = f"http://localhost/media/{key:s}"
response = client.get(
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
authorization = response["Authorization"]
assert "AWS4-HMAC-SHA256 Credential=" in authorization
assert (
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
response = requests.get(
file_url,
headers={
"authorization": authorization,
"x-amz-date": response["x-amz-date"],
"x-amz-content-sha256": response["x-amz-content-sha256"],
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
},
timeout=1,
)
assert response.content.decode("utf-8") == "my prose"
def test_api_documents_retrieve_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.
"""
document = factories.DocumentFactory(link_reach="restricted")
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
filename = f"{uuid.uuid4()!s}.jpg"
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
)
assert response.status_code == 403
assert "Authorization" not in response
@pytest.mark.parametrize("via", VIA)
def test_api_documents_retrieve_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.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
filename = f"{uuid.uuid4()!s}.jpg"
key = f"{document.pk!s}/attachments/{filename:s}"
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=key,
Body=BytesIO(b"my prose"),
ContentType="text/plain",
)
original_url = f"http://localhost/media/{key:s}"
response = client.get(
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
authorization = response["Authorization"]
assert "AWS4-HMAC-SHA256 Credential=" in authorization
assert (
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
response = requests.get(
file_url,
headers={
"authorization": authorization,
"x-amz-date": response["x-amz-date"],
"x-amz-content-sha256": response["x-amz-content-sha256"],
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
},
timeout=1,
)
assert response.content.decode("utf-8") == "my prose"

View File

@@ -1,8 +1,11 @@
"""
Tests for Documents API endpoint in impress's core app: update
"""
import random
from django.contrib.auth.models import AnonymousUser
import pytest
from rest_framework.test import APIClient
@@ -13,9 +16,22 @@ from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
def test_api_documents_update_anonymous():
"""Anonymous users should not be allowed to update a document."""
document = factories.DocumentFactory()
@pytest.mark.parametrize(
"reach, role",
[
("restricted", "reader"),
("restricted", "editor"),
("authenticated", "reader"),
("authenticated", "editor"),
("public", "reader"),
],
)
def test_api_documents_update_anonymous_forbidden(reach, role):
"""
Anonymous users should not be allowed to update a document when link
configuration does not allow it.
"""
document = factories.DocumentFactory(link_reach=reach, link_role=role)
old_document_values = serializers.DocumentSerializer(instance=document).data
new_document_values = serializers.DocumentSerializer(
@@ -36,18 +52,28 @@ def test_api_documents_update_anonymous():
assert document_values == old_document_values
def test_api_documents_update_authenticated_unrelated():
@pytest.mark.parametrize(
"reach,role",
[
("public", "reader"),
("authenticated", "reader"),
("restricted", "reader"),
("restricted", "editor"),
],
)
def test_api_documents_update_authenticated_unrelated_forbidden(reach, role):
"""
Authenticated users should not be allowed to update a document to which they are not related.
Authenticated users should not be allowed to update a document to which
they are not related if the link configuration does not allow it.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(is_public=False)
old_document_values = serializers.DocumentSerializer(instance=document).data
document = factories.DocumentFactory(link_reach=reach, link_role=role)
old_document_values = serializers.DocumentSerializer(instance=document).data
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
@@ -57,30 +83,79 @@ def test_api_documents_update_authenticated_unrelated():
format="json",
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
document.refresh_from_db()
document_values = serializers.DocumentSerializer(instance=document).data
assert document_values == old_document_values
@pytest.mark.parametrize("via", VIA)
def test_api_documents_update_authenticated_reader(via, mock_user_get_teams):
@pytest.mark.parametrize(
"is_authenticated,reach,role",
[
(False, "public", "editor"),
(True, "public", "editor"),
(True, "authenticated", "editor"),
],
)
def test_api_documents_update_anonymous_or_authenticated_unrelated(
is_authenticated, reach, role
):
"""
Users who are editors or reader of a document but not administrators should
Authenticated users should be able to update a document to which
they are not related if the link configuration allows it.
"""
client = APIClient()
if is_authenticated:
user = factories.UserFactory(with_owned_document=True)
client.force_login(user)
else:
user = AnonymousUser()
document = factories.DocumentFactory(link_reach=reach, link_role=role)
old_document_values = serializers.DocumentSerializer(instance=document).data
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
format="json",
)
assert response.status_code == 200
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"]:
assert value == old_document_values[key]
elif key == "updated_at":
assert value > old_document_values[key]
else:
assert value == new_document_values[key]
@pytest.mark.parametrize("via", VIA)
def test_api_documents_update_authenticated_reader(via, mock_user_teams):
"""
Users who are reader of a document but not administrators should
not be allowed to update it.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
document = factories.DocumentFactory(link_role="reader")
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="reader")
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="reader"
)
@@ -109,10 +184,10 @@ def test_api_documents_update_authenticated_reader(via, mock_user_get_teams):
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
@pytest.mark.parametrize("via", VIA)
def test_api_documents_update_authenticated_editor_administrator_or_owner(
via, role, mock_user_get_teams
via, role, mock_user_teams
):
"""A user who is editor, administrator or owner of a document should be allowed to update it."""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -121,7 +196,7 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
@@ -141,16 +216,18 @@ 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"]:
if key in ["id", "accesses", "created_at", "link_reach", "link_role"]:
assert value == old_document_values[key]
elif key == "updated_at":
assert value > old_document_values[key]
else:
assert value == new_document_values[key]
@pytest.mark.parametrize("via", VIA)
def test_api_documents_update_authenticated_owners(via, mock_user_get_teams):
def test_api_documents_update_authenticated_owners(via, mock_user_teams):
"""Administrators of a document should be allowed to update it."""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -159,7 +236,7 @@ def test_api_documents_update_authenticated_owners(via, mock_user_get_teams):
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="owner"
)
@@ -178,21 +255,21 @@ def test_api_documents_update_authenticated_owners(via, mock_user_get_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"]:
if key in ["id", "accesses", "created_at", "link_reach", "link_role"]:
assert value == old_document_values[key]
elif key == "updated_at":
assert value > old_document_values[key]
else:
assert value == new_document_values[key]
@pytest.mark.parametrize("via", VIA)
def test_api_documents_update_administrator_or_owner_of_another(
via, mock_user_get_teams
):
def test_api_documents_update_administrator_or_owner_of_another(via, mock_user_teams):
"""
Being administrator or owner of a document should not grant authorization to update
another document.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
@@ -203,28 +280,27 @@ def test_api_documents_update_administrator_or_owner_of_another(
document=document, user=user, role=random.choice(["administrator", "owner"])
)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document,
team="lasuite",
role=random.choice(["administrator", "owner"]),
)
is_public = random.choice([True, False])
document = factories.DocumentFactory(title="Old title", is_public=is_public)
old_document_values = serializers.DocumentSerializer(instance=document).data
other_document = factories.DocumentFactory(title="Old title", link_role="reader")
old_document_values = serializers.DocumentSerializer(instance=other_document).data
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
f"/api/v1.0/documents/{other_document.id!s}/",
new_document_values,
format="json",
)
assert response.status_code == 403 if is_public else 404
assert response.status_code == 403
document.refresh_from_db()
document_values = serializers.DocumentSerializer(instance=document).data
assert document_values == old_document_values
other_document.refresh_from_db()
other_document_values = serializers.DocumentSerializer(instance=other_document).data
assert other_document_values == old_document_values

View File

@@ -1,6 +1,7 @@
"""
Test suite for generated openapi schema.
"""
import json
from io import StringIO

View File

@@ -1,6 +1,7 @@
"""
Test template accesses API endpoints for users in impress's core app.
"""
import random
from uuid import uuid4
@@ -31,7 +32,7 @@ def test_api_template_accesses_list_authenticated_unrelated():
Authenticated users should not be allowed to list template accesses for a template
to which they are not related.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
@@ -56,17 +57,18 @@ def test_api_template_accesses_list_authenticated_unrelated():
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_list_authenticated_related(via, mock_user_get_teams):
def test_api_template_accesses_list_authenticated_related(via, mock_user_teams):
"""
Authenticated users should be able to list template accesses for a template
to which they are directly related, whatever their role in the template.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
user_access = None
if via == USER:
user_access = models.TemplateAccess.objects.create(
template=template,
@@ -74,7 +76,7 @@ def test_api_template_accesses_list_authenticated_related(via, mock_user_get_tea
role=random.choice(models.RoleChoices.choices)[0],
)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
user_access = models.TemplateAccess.objects.create(
template=template,
team="lasuite",
@@ -144,7 +146,7 @@ def test_api_template_accesses_retrieve_authenticated_unrelated():
Authenticated users should not be allowed to retrieve a template access for
a template to which they are not related.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
@@ -170,16 +172,18 @@ def test_api_template_accesses_retrieve_authenticated_unrelated():
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
assert response.json() == {
"detail": "No TemplateAccess matches the given query."
}
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_retrieve_authenticated_related(via, mock_user_get_teams):
def test_api_template_accesses_retrieve_authenticated_related(via, mock_user_teams):
"""
A user who is related to a template should be allowed to retrieve the
associated template user accesses.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
@@ -188,7 +192,7 @@ def test_api_template_accesses_retrieve_authenticated_related(via, mock_user_get
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(template=template, team="lasuite")
access = factories.UserTemplateAccessFactory(template=template)
@@ -207,201 +211,6 @@ def test_api_template_accesses_retrieve_authenticated_related(via, mock_user_get
}
def test_api_template_accesses_create_anonymous():
"""Anonymous users should not be allowed to create template accesses."""
user = factories.UserFactory()
template = factories.TemplateFactory()
response = APIClient().post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(user.id),
"template": str(template.id),
"role": random.choice(models.RoleChoices.choices)[0],
},
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert models.TemplateAccess.objects.exists() is False
def test_api_template_accesses_create_authenticated_unrelated():
"""
Authenticated users should not be allowed to create template accesses for a template to
which they are not related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
template = factories.TemplateFactory()
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
},
format="json",
)
assert response.status_code == 403
assert not models.TemplateAccess.objects.filter(user=other_user).exists()
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_create_authenticated_editor_or_reader(
via, role, mock_user_get_teams
):
"""Editors or readers of a template should not be allowed to create template accesses."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
)
other_user = factories.UserFactory()
for new_role in [role[0] for role in models.RoleChoices.choices]:
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"role": new_role,
},
format="json",
)
assert response.status_code == 403
assert not models.TemplateAccess.objects.filter(user=other_user).exists()
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_create_authenticated_administrator(
via, mock_user_get_teams
):
"""
Administrators of a template should be able to create template accesses
except for the "owner" role.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
other_user = factories.UserFactory()
# It should not be allowed to create an owner access
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"role": "owner",
},
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "Only owners of a resource can assign other users as owners."
}
# It should be allowed to create a lower access
role = random.choice(
[role[0] for role in models.RoleChoices.choices if role[0] != "owner"]
)
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.TemplateAccess.objects.filter(user=other_user).count() == 1
new_template_access = models.TemplateAccess.objects.filter(user=other_user).get()
assert response.json() == {
"abilities": new_template_access.get_abilities(user),
"id": str(new_template_access.id),
"team": "",
"role": role,
"user": str(other_user.id),
}
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_create_authenticated_owner(via, mock_user_get_teams):
"""
Owners of a template should be able to create template accesses whatever the role.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
other_user = factories.UserFactory()
role = random.choice([role[0] for role in models.RoleChoices.choices])
response = client.post(
f"/api/v1.0/templates/{template.id!s}/accesses/",
{
"user": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.TemplateAccess.objects.filter(user=other_user).count() == 1
new_template_access = models.TemplateAccess.objects.filter(user=other_user).get()
assert response.json() == {
"id": str(new_template_access.id),
"user": str(other_user.id),
"team": "",
"role": role,
"abilities": new_template_access.get_abilities(user),
}
def test_api_template_accesses_update_anonymous():
"""Anonymous users should not be allowed to update a template access."""
access = factories.UserTemplateAccessFactory()
@@ -432,14 +241,14 @@ def test_api_template_accesses_update_authenticated_unrelated():
Authenticated users should not be allowed to update a template access for a template to which
they are not related.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
access = factories.UserTemplateAccessFactory()
old_values = serializers.TemplateAccessSerializer(instance=access).data
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user": factories.UserFactory().id,
@@ -462,10 +271,10 @@ def test_api_template_accesses_update_authenticated_unrelated():
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_authenticated_editor_or_reader(
via, role, mock_user_get_teams
via, role, mock_user_teams
):
"""Editors or readers of a template should not be allowed to update its accesses."""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
@@ -474,7 +283,7 @@ def test_api_template_accesses_update_authenticated_editor_or_reader(
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
)
@@ -502,14 +311,12 @@ def test_api_template_accesses_update_authenticated_editor_or_reader(
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_administrator_except_owner(
via, mock_user_get_teams
):
def test_api_template_accesses_update_administrator_except_owner(via, mock_user_teams):
"""
A user who is a direct administrator in a template should be allowed to update a user
access for this template, as long as they don't try to set the role to owner.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
@@ -520,7 +327,7 @@ def test_api_template_accesses_update_administrator_except_owner(
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
@@ -529,8 +336,8 @@ def test_api_template_accesses_update_administrator_except_owner(
template=template,
role=random.choice(["administrator", "editor", "reader"]),
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
@@ -561,14 +368,12 @@ def test_api_template_accesses_update_administrator_except_owner(
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_administrator_from_owner(
via, mock_user_get_teams
):
def test_api_template_accesses_update_administrator_from_owner(via, mock_user_teams):
"""
A user who is an administrator in a template, should not be allowed to update
the user access of an "owner" for this template.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
@@ -579,7 +384,7 @@ def test_api_template_accesses_update_administrator_from_owner(
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
@@ -588,8 +393,8 @@ def test_api_template_accesses_update_administrator_from_owner(
access = factories.UserTemplateAccessFactory(
template=template, user=other_user, role="owner"
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
@@ -610,12 +415,12 @@ def test_api_template_accesses_update_administrator_from_owner(
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_administrator_to_owner(via, mock_user_get_teams):
def test_api_template_accesses_update_administrator_to_owner(via, mock_user_teams):
"""
A user who is an administrator in a template, should not be allowed to update
the user access of another user to grant template ownership.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
@@ -626,7 +431,7 @@ def test_api_template_accesses_update_administrator_to_owner(via, mock_user_get_
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
@@ -637,8 +442,8 @@ def test_api_template_accesses_update_administrator_to_owner(via, mock_user_get_
user=other_user,
role=random.choice(["administrator", "editor", "reader"]),
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
@@ -664,12 +469,12 @@ def test_api_template_accesses_update_administrator_to_owner(via, mock_user_get_
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_owner(via, mock_user_get_teams):
def test_api_template_accesses_update_owner(via, mock_user_teams):
"""
A user who is an owner in a template should be allowed to update
a user access for this template whatever the role.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
@@ -678,7 +483,7 @@ def test_api_template_accesses_update_owner(via, mock_user_get_teams):
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
@@ -687,8 +492,8 @@ def test_api_template_accesses_update_owner(via, mock_user_get_teams):
access = factories.UserTemplateAccessFactory(
template=template,
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_values = {
"id": uuid4(),
"user_id": factories.UserFactory().id,
@@ -720,26 +525,26 @@ def test_api_template_accesses_update_owner(via, mock_user_get_teams):
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_update_owner_self(via, mock_user_get_teams):
def test_api_template_accesses_update_owner_self(via, mock_user_teams):
"""
A user who is owner of a template should be allowed to update
their own user access provided there are other owners in the template.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
if via == USER:
access = factories.UserTemplateAccessFactory(
template=template, user=user, role="owner"
)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
if via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
access = factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
else:
access = factories.UserTemplateAccessFactory(
template=template, user=user, role="owner"
)
old_values = serializers.TemplateAccessSerializer(instance=access).data
new_role = random.choice(["administrator", "editor", "reader"])
@@ -788,7 +593,7 @@ def test_api_template_accesses_delete_authenticated():
Authenticated users should not be allowed to delete a template access for a
template to which they are not related.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
@@ -800,17 +605,17 @@ def test_api_template_accesses_delete_authenticated():
)
assert response.status_code == 403
assert models.TemplateAccess.objects.count() == 1
assert models.TemplateAccess.objects.count() == 2
@pytest.mark.parametrize("role", ["reader", "editor"])
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_editor_or_reader(via, role, mock_user_get_teams):
def test_api_template_accesses_delete_editor_or_reader(via, role, mock_user_teams):
"""
Authenticated users should not be allowed to delete a template access for a
template in which they are a simple editor or reader.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
@@ -819,14 +624,14 @@ def test_api_template_accesses_delete_editor_or_reader(via, role, mock_user_get_
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
)
access = factories.UserTemplateAccessFactory(template=template)
assert models.TemplateAccess.objects.count() == 2
assert models.TemplateAccess.objects.count() == 3
assert models.TemplateAccess.objects.filter(user=access.user).exists()
response = client.delete(
@@ -834,12 +639,12 @@ def test_api_template_accesses_delete_editor_or_reader(via, role, mock_user_get_
)
assert response.status_code == 403
assert models.TemplateAccess.objects.count() == 2
assert models.TemplateAccess.objects.count() == 3
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_administrators_except_owners(
via, mock_user_get_teams
via, mock_user_teams
):
"""
Users who are administrators in a template should be allowed to delete an access
@@ -856,7 +661,7 @@ def test_api_template_accesses_delete_administrators_except_owners(
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
@@ -877,12 +682,12 @@ def test_api_template_accesses_delete_administrators_except_owners(
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_administrator_on_owners(via, mock_user_get_teams):
def test_api_template_accesses_delete_administrator_on_owners(via, mock_user_teams):
"""
Users who are administrators in a template should not be allowed to delete an ownership
access from the template.
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
@@ -893,14 +698,14 @@ def test_api_template_accesses_delete_administrator_on_owners(via, mock_user_get
template=template, user=user, role="administrator"
)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="administrator"
)
access = factories.UserTemplateAccessFactory(template=template, role="owner")
assert models.TemplateAccess.objects.count() == 2
assert models.TemplateAccess.objects.count() == 3
assert models.TemplateAccess.objects.filter(user=access.user).exists()
response = client.delete(
@@ -908,11 +713,11 @@ def test_api_template_accesses_delete_administrator_on_owners(via, mock_user_get
)
assert response.status_code == 403
assert models.TemplateAccess.objects.count() == 2
assert models.TemplateAccess.objects.count() == 3
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_owners(via, mock_user_get_teams):
def test_api_template_accesses_delete_owners(via, mock_user_teams):
"""
Users should be able to delete the template access of another user
for a template of which they are owner.
@@ -926,7 +731,7 @@ def test_api_template_accesses_delete_owners(via, mock_user_get_teams):
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
@@ -945,30 +750,31 @@ def test_api_template_accesses_delete_owners(via, mock_user_get_teams):
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_delete_owners_last_owner(via, mock_user_get_teams):
def test_api_template_accesses_delete_owners_last_owner(via, mock_user_teams):
"""
It should not be possible to delete the last owner access from a template
"""
user = factories.UserFactory()
user = factories.UserFactory(with_owned_template=True)
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory()
access = None
if via == USER:
access = factories.UserTemplateAccessFactory(
template=template, user=user, role="owner"
)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
access = factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
assert models.TemplateAccess.objects.count() == 1
assert models.TemplateAccess.objects.count() == 2
response = client.delete(
f"/api/v1.0/templates/{template.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
assert models.TemplateAccess.objects.count() == 1
assert models.TemplateAccess.objects.count() == 2

View File

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

View File

@@ -1,6 +1,7 @@
"""
Tests for Templates API endpoint in impress's core app: create
"""
import pytest
from rest_framework.test import APIClient

View File

@@ -1,6 +1,7 @@
"""
Tests for Templates API endpoint in impress's core app: delete
"""
import random
import pytest
@@ -48,7 +49,7 @@ def test_api_templates_delete_authenticated_unrelated():
@pytest.mark.parametrize("role", ["reader", "editor", "administrator"])
@pytest.mark.parametrize("via", VIA)
def test_api_templates_delete_authenticated_member_or_administrator(
via, role, mock_user_get_teams
via, role, mock_user_teams
):
"""
Authenticated users should not be allowed to delete a template for which they are
@@ -63,7 +64,7 @@ def test_api_templates_delete_authenticated_member_or_administrator(
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
)
@@ -80,7 +81,7 @@ def test_api_templates_delete_authenticated_member_or_administrator(
@pytest.mark.parametrize("via", VIA)
def test_api_templates_delete_authenticated_owner(via, mock_user_get_teams):
def test_api_templates_delete_authenticated_owner(via, mock_user_teams):
"""
Authenticated users should be able to delete a template they own.
"""
@@ -93,7 +94,7 @@ def test_api_templates_delete_authenticated_owner(via, mock_user_get_teams):
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)

View File

@@ -1,6 +1,7 @@
"""
Test users API endpoints in the impress core app.
"""
import pytest
from rest_framework.test import APIClient
@@ -43,8 +44,10 @@ def test_api_templates_generate_document_anonymous_not_public():
format="json",
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_templates_generate_document_authenticated_public():
@@ -86,22 +89,24 @@ def test_api_templates_generate_document_authenticated_not_public():
format="json",
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
@pytest.mark.parametrize("via", VIA)
def test_api_templates_generate_document_related(via, mock_user_get_teams):
def test_api_templates_generate_document_related(via, mock_user_teams):
"""Users related to a template can generate pdf document."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
access = None
if via == USER:
access = factories.UserTemplateAccessFactory(user=user)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
access = factories.TeamTemplateAccessFactory(team="lasuite")
data = {"body": "# Test markdown body"}
@@ -178,3 +183,26 @@ def test_api_templates_generate_document_type_unknown():
'"unknown" is not a valid choice.',
]
}
def test_api_templates_generate_document_export_docx():
"""Generate pdf document with the body type html."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template = factories.TemplateFactory(is_public=True)
data = {"body": "<p>Test body</p>", "body_type": "html", "format": "docx"}
response = client.post(
f"/api/v1.0/templates/{template.id!s}/generate-document/",
data,
format="json",
)
assert response.status_code == 200
assert (
response.headers["content-type"]
== "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
)

View File

@@ -1,11 +1,11 @@
"""
Tests for Templates API endpoint in impress's core app: list
"""
from unittest import mock
import pytest
from rest_framework.pagination import PageNumberPagination
from rest_framework.status import HTTP_200_OK
from rest_framework.test import APIClient
from core import factories
@@ -16,12 +16,12 @@ pytestmark = pytest.mark.django_db
def test_api_templates_list_anonymous():
"""Anonymous users should only be able to list public templates."""
factories.TemplateFactory.create_batch(2, is_public=False)
templates = factories.TemplateFactory.create_batch(2, is_public=True)
expected_ids = {str(template.id) for template in templates}
public_templates = factories.TemplateFactory.create_batch(2, is_public=True)
expected_ids = {str(template.id) for template in public_templates}
response = APIClient().get("/api/v1.0/templates/")
assert response.status_code == HTTP_200_OK
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 2
results_id = {result["id"] for result in results}
@@ -31,7 +31,7 @@ def test_api_templates_list_anonymous():
def test_api_templates_list_authenticated_direct():
"""
Authenticated users should be able to list templates they are a direct
owner/administrator/member of.
owner/administrator/member of or that are public.
"""
user = factories.UserFactory()
@@ -53,24 +53,24 @@ def test_api_templates_list_authenticated_direct():
"/api/v1.0/templates/",
)
assert response.status_code == HTTP_200_OK
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 7
results_id = {result["id"] for result in results}
assert expected_ids == results_id
def test_api_templates_list_authenticated_via_team(mock_user_get_teams):
def test_api_templates_list_authenticated_via_team(mock_user_teams):
"""
Authenticated users should be able to list templates they are a
owner/administrator/member of via a team.
owner/administrator/member of via a team or that are public.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
mock_user_get_teams.return_value = ["team1", "team2", "unknown"]
mock_user_teams.return_value = ["team1", "team2", "unknown"]
templates_team1 = [
access.template
@@ -90,7 +90,7 @@ def test_api_templates_list_authenticated_via_team(mock_user_get_teams):
response = client.get("/api/v1.0/templates/")
assert response.status_code == HTTP_200_OK
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 7
results_id = {result["id"] for result in results}
@@ -117,7 +117,7 @@ def test_api_templates_list_pagination(
"/api/v1.0/templates/",
)
assert response.status_code == HTTP_200_OK
assert response.status_code == 200
content = response.json()
assert content["count"] == 3
@@ -133,7 +133,7 @@ def test_api_templates_list_pagination(
"/api/v1.0/templates/?page=2",
)
assert response.status_code == HTTP_200_OK
assert response.status_code == 200
content = response.json()
assert content["count"] == 3
@@ -160,26 +160,24 @@ def test_api_templates_list_authenticated_distinct():
"/api/v1.0/templates/",
)
assert response.status_code == HTTP_200_OK
assert response.status_code == 200
content = response.json()
assert len(content["results"]) == 1
assert content["results"][0]["id"] == str(template.id)
def test_api_templates_order():
"""
Test that the endpoint GET templates is sorted in 'created_at' descending order by default.
"""
def test_api_templates_list_order_default():
"""The templates list should be sorted by 'created_at' in descending order by default."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
template_ids = [
str(template.id)
for template in factories.TemplateFactory.create_batch(5, is_public=True)
str(access.template.id)
for access in factories.UserTemplateAccessFactory.create_batch(5, user=user)
]
response = APIClient().get(
response = client.get(
"/api/v1.0/templates/",
)
@@ -194,21 +192,21 @@ def test_api_templates_order():
), "created_at values are not sorted from newest to oldest"
def test_api_templates_order_param():
def test_api_templates_list_order_param():
"""
Test that the 'created_at' field is sorted in ascending order
when the 'ordering' query parameter is set.
The templates list is sorted by 'created_at' in ascending order when setting
the "ordering" query parameter.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
templates_ids = [
str(template.id)
for template in factories.TemplateFactory.create_batch(5, is_public=True)
str(access.template.id)
for access in factories.UserTemplateAccessFactory.create_batch(5, user=user)
]
response = APIClient().get(
response = client.get(
"/api/v1.0/templates/?ordering=created_at",
)
assert response.status_code == 200

View File

@@ -1,6 +1,7 @@
"""
Tests for Templates API endpoint in impress's core app: retrieve
"""
import pytest
from rest_framework.test import APIClient
@@ -40,8 +41,10 @@ def test_api_templates_retrieve_anonymous_not_public():
response = APIClient().get(f"/api/v1.0/templates/{template.id!s}/")
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_templates_retrieve_authenticated_unrelated_public():
@@ -93,8 +96,10 @@ def test_api_templates_retrieve_authenticated_unrelated_not_public():
response = client.get(
f"/api/v1.0/templates/{template.id!s}/",
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
def test_api_templates_retrieve_authenticated_related_direct():
@@ -145,12 +150,12 @@ def test_api_templates_retrieve_authenticated_related_direct():
}
def test_api_templates_retrieve_authenticated_related_team_none(mock_user_get_teams):
def test_api_templates_retrieve_authenticated_related_team_none(mock_user_teams):
"""
Authenticated users should not be able to retrieve a template related to teams in
which the user is not.
"""
mock_user_get_teams.return_value = []
mock_user_teams.return_value = []
user = factories.UserFactory()
@@ -173,8 +178,10 @@ def test_api_templates_retrieve_authenticated_related_team_none(mock_user_get_te
factories.TeamTemplateAccessFactory()
response = client.get(f"/api/v1.0/templates/{template.id!s}/")
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
@pytest.mark.parametrize(
@@ -187,13 +194,13 @@ def test_api_templates_retrieve_authenticated_related_team_none(mock_user_get_te
],
)
def test_api_templates_retrieve_authenticated_related_team_readers_or_editors(
teams, mock_user_get_teams
teams, mock_user_teams
):
"""
Authenticated users should be allowed to retrieve a template to which they
are related via a team whatever the role and see all its accesses.
"""
mock_user_get_teams.return_value = teams
mock_user_teams.return_value = teams
user = factories.UserFactory()
@@ -286,13 +293,13 @@ def test_api_templates_retrieve_authenticated_related_team_readers_or_editors(
],
)
def test_api_templates_retrieve_authenticated_related_team_administrators(
teams, mock_user_get_teams
teams, mock_user_teams
):
"""
Authenticated users should be allowed to retrieve a template to which they
are related via a team whatever the role and see all its accesses.
"""
mock_user_get_teams.return_value = teams
mock_user_teams.return_value = teams
user = factories.UserFactory()
@@ -404,13 +411,13 @@ def test_api_templates_retrieve_authenticated_related_team_administrators(
],
)
def test_api_templates_retrieve_authenticated_related_team_owners(
teams, mock_user_get_teams
teams, mock_user_teams
):
"""
Authenticated users should be allowed to retrieve a template to which they
are related via a team whatever the role and see all its accesses.
"""
mock_user_get_teams.return_value = teams
mock_user_teams.return_value = teams
user = factories.UserFactory()

View File

@@ -1,6 +1,7 @@
"""
Tests for Templates API endpoint in impress's core app: update
"""
import random
import pytest
@@ -57,8 +58,10 @@ def test_api_templates_update_authenticated_unrelated():
format="json",
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
template.refresh_from_db()
template_values = serializers.TemplateSerializer(instance=template).data
@@ -66,7 +69,7 @@ def test_api_templates_update_authenticated_unrelated():
@pytest.mark.parametrize("via", VIA)
def test_api_templates_update_authenticated_readers(via, mock_user_get_teams):
def test_api_templates_update_authenticated_readers(via, mock_user_teams):
"""
Users who are readers of a template should not be allowed to update it.
"""
@@ -79,7 +82,7 @@ def test_api_templates_update_authenticated_readers(via, mock_user_get_teams):
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="reader")
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="reader"
)
@@ -108,7 +111,7 @@ def test_api_templates_update_authenticated_readers(via, mock_user_get_teams):
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
@pytest.mark.parametrize("via", VIA)
def test_api_templates_update_authenticated_editor_or_administrator_or_owner(
via, role, mock_user_get_teams
via, role, mock_user_teams
):
"""Administrator or owner of a template should be allowed to update it."""
user = factories.UserFactory()
@@ -120,7 +123,7 @@ def test_api_templates_update_authenticated_editor_or_administrator_or_owner(
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role=role)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role=role
)
@@ -147,7 +150,7 @@ def test_api_templates_update_authenticated_editor_or_administrator_or_owner(
@pytest.mark.parametrize("via", VIA)
def test_api_templates_update_authenticated_owners(via, mock_user_get_teams):
def test_api_templates_update_authenticated_owners(via, mock_user_teams):
"""Administrators of a template should be allowed to update it."""
user = factories.UserFactory()
@@ -158,7 +161,7 @@ def test_api_templates_update_authenticated_owners(via, mock_user_get_teams):
if via == USER:
factories.UserTemplateAccessFactory(template=template, user=user, role="owner")
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template, team="lasuite", role="owner"
)
@@ -184,9 +187,7 @@ def test_api_templates_update_authenticated_owners(via, mock_user_get_teams):
@pytest.mark.parametrize("via", VIA)
def test_api_templates_update_administrator_or_owner_of_another(
via, mock_user_get_teams
):
def test_api_templates_update_administrator_or_owner_of_another(via, mock_user_teams):
"""
Being administrator or owner of a template should not grant authorization to update
another template.
@@ -202,7 +203,7 @@ def test_api_templates_update_administrator_or_owner_of_another(
template=template, user=user, role=random.choice(["administrator", "owner"])
)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamTemplateAccessFactory(
template=template,
team="lasuite",

View File

@@ -1,6 +1,7 @@
"""
Test users API endpoints in the impress core app.
"""
import pytest
from rest_framework.test import APIClient
@@ -119,6 +120,8 @@ def test_api_users_retrieve_me_authenticated():
assert response.json() == {
"id": str(user.id),
"email": user.email,
"full_name": user.full_name,
"short_name": user.short_name,
}

View File

@@ -0,0 +1,127 @@
"""
Test throttling on documents for the AI endpoint.
"""
from unittest.mock import patch
from django.core.cache import cache
from django.test import override_settings
import pytest
from rest_framework.response import Response
from rest_framework.test import APIRequestFactory
from rest_framework.views import APIView
from core.api.utils import AIDocumentRateThrottle
class DocumentAPIView(APIView):
"""A simple view to test the throttle"""
throttle_classes = [AIDocumentRateThrottle]
def get(self, request, *args, **kwargs):
"""Minimal get method for testing purposes."""
return Response({"message": "Success"})
@pytest.fixture(autouse=True)
def clear_cache():
"""Fixture to clear the cache before each test."""
cache.clear()
@override_settings(AI_DOCUMENT_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
@patch("time.time")
def test_api_utils_ai_document_rate_throttle_minute_limit(mock_time):
"""Test that minute limit is enforced."""
api_rf = APIRequestFactory()
mock_time.return_value = 1000000
# Simulate requests to the document API
for _i in range(3): # 3 first requests should be allowed
request = api_rf.get("/documents/1/")
response = DocumentAPIView.as_view()(request, pk=1)
assert response.status_code == 200
# Simulate passage of time
mock_time.return_value += 59
# 4th request should be throttled
request = api_rf.get("/documents/1/")
response = DocumentAPIView.as_view()(request, pk=1)
assert response.status_code == 429
# After the 60s backoff wait time has passed, we can make a request again
mock_time.return_value += 1
request = api_rf.get("/documents/1/")
response = DocumentAPIView.as_view()(request, pk=1)
assert response.status_code == 200
@override_settings(
AI_DOCUMENT_RATE_THROTTLE_RATES={"minute": 100000, "hour": 6, "day": 10}
)
@patch("time.time")
def test_ai_document_rate_throttle_hour_limit(mock_time):
"""Test that the hour limit is enforced without hitting the minute limit."""
api_rf = APIRequestFactory()
mock_time.return_value = 1000000
# Make requests to the document API, one per 21 seconds to avoid hitting the minute limit
for _i in range(6):
request = api_rf.get("/documents/1/")
response = DocumentAPIView.as_view()(request, pk=1)
assert response.status_code == 200
# Simulate passage of time
mock_time.return_value += 21
# Simulate passage of time
mock_time.return_value += 3600 - 6 * 21 - 1
# 7th request should be throttled
request = api_rf.get("/documents/1/")
response = DocumentAPIView.as_view()(request, pk=1)
assert response.status_code == 429
# After the 1h backoff wait time has passed, we can make a request again
mock_time.return_value += 1
request = api_rf.get("/documents/1/")
response = DocumentAPIView.as_view()(request, pk=1)
assert response.status_code == 200
@override_settings(AI_DOCUMENT_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
@patch("time.time")
def test_api_utils_ai_document_rate_throttle_day_limit(mock_time):
"""Test that day limit is enforced."""
api_rf = APIRequestFactory()
mock_time.return_value = 1000000
# Make requests to the document API, one per 10 minutes to avoid hitting
# the minute and hour limits
for _i in range(10): # 10 requests should be allowed
request = api_rf.get("/documents/1/")
response = DocumentAPIView.as_view()(request, pk=1)
assert response.status_code == 200
# Simulate passage of time
mock_time.return_value += 60 * 10
# Simulate passage of time
mock_time.return_value += 24 * 3600 - 10 * 60 * 10 - 1
# 11th request should be throttled
request = api_rf.get("/documents/1/")
response = DocumentAPIView.as_view()(request, pk=1)
assert response.status_code == 429
# After the 24h backoff wait time has passed we can make a request again
mock_time.return_value += 1
request = api_rf.get("/documents/1/")
response = DocumentAPIView.as_view()(request, pk=1)
assert response.status_code == 200

View File

@@ -0,0 +1,146 @@
"""
Test throttling on users for the AI endpoint.
"""
from unittest.mock import patch
from uuid import uuid4
from django.core.cache import cache
from django.test import override_settings
import pytest
from rest_framework.response import Response
from rest_framework.test import APIRequestFactory
from rest_framework.views import APIView
from core.api.utils import AIUserRateThrottle
from core.factories import UserFactory
pytestmark = pytest.mark.django_db
class DocumentAPIView(APIView):
"""A simple view to test the throttle"""
throttle_classes = [AIUserRateThrottle]
def get(self, request, *args, **kwargs):
"""Minimal get method for testing purposes."""
return Response({"message": "Success"})
@pytest.fixture(autouse=True)
def clear_cache():
"""Fixture to clear the cache before each test."""
cache.clear()
@override_settings(AI_USER_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
@patch("time.time")
def test_api_utils_ai_user_rate_throttle_minute_limit(mock_time):
"""Test that minute limit is enforced."""
user = UserFactory()
api_rf = APIRequestFactory()
mock_time.return_value = 1000000
# Simulate requests to the document API
for _i in range(3): # 3 first requests should be allowed
document_id = str(uuid4())
request = api_rf.get(f"/documents/{document_id:s}/")
request.user = user
response = DocumentAPIView.as_view()(request, pk=document_id)
assert response.status_code == 200
# Simulate passage of time
mock_time.return_value += 59
# 4th request should be throttled
document_id = str(uuid4())
request = api_rf.get(f"/documents/{document_id:s}/")
request.user = user
response = DocumentAPIView.as_view()(request, pk=document_id)
assert response.status_code == 429
# After the 60s backoff wait time has passed, we can make a request again
mock_time.return_value += 1
document_id = str(uuid4())
request = api_rf.get(f"/documents/{document_id:s}/")
request.user = user
response = DocumentAPIView.as_view()(request, pk=document_id)
assert response.status_code == 200
@override_settings(AI_USER_RATE_THROTTLE_RATES={"minute": 100000, "hour": 6, "day": 10})
@patch("time.time")
def test_ai_user_rate_throttle_hour_limit(mock_time):
"""Test that the hour limit is enforced without hitting the minute limit."""
user = UserFactory()
api_rf = APIRequestFactory()
mock_time.return_value = 1000000
# Make requests to the document API, one per 21 seconds to avoid hitting the minute limit
for _i in range(6):
document_id = str(uuid4())
request = api_rf.get(f"/documents/{document_id:s}/")
request.user = user
response = DocumentAPIView.as_view()(request, pk=document_id)
assert response.status_code == 200
# Simulate passage of time
mock_time.return_value += 21
# Simulate passage of time
mock_time.return_value += 3600 - 6 * 21 - 1
# 7th request should be throttled
request = api_rf.get(f"/documents/{document_id:s}/")
request.user = user
response = DocumentAPIView.as_view()(request, pk=document_id)
assert response.status_code == 429
# After the 1h backoff wait time has passed, we can make a request again
mock_time.return_value += 1
request = api_rf.get(f"/documents/{document_id:s}/")
request.user = user
response = DocumentAPIView.as_view()(request, pk=document_id)
assert response.status_code == 200
@override_settings(AI_USER_RATE_THROTTLE_RATES={"minute": 3, "hour": 6, "day": 10})
@patch("time.time")
def test_api_utils_ai_user_rate_throttle_day_limit(mock_time):
"""Test that day limit is enforced."""
user = UserFactory()
api_rf = APIRequestFactory()
mock_time.return_value = 1000000
# Make requests to the document API, one per 10 minutes to avoid hitting
# the minute and hour limits
for _i in range(10): # 10 requests should be allowed
document_id = str(uuid4())
request = api_rf.get(f"/documents/{document_id:s}/")
request.user = user
response = DocumentAPIView.as_view()(request, pk=document_id)
assert response.status_code == 200
# Simulate passage of time
mock_time.return_value += 60 * 10
# Simulate passage of time
mock_time.return_value += 24 * 3600 - 10 * 60 * 10 - 1
# 11th request should be throttled
request = api_rf.get(f"/documents/{document_id:s}/")
request.user = user
response = DocumentAPIView.as_view()(request, pk=document_id)
assert response.status_code == 429
# After the 24h backoff wait time has passed we can make a request again
mock_time.return_value += 1
request = api_rf.get(f"/documents/{document_id:s}/")
request.user = user
response = DocumentAPIView.as_view()(request, pk=document_id)
assert response.status_code == 200

View File

@@ -1,6 +1,7 @@
"""
Unit tests for the DocumentAccess model
"""
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
@@ -318,7 +319,7 @@ def test_models_document_access_get_abilities_for_editor_of_administrator():
def test_models_document_access_get_abilities_for_editor_of_editor_user(
django_assert_num_queries
django_assert_num_queries,
):
"""Check abilities of editor access for the editor of a document."""
access = factories.UserDocumentAccessFactory(role="editor")
@@ -377,7 +378,7 @@ def test_models_document_access_get_abilities_for_reader_of_administrator():
def test_models_document_access_get_abilities_for_reader_of_reader_user(
django_assert_num_queries
django_assert_num_queries,
):
"""Check abilities of reader access for the reader of a document."""
access = factories.UserDocumentAccessFactory(role="reader")

View File

@@ -1,9 +1,16 @@
"""
Unit tests for the Document model
"""
import smtplib
from logging import Logger
from unittest import mock
from django.contrib.auth.models import AnonymousUser
from django.core import mail
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.utils import timezone
import pytest
@@ -26,15 +33,15 @@ def test_models_documents_id_unique():
def test_models_documents_title_null():
"""The "title" field should not be null."""
with pytest.raises(ValidationError, match="This field cannot be null."):
models.Document.objects.create(title=None)
"""The "title" field can be null."""
document = models.Document.objects.create(title=None)
assert document.title is None
def test_models_documents_title_empty():
"""The "title" field should not be empty."""
with pytest.raises(ValidationError, match="This field cannot be blank."):
models.Document.objects.create(title="")
"""The "title" field can be empty."""
document = models.Document.objects.create(title="")
assert document.title == ""
def test_models_documents_title_max_length():
@@ -56,64 +63,99 @@ def test_models_documents_file_key():
# get_abilities
def test_models_documents_get_abilities_anonymous_public():
"""Check abilities returned for an anonymous user if the document is public."""
document = factories.DocumentFactory(is_public=True)
abilities = document.get_abilities(AnonymousUser())
@pytest.mark.parametrize(
"is_authenticated,reach,role",
[
(True, "restricted", "reader"),
(True, "restricted", "editor"),
(False, "restricted", "reader"),
(False, "restricted", "editor"),
(False, "authenticated", "reader"),
(False, "authenticated", "editor"),
],
)
def test_models_documents_get_abilities_forbidden(is_authenticated, reach, role):
"""
Check abilities returned for a document giving insufficient roles to link holders
i.e anonymous users or authenticated users who have no specific role on the document.
"""
document = factories.DocumentFactory(link_reach=reach, link_role=role)
user = factories.UserFactory() if is_authenticated else AnonymousUser()
abilities = document.get_abilities(user)
assert abilities == {
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"link_configuration": False,
"destroy": False,
"retrieve": True,
"update": False,
"manage_accesses": False,
"partial_update": False,
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
}
def test_models_documents_get_abilities_anonymous_not_public():
"""Check abilities returned for an anonymous user if the document is private."""
document = factories.DocumentFactory(is_public=False)
abilities = document.get_abilities(AnonymousUser())
assert abilities == {
"destroy": False,
"retrieve": False,
"update": False,
"manage_accesses": False,
"partial_update": False,
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
}
def test_models_documents_get_abilities_authenticated_unrelated_public():
"""Check abilities returned for an authenticated user if the user is public."""
document = factories.DocumentFactory(is_public=True)
abilities = document.get_abilities(factories.UserFactory())
@pytest.mark.parametrize(
"is_authenticated,reach",
[
(True, "public"),
(False, "public"),
(True, "authenticated"),
],
)
def test_models_documents_get_abilities_reader(is_authenticated, reach):
"""
Check abilities returned for a document giving reader role to link holders
i.e anonymous users or authenticated users who have no specific role on the document.
"""
document = factories.DocumentFactory(link_reach=reach, link_role="reader")
user = factories.UserFactory() if is_authenticated else AnonymousUser()
abilities = document.get_abilities(user)
assert abilities == {
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"destroy": False,
"link_configuration": False,
"manage_accesses": False,
"partial_update": False,
"retrieve": True,
"update": False,
"manage_accesses": False,
"partial_update": False,
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
}
def test_models_documents_get_abilities_authenticated_unrelated_not_public():
"""Check abilities returned for an authenticated user if the document is private."""
document = factories.DocumentFactory(is_public=False)
abilities = document.get_abilities(factories.UserFactory())
@pytest.mark.parametrize(
"is_authenticated,reach",
[
(True, "public"),
(False, "public"),
(True, "authenticated"),
],
)
def test_models_documents_get_abilities_editor(is_authenticated, reach):
"""
Check abilities returned for a document giving editor role to link holders
i.e anonymous users or authenticated users who have no specific role on the document.
"""
document = factories.DocumentFactory(link_reach=reach, link_role="editor")
user = factories.UserFactory() if is_authenticated else AnonymousUser()
abilities = document.get_abilities(user)
assert abilities == {
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"destroy": False,
"retrieve": False,
"update": False,
"link_configuration": False,
"manage_accesses": False,
"partial_update": False,
"partial_update": True,
"retrieve": True,
"update": True,
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
@@ -126,11 +168,15 @@ def test_models_documents_get_abilities_owner():
access = factories.UserDocumentAccessFactory(role="owner", user=user)
abilities = access.document.get_abilities(access.user)
assert abilities == {
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"destroy": True,
"retrieve": True,
"update": True,
"link_configuration": True,
"manage_accesses": True,
"partial_update": True,
"retrieve": True,
"update": True,
"versions_destroy": True,
"versions_list": True,
"versions_retrieve": True,
@@ -142,11 +188,15 @@ def test_models_documents_get_abilities_administrator():
access = factories.UserDocumentAccessFactory(role="administrator")
abilities = access.document.get_abilities(access.user)
assert abilities == {
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"destroy": False,
"retrieve": True,
"update": True,
"link_configuration": True,
"manage_accesses": True,
"partial_update": True,
"retrieve": True,
"update": True,
"versions_destroy": True,
"versions_list": True,
"versions_retrieve": True,
@@ -161,11 +211,15 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
abilities = access.document.get_abilities(access.user)
assert abilities == {
"ai_transform": True,
"ai_translate": True,
"attachment_upload": True,
"destroy": False,
"retrieve": True,
"update": True,
"link_configuration": False,
"manage_accesses": False,
"partial_update": True,
"retrieve": True,
"update": True,
"versions_destroy": False,
"versions_list": True,
"versions_retrieve": True,
@@ -174,17 +228,23 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
def test_models_documents_get_abilities_reader_user(django_assert_num_queries):
"""Check abilities returned for the reader of a document."""
access = factories.UserDocumentAccessFactory(role="reader")
access = factories.UserDocumentAccessFactory(
role="reader", document__link_role="reader"
)
with django_assert_num_queries(1):
abilities = access.document.get_abilities(access.user)
assert abilities == {
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"destroy": False,
"retrieve": True,
"update": False,
"link_configuration": False,
"manage_accesses": False,
"partial_update": False,
"retrieve": True,
"update": False,
"versions_destroy": False,
"versions_list": True,
"versions_retrieve": True,
@@ -193,30 +253,36 @@ def test_models_documents_get_abilities_reader_user(django_assert_num_queries):
def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
"""No query is done if the role is preset e.g. with query annotation."""
access = factories.UserDocumentAccessFactory(role="reader")
access = factories.UserDocumentAccessFactory(
role="reader", document__link_role="reader"
)
access.document.user_roles = ["reader"]
with django_assert_num_queries(0):
abilities = access.document.get_abilities(access.user)
assert abilities == {
"ai_transform": False,
"ai_translate": False,
"attachment_upload": False,
"destroy": False,
"retrieve": True,
"update": False,
"link_configuration": False,
"manage_accesses": False,
"partial_update": False,
"retrieve": True,
"update": False,
"versions_destroy": False,
"versions_list": True,
"versions_retrieve": True,
}
def test_models_documents_get_versions_slice(settings):
def test_models_documents_get_versions_slice_pagination(settings):
"""
The "get_versions_slice" method should allow navigating all versions of
the document with pagination.
"""
settings.S3_VERSIONS_PAGE_SIZE = 4
settings.DOCUMENT_VERSIONS_PAGE_SIZE = 4
# Create a document with 7 versions
document = factories.DocumentFactory()
@@ -224,7 +290,7 @@ def test_models_documents_get_versions_slice(settings):
document.content = f"bar{i:d}"
document.save()
# Add a version not related to the first document
# Add a document version not related to the first document
factories.DocumentFactory()
# - Get default max versions
@@ -242,7 +308,7 @@ def test_models_documents_get_versions_slice(settings):
from_version_id=response["next_version_id_marker"]
)
assert response["is_truncated"] is False
assert len(response["versions"]) == 3
assert len(response["versions"]) == 2
assert response["next_version_id_marker"] == ""
# - Get custom max versions
@@ -252,6 +318,30 @@ def test_models_documents_get_versions_slice(settings):
assert response["next_version_id_marker"] != ""
def test_models_documents_get_versions_slice_min_datetime():
"""
The "get_versions_slice" method should filter out versions anterior to
the from_datetime passed in argument and the current version.
"""
document = factories.DocumentFactory()
from_dt = []
for i in range(6):
from_dt.append(timezone.now())
document.content = f"bar{i:d}"
document.save()
response = document.get_versions_slice(min_datetime=from_dt[2])
assert len(response["versions"]) == 3
for version in response["versions"]:
assert version["last_modified"] > from_dt[2]
response = document.get_versions_slice(min_datetime=from_dt[4])
assert len(response["versions"]) == 1
assert response["versions"][0]["last_modified"] > from_dt[4]
def test_models_documents_version_duplicate():
"""A new version should be created in object storage only if the content has changed."""
document = factories.DocumentFactory()
@@ -278,3 +368,105 @@ def test_models_documents_version_duplicate():
Bucket=default_storage.bucket_name, Prefix=file_key
)
assert len(response["Versions"]) == 2
def test_models_documents__email_invitation__success():
"""
The email invitation is sent successfully.
"""
document = factories.DocumentFactory()
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
sender = factories.UserFactory(full_name="Test Sender", email="sender@example.com")
document.email_invitation(
"en", "guest@example.com", models.RoleChoices.EDITOR, sender
)
# pylint: disable-next=no-member
assert len(mail.outbox) == 1
# pylint: disable-next=no-member
email = mail.outbox[0]
assert email.to == ["guest@example.com"]
email_content = " ".join(email.body.split())
assert (
f'Test Sender (sender@example.com) invited you with the role "editor" '
f"on the following document : {document.title}" in email_content
)
assert f"docs/{document.id}/" in email_content
def test_models_documents__email_invitation__success_fr():
"""
The email invitation is sent successfully in french.
"""
document = factories.DocumentFactory()
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
sender = factories.UserFactory(
full_name="Test Sender2", email="sender2@example.com"
)
document.email_invitation(
"fr-fr",
"guest2@example.com",
models.RoleChoices.OWNER,
sender,
)
# pylint: disable-next=no-member
assert len(mail.outbox) == 1
# pylint: disable-next=no-member
email = mail.outbox[0]
assert email.to == ["guest2@example.com"]
email_content = " ".join(email.body.split())
assert (
f'Test Sender2 (sender2@example.com) vous a invité avec le rôle "propriétaire" '
f"sur le document suivant : {document.title}" in email_content
)
assert f"docs/{document.id}/" in email_content
@mock.patch(
"core.models.send_mail",
side_effect=smtplib.SMTPException("Error SMTPException"),
)
@mock.patch.object(Logger, "error")
def test_models_documents__email_invitation__failed(mock_logger, _mock_send_mail):
"""Check mail behavior when an SMTP error occurs when sent an email invitation."""
document = factories.DocumentFactory()
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
sender = factories.UserFactory()
document.email_invitation(
"en",
"guest3@example.com",
models.RoleChoices.ADMIN,
sender,
)
# No email has been sent
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
# Logger should be called
mock_logger.assert_called_once()
(
_,
email,
exception,
) = mock_logger.call_args.args
assert email == "guest3@example.com"
assert isinstance(exception, smtplib.SMTPException)

View File

@@ -2,13 +2,10 @@
Unit tests for the Invitation model
"""
import smtplib
import time
from logging import Logger
from unittest import mock
from django.contrib.auth.models import AnonymousUser
from django.core import exceptions, mail
from django.core import exceptions
import pytest
from faker import Faker
@@ -23,13 +20,6 @@ pytestmark = pytest.mark.django_db
fake = Faker()
def test_models_invitations_readonly_after_create():
"""Existing invitations should be readonly."""
invitation = factories.InvitationFactory()
with pytest.raises(exceptions.PermissionDenied):
invitation.save()
def test_models_invitations_email_no_empty_mail():
"""The "email" field should not be empty."""
with pytest.raises(exceptions.ValidationError, match="This field cannot be blank"):
@@ -168,67 +158,6 @@ def test_models_invitation__new_user__user_creation_constant_num_queries(
models.User.objects.create(email=user_email, password="!")
def test_models_document_invitations_email():
"""Check email invitation during invitation creation."""
member_access = factories.UserDocumentAccessFactory(role="reader")
document = member_access.document
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
factories.UserDocumentAccessFactory(document=document)
invitation = factories.InvitationFactory(document=document, email="john@people.com")
# pylint: disable-next=no-member
assert len(mail.outbox) == 1
# pylint: disable-next=no-member
email = mail.outbox[0]
assert email.to == [invitation.email]
assert email.subject == "Invitation to join Impress!"
email_content = " ".join(email.body.split())
assert "Invitation to join Impress!" in email_content
assert "[//example.com]" in email_content
@mock.patch(
"django.core.mail.send_mail",
side_effect=smtplib.SMTPException("Error SMTPException"),
)
@mock.patch.object(Logger, "error")
def test_models_document_invitations_email_failed(mock_logger, _mock_send_mail):
"""Check invitation behavior when an SMTP error occurs during invitation creation."""
member_access = factories.UserDocumentAccessFactory(role="reader")
document = member_access.document
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
factories.UserDocumentAccessFactory(document=document)
# No error should be raised
invitation = factories.InvitationFactory(document=document, email="john@people.com")
# No email has been sent
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
# Logger should be called
mock_logger.assert_called_once()
(
_,
email,
exception,
) = mock_logger.call_args.args
assert email == invitation.email
assert isinstance(exception, smtplib.SMTPException)
# get_abilities
@@ -260,7 +189,7 @@ def test_models_document_invitations_get_abilities_authenticated():
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize("role", ["administrator", "owner"])
def test_models_document_invitations_get_abilities_privileged_member(
role, via, mock_user_get_teams
role, via, mock_user_teams
):
"""Check abilities for a document member with a privileged role."""
@@ -269,7 +198,7 @@ def test_models_document_invitations_get_abilities_privileged_member(
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
@@ -282,13 +211,13 @@ def test_models_document_invitations_get_abilities_privileged_member(
assert abilities == {
"destroy": True,
"retrieve": True,
"partial_update": False,
"update": False,
"partial_update": True,
"update": True,
}
@pytest.mark.parametrize("via", VIA)
def test_models_document_invitations_get_abilities_reader(via, mock_user_get_teams):
def test_models_document_invitations_get_abilities_reader(via, mock_user_teams):
"""Check abilities for a document reader with 'reader' role."""
user = factories.UserFactory()
@@ -296,7 +225,7 @@ def test_models_document_invitations_get_abilities_reader(via, mock_user_get_tea
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="reader")
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="reader"
)
@@ -313,7 +242,7 @@ def test_models_document_invitations_get_abilities_reader(via, mock_user_get_tea
@pytest.mark.parametrize("via", VIA)
def test_models_document_invitations_get_abilities_editor(via, mock_user_get_teams):
def test_models_document_invitations_get_abilities_editor(via, mock_user_teams):
"""Check abilities for a document editor with 'editor' role."""
user = factories.UserFactory()
@@ -321,7 +250,7 @@ def test_models_document_invitations_get_abilities_editor(via, mock_user_get_tea
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="editor")
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="editor"
)

View File

@@ -1,6 +1,7 @@
"""
Unit tests for the TemplateAccess model
"""
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
@@ -318,7 +319,7 @@ def test_models_template_access_get_abilities_for_editor_of_administrator():
def test_models_template_access_get_abilities_for_editor_of_editor_user(
django_assert_num_queries
django_assert_num_queries,
):
"""Check abilities of editor access for the editor of a template."""
access = factories.UserTemplateAccessFactory(role="editor")
@@ -377,7 +378,7 @@ def test_models_template_access_get_abilities_for_reader_of_administrator():
def test_models_template_access_get_abilities_for_reader_of_reader_user(
django_assert_num_queries
django_assert_num_queries,
):
"""Check abilities of reader access for the reader of a template."""
access = factories.UserTemplateAccessFactory(role="reader")

View File

@@ -1,6 +1,11 @@
"""
Unit tests for the Template model
"""
import os
import time
from unittest import mock
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
@@ -184,3 +189,31 @@ def test_models_templates_get_abilities_preset_role(django_assert_num_queries):
"partial_update": False,
"generate_document": True,
}
def test_models_templates__generate_word():
"""Generate word document and assert no tmp files are left in /tmp folder."""
template = factories.TemplateFactory()
response = template.generate_word("<p>Test body</p>", {})
assert response.status_code == 200
assert len([f for f in os.listdir("/tmp") if f.startswith("docx_")]) == 0
@mock.patch(
"pypandoc.convert_text",
side_effect=RuntimeError("Conversion failed"),
)
def test_models_templates__generate_word__raise_error(_mock_pypandoc):
"""
Generate word document and assert no tmp files are left in /tmp folder
even when the conversion fails.
"""
template = factories.TemplateFactory()
try:
template.generate_word("<p>Test body</p>", {})
except RuntimeError as e:
assert str(e) == "Conversion failed"
time.sleep(0.5)
assert len([f for f in os.listdir("/tmp") if f.startswith("docx_")]) == 0

View File

@@ -1,6 +1,7 @@
"""
Unit tests for the User model
"""
from unittest import mock
from django.core.exceptions import ValidationError

View File

@@ -0,0 +1,104 @@
"""
Test ai API endpoints in the impress core app.
"""
import json
from unittest.mock import MagicMock, patch
from django.core.exceptions import ImproperlyConfigured
from django.test.utils import override_settings
import pytest
from openai import OpenAIError
from core.services.ai_services import AIService
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize(
"setting_name, setting_value",
[
("AI_BASE_URL", None),
("AI_API_KEY", None),
("AI_MODEL", None),
],
)
def test_api_ai_setting_missing(setting_name, setting_value):
"""Setting should be set"""
with override_settings(**{setting_name: setting_value}):
with pytest.raises(
ImproperlyConfigured,
match="AI configuration not set",
):
AIService()
@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__client_error(mock_create):
"""Fail when the client raises an error"""
mock_create.side_effect = OpenAIError("Mocked client error")
with pytest.raises(
OpenAIError,
match="Mocked client error",
):
AIService().transform("hello", "prompt")
@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__client_invalid_response(mock_create):
"""Fail when the client response is invalid"""
answer = {"no_answer": "This is an invalid response"}
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=json.dumps(answer)))]
)
with pytest.raises(
RuntimeError,
match="AI response does not contain an answer",
):
AIService().transform("hello", "prompt")
@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(mock_create):
"""The AI request should work as expect when called with valid arguments."""
answer = '{"answer": "Salut"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
response = AIService().transform("hello", "prompt")
assert response == {"answer": "Salut"}
@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_sanitize(mock_create):
"""The AI response should be sanitized"""
answer = '{"answer": "Salut\\n \tle \nmonde"}'
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=answer))]
)
response = AIService().transform("hello", "prompt")
assert response == {"answer": "Salut\n \tle \nmonde"}

View File

@@ -1,10 +1,11 @@
"""URL configuration for the core app."""
from django.conf import settings
from django.urls import include, path, re_path
from rest_framework.routers import DefaultRouter
from core.api import viewsets
from core.api import viewsets, create_summary
from core.authentication.urls import urlpatterns as oidc_urls
# - Main endpoints
@@ -43,6 +44,7 @@ urlpatterns = [
[
*router.urls,
*oidc_urls,
path("summary/", create_summary, name="create_summary"),
re_path(
r"^documents/(?P<resource_id>[0-9a-z-]*)/",
include(document_related_router.urls),

View File

@@ -1,7 +1,7 @@
<page size="A4">
<div class="header">
<img
src="https://upload.wikimedia.org/wikipedia/fr/7/72/Logo_du_Gouvernement_de_la_R%C3%A9publique_fran%C3%A7aise_%282020%29.svg"
<img width="200"
src="https://impress-staging.beta.numerique.gouv.fr/assets/logo-gouv.png"
/>
</div>
<div class="content">

View File

@@ -1,18 +1,20 @@
body {
background: white;
font-family: arial
}
.header {
display: flex;
justify-content: space-between;
font-family: arial;
}
.header img {
width: 5cm;
margin-left: -0.4cm;
}
.body{
margin-top: 1.5rem
margin-top: 1.5rem;
}
img {
max-width: 100%;
}
[custom-style="center"] {
text-align: center;
}
[custom-style="right"] {
text-align: right;
}

View File

@@ -1,5 +1,23 @@
"""Parameters that define how the demo site will be built."""
NB_OBJECTS = {
"users": 100,
"users": 50,
"docs": 50,
"max_users_per_document": 50,
}
DEV_USERS = [
{
"username": "impress",
"email": "impress@impress.world",
},
{
"username": "user-e2e-webkit",
"email": "user@webkit.e2e",
},
{
"username": "user-e2e-firefox",
"email": "user@firefox.e2e",
},
{"username": "user-e2e-chromium", "email": "user@chromium.e2e"},
]

View File

@@ -2,6 +2,7 @@
"""create_demo management command"""
import logging
import math
import random
import time
from collections import defaultdict
@@ -111,7 +112,11 @@ def create_demo(stdout):
queue = BulkQueue(stdout)
with Timeit(stdout, "Creating users"):
name_size = int(math.sqrt(defaults.NB_OBJECTS["users"]))
first_names = [fake.first_name() for _ in range(name_size)]
last_names = [fake.last_name() for _ in range(name_size)]
for i in range(defaults.NB_OBJECTS["users"]):
first_name = random.choice(first_names)
queue.push(
models.User(
admin_email=f"user{i:d}@example.com",
@@ -120,11 +125,74 @@ def create_demo(stdout):
is_superuser=False,
is_active=True,
is_staff=False,
short_name=first_name,
full_name=f"{first_name:s} {random.choice(last_names):s}",
language=random.choice(settings.LANGUAGES)[0],
)
)
queue.flush()
with Timeit(stdout, "Creating documents"):
for _ in range(defaults.NB_OBJECTS["docs"]):
queue.push(
models.Document(
title=fake.sentence(nb_words=4),
link_reach=models.LinkReachChoices.AUTHENTICATED
if random_true_with_probability(0.5)
else random.choice(models.LinkReachChoices.values),
)
)
queue.flush()
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,
random.randint(1, defaults.NB_OBJECTS["max_users_per_document"]),
):
role = random.choice(models.RoleChoices.choices)
queue.push(
models.DocumentAccess(
document_id=doc_id, user_id=user_id, role=role[0]
)
)
queue.flush()
with Timeit(stdout, "Creating development users"):
for dev_user in defaults.DEV_USERS:
queue.push(
models.User(
admin_email=dev_user["email"],
email=dev_user["email"],
sub=dev_user["email"],
password="!",
is_superuser=False,
is_active=True,
is_staff=False,
language=random.choice(settings.LANGUAGES)[0],
)
)
queue.flush()
with Timeit(stdout, "Creating docs accesses on development users"):
for dev_user in defaults.DEV_USERS:
docs_ids = list(models.Document.objects.values_list("id", flat=True))
user_id = models.User.objects.get(email=dev_user["email"]).id
for doc_id in docs_ids:
role = random.choice(models.RoleChoices.choices)
queue.push(
models.DocumentAccess(
document_id=doc_id, user_id=user_id, role=role[0]
)
)
queue.flush()
with Timeit(stdout, "Creating Template"):
with open(
file="demo/data/template/code.txt", mode="r", encoding="utf-8"

View File

@@ -1,4 +1,5 @@
"""Management user to create a superuser."""
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand

View File

@@ -16,3 +16,16 @@ def test_commands_create_demo():
call_command("create_demo")
assert models.Template.objects.count() == 1
assert models.User.objects.count() >= 50
assert models.Document.objects.count() >= 50
assert models.DocumentAccess.objects.count() > 50
# assert dev users have doc accesses
user = models.User.objects.get(email="impress@impress.world")
assert models.DocumentAccess.objects.filter(user=user).exists()
user = models.User.objects.get(email="user@webkit.e2e")
assert models.DocumentAccess.objects.filter(user=user).exists()
user = models.User.objects.get(email="user@firefox.e2e")
assert models.DocumentAccess.objects.filter(user=user).exists()
user = models.User.objects.get(email="user@chromium.e2e")
assert models.DocumentAccess.objects.filter(user=user).exists()

View File

@@ -1,4 +1,5 @@
"""Impress celery configuration file."""
import os
from celery import Celery

View File

@@ -9,6 +9,7 @@ https://docs.djangoproject.com/en/3.1/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.1/ref/settings/
"""
import json
import os
@@ -137,7 +138,85 @@ class Base(Configuration):
environ_prefix=None,
)
S3_VERSIONS_PAGE_SIZE = 50
# Document images
DOCUMENT_IMAGE_MAX_SIZE = values.Value(
10 * (2**20), # 10MB
environ_name="DOCUMENT_IMAGE_MAX_SIZE",
environ_prefix=None,
)
DOCUMENT_UNSAFE_MIME_TYPES = [
# Executable Files
"application/x-msdownload",
"application/x-bat",
"application/x-dosexec",
"application/x-sh",
"application/x-ms-dos-executable",
"application/x-msi",
"application/java-archive",
"application/octet-stream",
# Dynamic Web Pages
"application/x-httpd-php",
"application/x-asp",
"application/x-aspx",
"application/jsp",
"application/xhtml+xml",
"application/x-python-code",
"application/x-perl",
"text/html",
"text/javascript",
"text/x-php",
# System Files
"application/x-msdownload",
"application/x-sys",
"application/x-drv",
"application/cpl",
"application/x-apple-diskimage",
# Script Files
"application/javascript",
"application/x-vbscript",
"application/x-powershell",
"application/x-shellscript",
# Compressed/Archive Files
"application/zip",
"application/x-tar",
"application/gzip",
"application/x-bzip2",
"application/x-7z-compressed",
"application/x-rar",
"application/x-rar-compressed",
"application/x-compress",
"application/x-lzma",
# Macros in Documents
"application/vnd.ms-word",
"application/vnd.ms-excel",
"application/vnd.ms-powerpoint",
"application/vnd.ms-word.document.macroenabled.12",
"application/vnd.ms-excel.sheet.macroenabled.12",
"application/vnd.ms-powerpoint.presentation.macroenabled.12",
# Disk Images & Virtual Disk Files
"application/x-iso9660-image",
"application/x-vmdk",
"application/x-apple-diskimage",
"application/x-dmg",
# Other Dangerous MIME Types
"application/x-ms-application",
"application/x-msdownload",
"application/x-shockwave-flash",
"application/x-silverlight-app",
"application/x-java-vm",
"application/x-bittorrent",
"application/hta",
"application/x-csh",
"application/x-ksh",
"application/x-ms-regedit",
"application/x-msdownload",
"application/xml",
"image/svg+xml",
]
# Document versions
DOCUMENT_VERSIONS_PAGE_SIZE = 50
# Internationalization
# https://docs.djangoproject.com/en/3.1/topics/i18n/
@@ -278,6 +357,7 @@ class Base(Configuration):
EMAIL_HOST_PASSWORD = values.Value(None)
EMAIL_PORT = values.PositiveIntegerValue(None)
EMAIL_USE_TLS = values.BooleanValue(False)
EMAIL_USE_SSL = values.BooleanValue(False)
EMAIL_FROM = values.Value("from@example.com")
AUTH_USER_MODEL = "core.User"
@@ -295,6 +375,7 @@ class Base(Configuration):
# Easy thumbnails
THUMBNAIL_EXTENSION = "webp"
THUMBNAIL_TRANSPARENCY_EXTENSION = "webp"
THUMBNAIL_DEFAULT_STORAGE_ALIAS = "default"
THUMBNAIL_ALIASES = {}
# Celery
@@ -364,9 +445,49 @@ class Base(Configuration):
OIDC_STORE_ID_TOKEN = values.BooleanValue(
default=True, environ_name="OIDC_STORE_ID_TOKEN", environ_prefix=None
)
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = values.BooleanValue(
default=True,
environ_name="OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION",
environ_prefix=None,
)
ALLOW_LOGOUT_GET_METHOD = values.BooleanValue(
default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None
)
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)
AI_DOCUMENT_RATE_THROTTLE_RATES = values.DictValue(
{
"minute": 5,
"hour": 100,
"day": 500,
},
environ_name="AI_DOCUMENT_RATE_THROTTLE_RATES",
environ_prefix=None,
)
AI_USER_RATE_THROTTLE_RATES = values.DictValue(
{
"minute": 3,
"hour": 50,
"day": 200,
},
environ_name="AI_USER_RATE_THROTTLE_RATES",
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,
)
# pylint: disable=invalid-name
@property
@@ -526,6 +647,14 @@ class Production(Base):
# In other cases, you should comment the following line to avoid security issues.
# SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_HSTS_SECONDS = 60
SECURE_HSTS_PRELOAD = True
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_SSL_REDIRECT = True
SECURE_REDIRECT_EXEMPT = [
"^__lbheartbeat__",
"^__heartbeat__",
]
# Modern browsers require to have the `secure` attribute on cookies with `Samesite=none`
CSRF_COOKIE_SECURE = True

Binary file not shown.

View File

@@ -1,208 +1,355 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-04-03 10:31+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"POT-Creation-Date: 2024-10-15 07:19+0000\n"
"PO-Revision-Date: 2024-10-15 07:23\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\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: en\n"
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 8\n"
#: core/admin.py:31
#: core/admin.py:33
msgid "Personal info"
msgstr ""
#: core/admin.py:33
#: core/admin.py:46
msgid "Permissions"
msgstr ""
#: core/admin.py:45
#: core/admin.py:58
msgid "Important dates"
msgstr ""
#: core/api/serializers.py:128
msgid "Markdown Body"
#: core/api/serializers.py:253
msgid "Body"
msgstr ""
#: core/authentication.py:71
#: core/api/serializers.py:256
msgid "Body type"
msgstr ""
#: core/api/serializers.py:262
msgid "Format"
msgstr ""
#: core/authentication/backends.py:57
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.py:91
msgid "Claims contained no recognizable user identification"
#: core/models.py:62 core/models.py:69
msgid "Reader"
msgstr ""
#: core/models.py:27
msgid "Member"
#: core/models.py:63 core/models.py:70
msgid "Editor"
msgstr ""
#: core/models.py:28
#: core/models.py:71
msgid "Administrator"
msgstr ""
#: core/models.py:29
#: core/models.py:72
msgid "Owner"
msgstr ""
#: core/models.py:41
#: core/models.py:80
msgid "Restricted"
msgstr ""
#: core/models.py:84
msgid "Authenticated"
msgstr ""
#: core/models.py:86
msgid "Public"
msgstr ""
#: core/models.py:98
msgid "id"
msgstr ""
#: core/models.py:42
#: core/models.py:99
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:48
#: core/models.py:105
msgid "created on"
msgstr ""
#: core/models.py:49
#: core/models.py:106
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:54
#: core/models.py:111
msgid "updated on"
msgstr ""
#: core/models.py:55
#: core/models.py:112
msgid "date and time at which a record was last updated"
msgstr ""
#: core/models.py:75
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
#: core/models.py:81
msgid "sub"
msgstr ""
#: core/models.py:83
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
#: core/models.py:91
msgid "identity email address"
msgstr ""
#: core/models.py:96
msgid "admin email address"
msgstr ""
#: core/models.py:103
msgid "language"
msgstr ""
#: core/models.py:104
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:110
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:113
msgid "device"
msgstr ""
#: core/models.py:115
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:118
msgid "staff status"
msgstr ""
#: core/models.py:120
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:123
msgid "active"
msgstr ""
#: core/models.py:126
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
#: core/models.py:132
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
msgstr ""
#: core/models.py:138
msgid "user"
msgid "sub"
msgstr ""
#: core/models.py:139
msgid "users"
#: core/models.py:140
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
#: core/models.py:161
msgid "title"
#: core/models.py:149
msgid "full name"
msgstr ""
#: core/models.py:162
msgid "description"
#: core/models.py:150
msgid "short name"
msgstr ""
#: core/models.py:163
msgid "code"
#: core/models.py:152
msgid "identity email address"
msgstr ""
#: core/models.py:157
msgid "admin email address"
msgstr ""
#: core/models.py:164
msgid "css"
msgid "language"
msgstr ""
#: core/models.py:166
msgid "public"
#: core/models.py:165
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:168
msgid "Whether this template is public for anyone to use."
#: core/models.py:171
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:174
msgid "Template"
msgid "device"
msgstr ""
#: core/models.py:175
msgid "Templates"
#: core/models.py:176
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:256
msgid "Template/user relation"
#: core/models.py:179
msgid "staff status"
msgstr ""
#: core/models.py:257
msgid "Template/user relations"
#: core/models.py:181
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:263
msgid "This user is already in this template."
#: core/models.py:184
msgid "active"
msgstr ""
#: core/models.py:269
msgid "This team is already in this template."
#: core/models.py:187
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: core/models.py:275
#: core/models.py:199
msgid "user"
msgstr ""
#: core/models.py:200
msgid "users"
msgstr ""
#: core/models.py:332 core/models.py:638
msgid "title"
msgstr ""
#: core/models.py:347
msgid "Document"
msgstr ""
#: core/models.py:348
msgid "Documents"
msgstr ""
#: core/models.py:351
msgid "Untitled Document"
msgstr ""
#: core/models.py:530
#, python-format
msgid "%(sender_name)s shared a document with you: %(document)s"
msgstr ""
#: core/models.py:574
msgid "Document/user link trace"
msgstr ""
#: core/models.py:575
msgid "Document/user link traces"
msgstr ""
#: core/models.py:581
msgid "A link trace already exists for this document/user."
msgstr ""
#: core/models.py:602
msgid "Document/user relation"
msgstr ""
#: core/models.py:603
msgid "Document/user relations"
msgstr ""
#: core/models.py:609
msgid "This user is already in this document."
msgstr ""
#: core/models.py:615
msgid "This team is already in this document."
msgstr ""
#: core/models.py:621 core/models.py:810
msgid "Either user or team must be set, not both."
msgstr ""
#: impress/settings.py:134
#: core/models.py:639
msgid "description"
msgstr ""
#: core/models.py:640
msgid "code"
msgstr ""
#: core/models.py:641
msgid "css"
msgstr ""
#: core/models.py:643
msgid "public"
msgstr ""
#: core/models.py:645
msgid "Whether this template is public for anyone to use."
msgstr ""
#: core/models.py:651
msgid "Template"
msgstr ""
#: core/models.py:652
msgid "Templates"
msgstr ""
#: core/models.py:791
msgid "Template/user relation"
msgstr ""
#: core/models.py:792
msgid "Template/user relations"
msgstr ""
#: core/models.py:798
msgid "This user is already in this template."
msgstr ""
#: core/models.py:804
msgid "This team is already in this template."
msgstr ""
#: core/models.py:827
msgid "email address"
msgstr ""
#: core/models.py:844
msgid "Document invitation"
msgstr ""
#: core/models.py:845
msgid "Document invitations"
msgstr ""
#: core/models.py:862
msgid "This email is already associated to a registered user."
msgstr ""
#: core/templates/mail/html/hello.html:159 core/templates/mail/text/hello.txt:3
msgid "Company logo"
msgstr ""
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
#, python-format
msgid "Hello %(name)s"
msgstr ""
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
msgid "Hello"
msgstr ""
#: core/templates/mail/html/hello.html:189 core/templates/mail/text/hello.txt:6
msgid "Thank you very much for your visit!"
msgstr ""
#: core/templates/mail/html/hello.html:221
#, python-format
msgid "This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
msgstr ""
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/text/invitation.txt:3
msgid "La Suite Numérique"
msgstr ""
#: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:6
#, python-format
msgid " %(sender_name)s shared a document with you ! "
msgstr ""
#: core/templates/mail/html/invitation.html:196
#: core/templates/mail/text/invitation.txt:8
#, python-format
msgid " %(sender_name_email)s invited you with the role \"%(role)s\" on the following document : "
msgstr ""
#: core/templates/mail/html/invitation.html:205
#: core/templates/mail/text/invitation.txt:10
msgid "Open"
msgstr ""
#: core/templates/mail/html/invitation.html:222
#: core/templates/mail/text/invitation.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr ""
#: core/templates/mail/html/invitation.html:229
#: core/templates/mail/text/invitation.txt:16
msgid "Brought to you by La Suite Numérique"
msgstr ""
#: core/templates/mail/text/hello.txt:8
#, python-format
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: impress/settings.py:176
msgid "English"
msgstr ""
#: impress/settings.py:135
#: impress/settings.py:177
msgid "French"
msgstr ""

Binary file not shown.

View File

@@ -1,208 +1,355 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-04-03 10:31+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"POT-Creation-Date: 2024-10-15 07:19+0000\n"
"PO-Revision-Date: 2024-10-15 07:23\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:31
msgid "Personal info"
msgstr ""
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
"X-Crowdin-Project: lasuite-people\n"
"X-Crowdin-Project-ID: 637934\n"
"X-Crowdin-Language: fr\n"
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 8\n"
#: core/admin.py:33
msgid "Personal info"
msgstr "Infos Personnelles"
#: core/admin.py:46
msgid "Permissions"
msgstr ""
msgstr "Permissions"
#: core/admin.py:45
#: core/admin.py:58
msgid "Important dates"
msgstr "Dates importantes"
#: core/api/serializers.py:253
msgid "Body"
msgstr ""
#: core/api/serializers.py:128
msgid "Markdown Body"
#: core/api/serializers.py:256
msgid "Body type"
msgstr ""
#: core/authentication.py:71
#: core/api/serializers.py:262
msgid "Format"
msgstr ""
#: core/authentication/backends.py:57
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.py:91
msgid "Claims contained no recognizable user identification"
msgstr ""
#: core/models.py:62 core/models.py:69
msgid "Reader"
msgstr "Lecteur"
#: core/models.py:27
msgid "Member"
msgstr ""
#: core/models.py:63 core/models.py:70
msgid "Editor"
msgstr "Éditeur"
#: core/models.py:28
#: core/models.py:71
msgid "Administrator"
msgstr ""
msgstr "Administrateur"
#: core/models.py:29
#: core/models.py:72
msgid "Owner"
msgstr ""
msgstr "Propriétaire"
#: core/models.py:41
#: core/models.py:80
msgid "Restricted"
msgstr "Restreint"
#: core/models.py:84
msgid "Authenticated"
msgstr "Authentifié"
#: core/models.py:86
msgid "Public"
msgstr "Public"
#: core/models.py:98
msgid "id"
msgstr ""
#: core/models.py:42
#: core/models.py:99
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:48
#: core/models.py:105
msgid "created on"
msgstr ""
#: core/models.py:49
#: core/models.py:106
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:54
#: core/models.py:111
msgid "updated on"
msgstr ""
#: core/models.py:55
#: core/models.py:112
msgid "date and time at which a record was last updated"
msgstr ""
#: core/models.py:75
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
#: core/models.py:81
msgid "sub"
msgstr ""
#: core/models.py:83
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
#: core/models.py:91
msgid "identity email address"
msgstr ""
#: core/models.py:96
msgid "admin email address"
msgstr ""
#: core/models.py:103
msgid "language"
msgstr ""
#: core/models.py:104
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:110
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:113
msgid "device"
msgstr ""
#: core/models.py:115
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:118
msgid "staff status"
msgstr ""
#: core/models.py:120
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:123
msgid "active"
msgstr ""
#: core/models.py:126
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
#: core/models.py:132
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
msgstr ""
#: core/models.py:138
msgid "user"
msgid "sub"
msgstr ""
#: core/models.py:139
msgid "users"
#: core/models.py:140
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
#: core/models.py:161
msgid "title"
#: core/models.py:149
msgid "full name"
msgstr ""
#: core/models.py:162
msgid "description"
#: core/models.py:150
msgid "short name"
msgstr ""
#: core/models.py:163
msgid "code"
#: core/models.py:152
msgid "identity email address"
msgstr ""
#: core/models.py:157
msgid "admin email address"
msgstr ""
#: core/models.py:164
msgid "css"
msgid "language"
msgstr ""
#: core/models.py:166
msgid "public"
#: core/models.py:165
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:168
msgid "Whether this template is public for anyone to use."
#: core/models.py:171
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:174
msgid "Template"
msgid "device"
msgstr ""
#: core/models.py:175
msgid "Templates"
#: core/models.py:176
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:256
msgid "Template/user relation"
#: core/models.py:179
msgid "staff status"
msgstr ""
#: core/models.py:257
msgid "Template/user relations"
#: core/models.py:181
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:263
msgid "This user is already in this template."
#: core/models.py:184
msgid "active"
msgstr ""
#: core/models.py:269
msgid "This team is already in this template."
#: core/models.py:187
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: core/models.py:275
#: core/models.py:199
msgid "user"
msgstr ""
#: core/models.py:200
msgid "users"
msgstr ""
#: core/models.py:332 core/models.py:638
msgid "title"
msgstr ""
#: core/models.py:347
msgid "Document"
msgstr ""
#: core/models.py:348
msgid "Documents"
msgstr ""
#: core/models.py:351
msgid "Untitled Document"
msgstr ""
#: core/models.py:530
#, python-format
msgid "%(sender_name)s shared a document with you: %(document)s"
msgstr "%(sender_name)s a partagé un document avec vous: %(document)s"
#: core/models.py:574
msgid "Document/user link trace"
msgstr ""
#: core/models.py:575
msgid "Document/user link traces"
msgstr ""
#: core/models.py:581
msgid "A link trace already exists for this document/user."
msgstr ""
#: core/models.py:602
msgid "Document/user relation"
msgstr ""
#: core/models.py:603
msgid "Document/user relations"
msgstr ""
#: core/models.py:609
msgid "This user is already in this document."
msgstr ""
#: core/models.py:615
msgid "This team is already in this document."
msgstr ""
#: core/models.py:621 core/models.py:810
msgid "Either user or team must be set, not both."
msgstr ""
#: impress/settings.py:134
#: core/models.py:639
msgid "description"
msgstr ""
#: core/models.py:640
msgid "code"
msgstr ""
#: core/models.py:641
msgid "css"
msgstr ""
#: core/models.py:643
msgid "public"
msgstr ""
#: core/models.py:645
msgid "Whether this template is public for anyone to use."
msgstr ""
#: core/models.py:651
msgid "Template"
msgstr ""
#: core/models.py:652
msgid "Templates"
msgstr ""
#: core/models.py:791
msgid "Template/user relation"
msgstr ""
#: core/models.py:792
msgid "Template/user relations"
msgstr ""
#: core/models.py:798
msgid "This user is already in this template."
msgstr ""
#: core/models.py:804
msgid "This team is already in this template."
msgstr ""
#: core/models.py:827
msgid "email address"
msgstr ""
#: core/models.py:844
msgid "Document invitation"
msgstr ""
#: core/models.py:845
msgid "Document invitations"
msgstr ""
#: core/models.py:862
msgid "This email is already associated to a registered user."
msgstr ""
#: core/templates/mail/html/hello.html:159 core/templates/mail/text/hello.txt:3
msgid "Company logo"
msgstr ""
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
#, python-format
msgid "Hello %(name)s"
msgstr ""
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
msgid "Hello"
msgstr ""
#: core/templates/mail/html/hello.html:189 core/templates/mail/text/hello.txt:6
msgid "Thank you very much for your visit!"
msgstr ""
#: core/templates/mail/html/hello.html:221
#, python-format
msgid "This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
msgstr ""
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/text/invitation.txt:3
msgid "La Suite Numérique"
msgstr ""
#: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:6
#, python-format
msgid " %(sender_name)s shared a document with you ! "
msgstr " %(sender_name)s a partagé un document avec vous ! "
#: core/templates/mail/html/invitation.html:196
#: core/templates/mail/text/invitation.txt:8
#, python-format
msgid " %(sender_name_email)s invited you with the role \"%(role)s\" on the following document : "
msgstr " %(sender_name_email)s vous a invité avec le rôle \"%(role)s\" sur le document suivant : "
#: core/templates/mail/html/invitation.html:205
#: core/templates/mail/text/invitation.txt:10
msgid "Open"
msgstr "Ouvrir"
#: core/templates/mail/html/invitation.html:222
#: core/templates/mail/text/invitation.txt:14
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
msgstr " Docs, votre nouvel outil incontournable pour organiser, partager et collaborer sur vos documents en équipe. "
#: core/templates/mail/html/invitation.html:229
#: core/templates/mail/text/invitation.txt:16
msgid "Brought to you by La Suite Numérique"
msgstr "Proposé par La Suite Numérique"
#: core/templates/mail/text/hello.txt:8
#, python-format
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: impress/settings.py:176
msgid "English"
msgstr ""
#: impress/settings.py:135
#: impress/settings.py:177
msgid "French"
msgstr ""

View File

@@ -2,6 +2,7 @@
"""
impress's sandbox management script.
"""
import os
import sys

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "1.0.0"
version = "1.5.1"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,37 +25,39 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.33.6",
"boto3==1.35.41",
"Brotli==1.1.0",
"celery[redis]==5.3.6",
"django-configurations==2.5",
"django-cors-headers==4.3.1",
"django-countries==7.5.1",
"celery[redis]==5.4.0",
"django-configurations==2.5.1",
"django-cors-headers==4.5.0",
"django-countries==7.6.1",
"django-parler==2.3",
"redis==5.0.3",
"redis==5.1.1",
"django-redis==5.4.0",
"django-storages[s3]==1.14.2",
"django-storages[s3]==1.14.4",
"django-timezone-field>=5.1",
"django==5.0.3",
"djangorestframework==3.14.0",
"drf_spectacular==0.26.5",
"dockerflow==2022.8.0",
"easy_thumbnails==2.8.5",
"factory_boy==3.3.0",
"freezegun==1.5.0",
"gunicorn==22.0.0",
"jsonschema==4.20.0",
"markdown==3.5.1",
"django==5.1.2",
"djangorestframework==3.15.2",
"drf_spectacular==0.27.2",
"dockerflow==2024.4.2",
"easy_thumbnails==2.10",
"factory_boy==3.3.1",
"gunicorn==23.0.0",
"jsonschema==4.23.0",
"markdown==3.7",
"nested-multipart-parser==1.5.0",
"psycopg[binary]==3.1.14",
"PyJWT==2.8.0",
"python-frontmatter==1.0.1",
"requests==2.32.2",
"sentry-sdk==1.38.0",
"openai==1.44.1",
"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",
"url-normalize==1.4.3",
"WeasyPrint>=60.2",
"whitenoise==6.6.0",
"mozilla-django-oidc==4.0.0",
"whitenoise==6.7.0",
"mozilla-django-oidc==4.0.1",
]
[project.urls]
@@ -67,20 +69,21 @@ dependencies = [
[project.optional-dependencies]
dev = [
"django-extensions==3.2.3",
"drf-spectacular-sidecar==2023.12.1",
"drf-spectacular-sidecar==2024.7.1",
"freezegun==1.5.1",
"ipdb==0.13.13",
"ipython==8.18.1",
"pyfakefs==5.3.2",
"pylint-django==2.5.5",
"pylint==3.0.3",
"pytest-cov==4.1.0",
"pytest-django==4.7.0",
"pytest==7.4.3",
"pytest-icdiff==0.8",
"pytest-xdist==3.5.0",
"responses==0.24.1",
"ruff==0.1.6",
"types-requests==2.31.0.10",
"ipython==8.28.0",
"pyfakefs==5.7.1",
"pylint-django==2.6.1",
"pylint==3.3.1",
"pytest-cov==5.0.0",
"pytest-django==4.9.0",
"pytest==8.3.3",
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.3",
"ruff==0.6.9",
"types-requests==2.32.0.20241016",
]
[tool.setuptools]
@@ -99,11 +102,11 @@ exclude = [
"__pycache__",
"*/migrations/*",
]
ignore= ["DJ001", "PLR2004"]
line-length = 88
[tool.ruff.lint]
ignore = ["DJ001", "PLR2004"]
select = [
"B", # flake8-bugbear
"BLE", # flake8-blind-except
@@ -125,7 +128,7 @@ select = [
section-order = ["future","standard-library","django","third-party","impress","first-party","local-folder"]
sections = { impress=["core"], django=["django"] }
[tool.ruff.per-file-ignores]
[tool.ruff.lint.per-file-ignores]
"**/tests/*" = ["S", "SLF"]
[tool.pytest.ini_options]

View File

@@ -1,10 +1,10 @@
FROM node:20-alpine as frontend-deps-y-webrtc-signaling
FROM node:20-alpine AS frontend-deps-y-provider
WORKDIR /home/frontend/
COPY ./src/frontend/package.json ./package.json
COPY ./src/frontend/yarn.lock ./yarn.lock
COPY ./src/frontend/apps/y-webrtc-signaling/package.json ./apps/y-webrtc-signaling/package.json
COPY ./src/frontend/servers/y-provider/package.json ./servers/y-provider/package.json
COPY ./src/frontend/packages/eslint-config-impress/package.json ./packages/eslint-config-impress/package.json
RUN yarn install
@@ -14,10 +14,10 @@ COPY ./src/frontend/ .
# Copy entrypoint
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
# ---- y-webrtc-signaling ----
FROM frontend-deps-y-webrtc-signaling as y-webrtc-signaling
# ---- y-provider ----
FROM frontend-deps-y-provider AS y-provider
WORKDIR /home/frontend/apps/y-webrtc-signaling
WORKDIR /home/frontend/servers/y-provider
RUN yarn build
# Un-privileged user running the application
@@ -28,7 +28,7 @@ ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
CMD ["yarn", "start"]
FROM node:20-alpine as frontend-deps
FROM node:20-alpine AS frontend-deps
WORKDIR /home/frontend/
@@ -43,11 +43,11 @@ COPY .dockerignore ./.dockerignore
COPY ./src/frontend/ .
### ---- Front-end builder image ----
FROM frontend-deps as impress
FROM frontend-deps AS impress
WORKDIR /home/frontend/apps/impress
FROM frontend-deps as impress-dev
FROM frontend-deps AS impress-dev
WORKDIR /home/frontend/apps/impress
@@ -57,14 +57,29 @@ CMD [ "yarn", "dev"]
# Tilt will rebuild impress target so, we dissociate impress and impress-builder
# to avoid rebuilding the app at every changes.
FROM impress as impress-builder
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}
RUN yarn build
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.25 as frontend-production
FROM nginxinc/nginx-unprivileged:1.26-alpine AS frontend-production
# Un-privileged user running the application
ARG DOCKER_USER

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -17,3 +17,11 @@ setup('authenticate-webkit', async ({ page }) => {
.context()
.storageState({ path: `playwright/.auth/user-webkit.json` });
});
setup('authenticate-firefox', async ({ page }) => {
await page.goto('/');
await keyCloakSignIn(page, 'firefox');
await page
.context()
.storageState({ path: `playwright/.auth/user-firefox.json` });
});

View File

@@ -1,10 +1,6 @@
import { Page, expect } from '@playwright/test';
export const keyCloakSignIn = async (page: Page, browserName: string) => {
const title = await page.locator('h1').first().textContent({
timeout: 5000,
});
const login = `user-e2e-${browserName}`;
const password = `password-e2e-${browserName}`;
@@ -12,7 +8,7 @@ export const keyCloakSignIn = async (page: Page, browserName: string) => {
await page.getByRole('textbox', { name: 'password' }).fill(password);
await page.click('input[type="submit"]', { force: true });
} else if (title?.includes('Sign in to your account')) {
} else {
await page.getByRole('textbox', { name: 'username' }).fill(login);
await page.getByRole('textbox', { name: 'password' }).fill(password);
@@ -33,62 +29,46 @@ export const createDoc = async (
length: number,
isPublic: boolean = false,
) => {
const panel = page.getByLabel('Documents panel').first();
const buttonCreate = page.getByRole('button', {
name: 'Create the document',
});
const randomDocs = randomName(docName, browserName, length);
for (let i = 0; i < randomDocs.length; i++) {
await panel.getByRole('button', { name: 'Add a document' }).click();
await page.getByText('Document name').fill(randomDocs[i]);
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
await page
.getByRole('button', {
name: 'Create a new document',
})
.click();
await page.getByRole('heading', { name: 'Untitled document' }).click();
await page.keyboard.type(randomDocs[i]);
await page.getByText('Created at ').click();
if (isPublic) {
await page.getByText('Is it public ?').click();
}
await page.getByRole('button', { name: 'Share' }).click();
await page.getByText('Doc private').click();
await expect(buttonCreate).toBeEnabled();
await buttonCreate.click();
await expect(panel.locator('li').getByText(randomDocs[i])).toBeVisible();
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;
};
export const createTemplate = async (
page: Page,
templateName: string,
browserName: string,
length: number,
) => {
const menu = page.locator('menu').first();
await menu.getByLabel(`Template button`).click();
const panel = page.getByLabel('Templates panel').first();
const buttonCreate = page.getByRole('button', {
name: 'Create the template',
});
const randomTemplates = randomName(templateName, browserName, length);
for (let i = 0; i < randomTemplates.length; i++) {
await panel.getByRole('button', { name: 'Add a template' }).click();
await page.getByText('Template name').fill(randomTemplates[i]);
await expect(buttonCreate).toBeEnabled();
await buttonCreate.click();
await expect(
panel.locator('li').getByText(randomTemplates[i]),
).toBeVisible();
}
return randomTemplates;
};
export const addNewMember = async (
page: Page,
index: number,
role: 'Admin' | 'Owner' | 'Member',
role: 'Administrator' | 'Owner' | 'Member' | 'Editor' | 'Reader',
fillText: string = 'user',
) => {
const responsePromiseSearchUser = page.waitForResponse(
@@ -97,9 +77,6 @@ export const addNewMember = async (
response.status() === 200,
);
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Add members' }).click();
const inputSearch = page.getByLabel(/Find a member to add to the document/);
// Select a new user
@@ -115,8 +92,8 @@ export const addNewMember = async (
await page.getByRole('option', { name: users[index].email }).click();
// Choose a role
await page.getByRole('radio', { name: role }).click();
await page.getByRole('combobox', { name: /Choose a role/ }).click();
await page.getByRole('option', { name: role }).click();
await page.getByRole('button', { name: 'Validate' }).click();
await expect(
@@ -125,3 +102,156 @@ export const addNewMember = async (
return users[index].email;
};
interface GoToGridDocOptions {
nthRow?: number;
title?: string;
}
export const goToGridDoc = async (
page: Page,
{ nthRow = 1, title }: GoToGridDocOptions = {},
) => {
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,
});
const rows = datagridTable.getByRole('row');
const row = title
? rows.filter({
hasText: title,
})
: rows.nth(nthRow);
const docTitleCell = row.getByRole('cell').nth(1);
const docTitle = await docTitleCell.textContent();
expect(docTitle).toBeDefined();
await row.getByRole('link').first().click();
return docTitle as string;
};
export const mockedDocument = async (page: Page, json: object) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
!request.url().includes('page=') &&
!request.url().includes('versions') &&
!request.url().includes('accesses') &&
!request.url().includes('invitations')
) {
await route.fulfill({
json: {
id: 'mocked-document-id',
content: '',
title: 'Mocked document',
accesses: [],
abilities: {
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,
},
link_reach: 'restricted',
created_at: '2021-09-01T09:00:00Z',
...json,
},
});
} else {
await route.continue();
}
});
};
export const mockedInvitations = async (page: Page, json?: object) => {
await page.route('**/invitations/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
request.url().includes('invitations') &&
request.url().includes('page=')
) {
await route.fulfill({
json: {
count: 1,
next: null,
previous: null,
results: [
{
id: '120ec765-43af-4602-83eb-7f4e1224548a',
abilities: {
destroy: true,
update: true,
partial_update: true,
retrieve: true,
},
created_at: '2024-10-03T12:19:26.107687Z',
email: 'test@invitation.test',
document: '4888c328-8406-4412-9b0b-c0ba5b9e5fb6',
role: 'editor',
issuer: '7380f42f-02eb-4ad5-b8f0-037a0e66066d',
is_expired: false,
...json,
},
],
},
});
} else {
await route.continue();
}
});
};
export const mockedAccesses = async (page: Page, json?: object) => {
await page.route('**/accesses/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
request.url().includes('accesses') &&
request.url().includes('page=')
) {
await route.fulfill({
json: {
count: 1,
next: null,
previous: null,
results: [
{
id: 'bc8bbbc5-a635-4f65-9817-fd1e9ec8ef87',
user: {
id: 'b4a21bb3-722e-426c-9f78-9d190eda641c',
email: 'test@accesses.test',
},
team: '',
role: 'reader',
abilities: {
destroy: true,
update: true,
partial_update: true,
retrieve: true,
set_role_to: ['administrator', 'editor'],
},
...json,
},
],
},
});
} else {
await route.continue();
}
});
};

View File

@@ -1,130 +1,31 @@
import { expect, test } from '@playwright/test';
import { createDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Create', () => {
test('checks all the create doc elements are visible', async ({ page }) => {
const buttonCreateHomepage = page.getByRole('button', {
name: 'Create a new document',
});
await buttonCreateHomepage.click();
await expect(buttonCreateHomepage).toBeHidden();
test('it creates a doc', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, 'My new doc', browserName, 1);
const card = page.getByLabel('Create new document card').first();
await expect(card.getByLabel('Document name')).toBeVisible();
await expect(card.getByLabel('icon group')).toBeVisible();
await expect(
card.getByRole('heading', {
name: 'Name the document',
level: 3,
}),
).toBeVisible();
await expect(card.getByText('Is it public ?')).toBeVisible();
await expect(
card.getByRole('button', {
name: 'Create the document',
}),
).toBeVisible();
await expect(
card.getByRole('button', {
name: 'Cancel',
}),
).toBeVisible();
});
test('checks the cancel button interaction', async ({ page }) => {
const buttonCreateHomepage = page.getByRole('button', {
name: 'Create a new document',
});
await buttonCreateHomepage.click();
await expect(buttonCreateHomepage).toBeHidden();
const card = page.getByLabel('Create new document card').first();
await card
.getByRole('button', {
name: 'Cancel',
})
.click();
await expect(buttonCreateHomepage).toBeVisible();
});
test('checks the routing on new doc created', async ({
page,
browserName,
}) => {
const panel = page.getByLabel('Documents panel').first();
await panel.getByRole('button', { name: 'Add a document' }).click();
const docName = `My routing doc ${browserName}-${Math.floor(Math.random() * 1000)}`;
await page.getByText('Document name').fill(docName);
await page.getByRole('button', { name: 'Create the document' }).click();
const elDoc = page.locator('h2').getByText(docName);
await expect(elDoc).toBeVisible();
await panel.getByRole('button', { name: 'Add a document' }).click();
await expect(elDoc).toBeHidden();
await panel.locator('li').getByText(docName).click();
await expect(elDoc).toBeVisible();
});
test('checks alias docs url with homepage', async ({ page }) => {
await expect(page).toHaveURL('/');
const buttonCreateHomepage = page.getByRole('button', {
name: 'Create a new document',
});
await expect(buttonCreateHomepage).toBeVisible();
await page.goto('/docs');
await expect(buttonCreateHomepage).toBeVisible();
await expect(page).toHaveURL(/\/docs$/);
});
test('checks 404 on docs/[id] page', async ({ page }) => {
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(300);
await page.goto('/docs/some-unknown-doc');
await expect(
page.getByText(
'It seems that the page you are looking for does not exist or cannot be displayed correctly.',
),
).toBeVisible({
timeout: 15000,
});
});
test('checks that the doc is public', async ({ page, browserName }) => {
const responsePromiseDoc = page.waitForResponse(
(response) =>
response.url().includes('/documents/') && response.status() === 201,
await page.waitForFunction(
() => document.title.match(/My new doc - Docs/),
{ timeout: 5000 },
);
const panel = page.getByLabel('Documents panel').first();
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
await panel.getByRole('button', { name: 'Add a document' }).click();
const datagrid = page.getByLabel('Datagrid of the documents page 1');
const datagridTable = datagrid.getByRole('table');
const docName = `My routing doc ${browserName}-${Math.floor(Math.random() * 1000)}`;
await page.getByText('Document name').fill(docName);
await page.getByText('Is it public ?').click();
await page.getByRole('button', { name: 'Create the document' }).click();
const responseDoc = await responsePromiseDoc;
const is_public = (await responseDoc.json()).is_public;
expect(is_public).toBeTruthy();
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
timeout: 10000,
});
await expect(datagridTable.getByText(docTitle)).toBeVisible({
timeout: 5000,
});
});
});

View File

@@ -1,26 +1,15 @@
import path from 'path';
import { expect, test } from '@playwright/test';
import { createDoc } from './common';
import { createDoc, goToGridDoc, mockedDocument } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Editor', () => {
test('checks the Doc Editor interact correctly', async ({
page,
browserName,
}) => {
const randomDoc = await createDoc(page, 'doc-editor', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc[0])).toBeVisible();
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
await expect(page.getByText('Hello World')).toBeVisible();
});
test('checks the Doc is connected to the webrtc server', async ({
test('checks the Doc is connected to the provider server', async ({
page,
browserName,
}) => {
@@ -40,12 +29,7 @@ test.describe('Doc Editor', () => {
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
const framesent = await framesentPromise;
const payload = JSON.parse(framesent.payload as string) as {
type: string;
};
const typeCases = ['publish', 'subscribe', 'unsubscribe', 'ping'];
expect(typeCases.includes(payload.type)).toBeTruthy();
expect(framesent.payload).not.toBeNull();
});
test('markdown button converts from markdown to the editor syntax json', async ({
@@ -56,19 +40,18 @@ test.describe('Doc Editor', () => {
await expect(page.locator('h2').getByText(randomDoc[0])).toBeVisible();
await page.locator('.ProseMirror.bn-editor').click();
await page
.locator('.ProseMirror.bn-editor')
.fill('[test markdown](http://test-markdown.html)');
const editor = page.locator('.ProseMirror');
await editor.click();
await editor.fill('[test markdown](http://test-markdown.html)');
await expect(page.getByText('[test markdown]')).toBeVisible();
await expect(editor.getByText('[test markdown]')).toBeVisible();
await page.getByText('[test markdown]').dblclick();
await editor.getByText('[test markdown]').dblclick();
await page.locator('button[data-test="convertMarkdown"]').click();
await expect(page.getByText('[test markdown]')).toBeHidden();
await expect(editor.getByText('[test markdown]')).toBeHidden();
await expect(
page.getByRole('link', {
editor.getByRole('link', {
name: 'test markdown',
}),
).toHaveAttribute('href', 'http://test-markdown.html');
@@ -76,129 +59,136 @@ test.describe('Doc Editor', () => {
test('it renders correctly when we switch from one doc to another', async ({
page,
browserName,
}) => {
const [firstDoc, secondDoc] = await createDoc(
page,
'doc-multiple',
browserName,
2,
);
const panel = page.getByLabel('Documents panel').first();
// Check the first doc
await panel.getByText(firstDoc).click();
const firstDoc = await goToGridDoc(page);
await expect(page.locator('h2').getByText(firstDoc)).toBeVisible();
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World Doc 1');
await expect(page.getByText('Hello World Doc 1')).toBeVisible();
const editor = page.locator('.ProseMirror');
await editor.click();
await editor.fill('Hello World Doc 1');
await expect(editor.getByText('Hello World Doc 1')).toBeVisible();
// Check the second doc
await panel.getByText(secondDoc).click();
const secondDoc = await goToGridDoc(page, {
nthRow: 2,
});
await expect(page.locator('h2').getByText(secondDoc)).toBeVisible();
await expect(page.getByText('Hello World Doc 1')).toBeHidden();
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World Doc 2');
await expect(page.getByText('Hello World Doc 2')).toBeVisible();
await expect(editor.getByText('Hello World Doc 1')).toBeHidden();
await editor.click();
await editor.fill('Hello World Doc 2');
await expect(editor.getByText('Hello World Doc 2')).toBeVisible();
// Check the first doc again
await panel.getByText(firstDoc).click();
await goToGridDoc(page, {
title: firstDoc,
});
await expect(page.locator('h2').getByText(firstDoc)).toBeVisible();
await expect(page.getByText('Hello World Doc 2')).toBeHidden();
await expect(page.getByText('Hello World Doc 1')).toBeVisible();
await expect(editor.getByText('Hello World Doc 2')).toBeHidden();
await expect(editor.getByText('Hello World Doc 1')).toBeVisible();
});
test('it saves the doc when we change pages', async ({
page,
browserName,
}) => {
const [doc] = await createDoc(page, 'doc-save-page', browserName, 1);
const panel = page.getByLabel('Documents panel').first();
test('it saves the doc when we change pages', async ({ page }) => {
// Check the first doc
await panel.getByText(doc).click();
const doc = await goToGridDoc(page);
await expect(page.locator('h2').getByText(doc)).toBeVisible();
await page.locator('.ProseMirror.bn-editor').click();
await page
.locator('.ProseMirror.bn-editor')
.fill('Hello World Doc persisted 1');
await expect(page.getByText('Hello World Doc persisted 1')).toBeVisible();
await panel
.getByRole('button', {
name: 'Add a document',
})
.click();
const editor = page.locator('.ProseMirror');
await editor.click();
await editor.fill('Hello World Doc persisted 1');
await expect(editor.getByText('Hello World Doc persisted 1')).toBeVisible();
await expect(
page.getByText(`Your document "${doc}" has been saved.`),
).toBeVisible();
const secondDoc = await goToGridDoc(page, {
nthRow: 2,
});
const card = page.getByLabel('Create new document card').first();
await expect(
card.getByRole('heading', {
name: 'Name the document',
level: 3,
}),
).toBeVisible();
await expect(page.locator('h2').getByText(secondDoc)).toBeVisible();
await page.goto('/');
await goToGridDoc(page, {
title: doc,
});
await panel.getByText(doc).click();
await expect(page.getByText('Hello World Doc persisted 1')).toBeVisible();
await expect(editor.getByText('Hello World Doc persisted 1')).toBeVisible();
});
test('it saves the doc when we quit pages', async ({ page, browserName }) => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(browserName === 'webkit', 'This test is very flaky with webkit');
const [doc] = await createDoc(page, 'doc-save-quit', browserName, 1);
const panel = page.getByLabel('Documents panel').first();
// Check the first doc
await panel.getByText(doc).click();
const doc = await goToGridDoc(page);
await expect(page.locator('h2').getByText(doc)).toBeVisible();
await page.locator('.ProseMirror.bn-editor').click();
await page
.locator('.ProseMirror.bn-editor')
.fill('Hello World Doc persisted 2');
await expect(page.getByText('Hello World Doc persisted 2')).toBeVisible();
const editor = page.locator('.ProseMirror');
await editor.click();
await editor.fill('Hello World Doc persisted 2');
await expect(editor.getByText('Hello World Doc persisted 2')).toBeVisible();
await page.goto('/');
await panel.getByText(doc).click();
await goToGridDoc(page, {
title: doc,
});
await expect(page.getByText('Hello World Doc persisted 2')).toBeVisible();
await expect(editor.getByText('Hello World Doc persisted 2')).toBeVisible();
});
test('it cannot edit if viewer', async ({ page, browserName }) => {
await page.route('**/documents/**/', async (route) => {
test('it cannot edit if viewer', async ({ page }) => {
await mockedDocument(page, {
abilities: {
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,
},
});
await goToGridDoc(page);
await expect(
page.getByText('Read only, you cannot edit this document.'),
).toBeVisible();
});
test('it adds an image to the doc editor', async ({ page }) => {
await goToGridDoc(page);
const fileChooserPromise = page.waitForEvent('filechooser');
await page.locator('.bn-block-outer').last().fill('Hello World');
await page.keyboard.press('Enter');
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Resizable image with caption').click();
await page.getByText('Upload image').click();
const 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 the AI buttons', async ({ page, browserName }) => {
await page.route(/.*\/ai-translate\//, async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
!request.url().includes('page=')
) {
if (request.method().includes('POST')) {
await route.fulfill({
json: {
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
content: '',
title: 'Mocked document',
accesses: [],
abilities: {
destroy: false, // Means not owner
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,
},
is_public: false,
answer: 'Bonjour le monde',
},
});
} else {
@@ -206,12 +196,42 @@ test.describe('Doc Editor', () => {
}
});
await createDoc(page, 'doc-right-edit', browserName, 1);
await createDoc(page, 'doc-ai', browserName, 1);
await page.locator('.bn-block-outer').last().fill('Hello World');
const editor = page.locator('.ProseMirror');
await editor.getByText('Hello').dblclick();
await page.getByRole('button', { name: 'AI' }).click();
await expect(
page.getByText(
"Read only, you don't have the right to update this document.",
),
page.getByRole('menuitem', { name: 'Use as prompt' }),
).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Rephrase' }),
).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Summarize' }),
).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Correct' })).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Language' }),
).toBeVisible();
await page.getByRole('menuitem', { name: 'Language' }).hover();
await expect(
page.getByRole('menuitem', { name: 'English', exact: true }),
).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'French', exact: true }),
).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'German', exact: true }),
).toBeVisible();
await page.getByRole('menuitem', { name: 'English', exact: true }).click();
await expect(editor.getByText('Bonjour le monde')).toBeVisible();
});
});

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