Compare commits

..

121 Commits

Author SHA1 Message Date
Arnaud Robin
d3868235f1 🌐(frontend) add localization to editor 2024-10-21 10:48:18 +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
244 changed files with 11662 additions and 6056 deletions

View File

@@ -104,7 +104,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,7 +132,7 @@ 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'
@@ -143,7 +143,7 @@ jobs:
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 }}

View File

@@ -96,10 +96,10 @@ 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
@@ -139,7 +139,7 @@ jobs:
with:
targets: |
app-dev
y-webrtc-signaling
y-provider
load: true
set: |
*.cache-from=type=gha,scope=cached-stage
@@ -149,6 +149,10 @@ jobs:
run: |
make run
- name: Start Nginx for the frontend
run: |
docker compose up --force-recreate -d nginx-front
- name: Apply DRF migrations
run: |
make migrate
@@ -158,14 +162,92 @@ jobs:
make demo FLUSH_ARGS='--no-input'
- 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: build-front
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
with:
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-provider
load: true
set: |
*.cache-from=type=gha,scope=cached-stage
*.cache-to=type=gha,scope=cached-stage,mode=max
- name: Start Docker services
run: |
make run
- name: Start Nginx for the frontend
run: |
docker compose up --force-recreate -d nginx-front
- name: Apply DRF migrations
run: |
make migrate
- name: Add dummy data
run: |
make demo FLUSH_ARGS='--no-input'
- 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

@@ -198,75 +198,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

@@ -6,4 +6,5 @@ creation_rules:
- age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7 # github-repo
- age1plkp8td6zzfcavjusmsfrlk54t9vn8jjxm8zaz7cmnr7kzl2nfnsd54hwg # Anthony Le-Courric
- age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3 # Antoine Lebaud
- age1hnhuzj96ktkhpyygvmz0x9h8mfvssz7ss6emmukags644mdhf4msajk93r # Samuel Paccoud

View File

@@ -6,8 +6,88 @@ 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
- ✨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
- 🌐(frontend) add localization to editor #268
## Changed
- ♻️ 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
- 🐛 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
@@ -23,6 +103,7 @@ and this project adheres to
- ♻️(frontend) create a doc from a modal #132
- ♻️(frontend) manage members from the share modal #140
## [1.0.0] - 2024-07-02
## Added
@@ -61,6 +142,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
@@ -69,7 +151,10 @@ and this project adheres to
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.1.0...main
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.3.0...main
[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

@@ -75,6 +75,8 @@ RUN apt-get update && \
libgdk-pixbuf2.0-0 \
libpango-1.0-0 \
libpangocairo-1.0-0 \
pandoc \
fonts-noto-color-emoji \
shared-mime-info && \
rm -rf /var/lib/apt/lists/*

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
@@ -93,6 +92,7 @@ bootstrap: \
# -- Docker/compose
build: ## build the app-dev container
@$(COMPOSE) build app-dev --no-cache
@$(COMPOSE) build frontend-dev --no-cache
.PHONY: build
down: ## stop and remove containers, networks, images, and volumes
@@ -105,7 +105,7 @@ 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 y-provider
@echo "Wait for postgresql to be up..."
@$(WAIT_DB)
.PHONY: run
@@ -186,7 +186,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 +275,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
@@ -324,3 +314,13 @@ start-tilt: ## start the kubernetes cluster using kind
tilt up -f ./bin/Tiltfile
.PHONY: build-k8s-cluster
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

@@ -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
@@ -121,6 +119,14 @@ services:
depends_on:
- keycloak
nginx-front:
image: nginx:1.25
ports:
- "3000:3000"
volumes:
- ./src/frontend/apps/impress/conf/default.conf:/etc/nginx/conf.d/default.conf
- ./src/frontend/apps/impress/out:/usr/share/nginx/html
dockerize:
image: jwilder/dockerize
@@ -141,19 +147,19 @@ 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/
- ./src/frontend/servers/y-provider:/home/frontend/servers/y-provider
- /home/frontend/servers/y-provider/node_modules/
- /home/frontend/servers/y-provider/dist/
frontend-dev:
user: "${DOCKER_USER:-1000}"
@@ -167,12 +173,11 @@ services:
- ./src/frontend/apps/impress:/home/frontend/apps/impress
- /home/frontend/node_modules/
depends_on:
- y-webrtc-signaling
- y-provider
- celery-dev
kc_postgresql:
image: postgres:14.3
platform: linux/amd64
ports:
- "5433:5432"
env_file:

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

@@ -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...2643697e5f

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 _
@@ -91,6 +92,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
@@ -16,9 +17,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

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,4 +1,8 @@
"""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 _
@@ -62,9 +66,8 @@ 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],
).exists():
raise exceptions.PermissionDenied(
@@ -74,7 +77,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()
@@ -145,11 +148,85 @@ class DocumentSerializer(BaseResourceSerializer):
"title",
"accesses",
"abilities",
"is_public",
"link_role",
"link_reach",
"created_at",
"updated_at",
]
read_only_fields = ["id", "accesses", "abilities", "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."
)
# Validate file type
mime_type, _ = mimetypes.guess_type(file.name)
if mime_type not in settings.DOCUMENT_IMAGE_ALLOWED_MIME_TYPES:
mime_types = ", ".join(settings.DOCUMENT_IMAGE_ALLOWED_MIME_TYPES)
raise serializers.ValidationError(
f"File type '{mime_type:s}' is not allowed. Allowed types are: {mime_types:s}"
)
return file
class TemplateSerializer(BaseResourceSerializer):
@@ -180,6 +257,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):
@@ -234,9 +317,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():
@@ -247,7 +329,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()
@@ -259,3 +341,12 @@ class InvitationSerializer(serializers.ModelSerializer):
attrs["document_id"] = document_id
attrs["issuer"] = user
return attrs
class DocumentVersionSerializer(serializers.Serializer):
"""Serialize Versions."""
etag = serializers.CharField()
is_latest = serializers.BooleanField()
last_modified = serializers.DateTimeField()
version_id = serializers.CharField()

View File

@@ -0,0 +1,33 @@
"""Util to generate S3 authorization headers for object storage access control"""
from django.core.files.storage import default_storage
import botocore
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

View File

@@ -1,13 +1,20 @@
"""API endpoints"""
from io import BytesIO
import os
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 (
OuterRef,
Q,
Subquery,
)
from django.http import FileResponse, Http404
from django.http import Http404
from botocore.exceptions import ClientError
from rest_framework import (
@@ -24,11 +31,24 @@ from rest_framework import (
)
from core import models
from core.utils import email_invitation
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):
"""
@@ -166,28 +186,21 @@ class ResourceViewsetMixin:
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 +239,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 +277,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)
@@ -295,34 +307,66 @@ 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"]
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,17 +374,29 @@ class DocumentViewSet(
Return the document's versions but only those created after the user got access
to the document
"""
if not request.user.is_authenticated:
raise exceptions.PermissionDenied("Authentication required.")
document = self.get_object()
user = request.user
from_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),
)
)
return drf_response.Response(
document.get_versions_slice(from_datetime=from_datetime)
versions_data = document.get_versions_slice(from_datetime=from_datetime)[
"versions"
]
paginator = pagination.PageNumberPagination()
paginated_versions = paginator.paginate_queryset(versions_data, request)
serialized_versions = serializers.DocumentVersionSerializer(
paginated_versions, many=True
)
return paginator.get_paginated_response(serialized_versions.data)
@decorators.action(
detail=True,
methods=["get", "delete"],
@@ -358,10 +414,11 @@ class DocumentViewSet(
# Don't let users access versions that were created before they were given access
# to the document
user = request.user
from_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:
@@ -377,9 +434,95 @@ 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
)
if not serializer.is_valid():
return drf_response.Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
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)
if not serializer.is_valid():
return drf_response.Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
# Extract the file extension from the original filename
file = serializer.validated_data["file"]
extension = os.path.splitext(file.name)[1]
# Generate a generic yet unique filename to store the image in object storage
file_id = uuid.uuid4()
key = f"{document.key_base}/{ATTACHMENTS_FOLDER:s}/{file_id!s}{extension:s}"
default_storage.save(key, file)
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)
class DocumentAccessViewSet(
ResourceAccessViewsetMixin,
@@ -421,12 +564,18 @@ 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")
email_invitation(language, access.user.email, access.document.id)
class TemplateViewSet(
ResourceViewsetMixin,
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
@@ -442,6 +591,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 +621,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 +641,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 +693,7 @@ class InvitationViewset(
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""API ViewSet for user invitations to document.
@@ -530,8 +707,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 +738,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 +763,10 @@ 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")
email_invitation(language, invitation.email, invitation.document.id)

View File

@@ -1,6 +1,7 @@
"""
Core application enums declaration
"""
from django.conf import global_settings, settings
from django.utils.translation import gettext_lazy as _

View File

@@ -2,6 +2,7 @@
"""
Core application factories
"""
from django.conf import settings
from django.contrib.auth.hashers import make_password
@@ -34,8 +35,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 +53,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

@@ -1,58 +1,65 @@
"""
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
from django.contrib.auth import models as auth_models
from django.contrib.auth.base_user import AbstractBaseUser
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.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 +67,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
@@ -214,7 +235,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 +268,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 +320,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 +339,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,28 +411,6 @@ 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
):
@@ -395,7 +428,7 @@ class Document(BaseModel):
response = default_storage.connection.meta.client.list_object_versions(
Bucket=default_storage.bucket_name,
Prefix=self.file_key,
MaxKeys=settings.S3_VERSIONS_PAGE_SIZE,
MaxKeys=settings.DOCUMENT_VERSIONS_PAGE_SIZE,
**token,
)
@@ -413,7 +446,7 @@ class Document(BaseModel):
if response["NextVersionIdMarker"]:
return self.get_versions_slice(
from_version_id=response["NextVersionIdMarker"],
page_size=settings.S3_VERSIONS_PAGE_SIZE,
page_size=settings.DOCUMENT_VERSIONS_PAGE_SIZE,
from_datetime=from_datetime,
)
return {
@@ -425,9 +458,9 @@ class Document(BaseModel):
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)
MaxKeys=min(page_size, settings.DOCUMENT_VERSIONS_PAGE_SIZE)
if page_size
else settings.S3_VERSIONS_PAGE_SIZE,
else settings.DOCUMENT_VERSIONS_PAGE_SIZE,
**token,
)
return {
@@ -457,26 +490,71 @@ 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 {
"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,
}
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 +642,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 +738,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 +820,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 +842,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 +861,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)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

View File

@@ -92,9 +92,12 @@ 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)

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",
@@ -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()
@@ -469,7 +276,7 @@ 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()
@@ -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,9 +316,7 @@ 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.
@@ -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,9 +373,7 @@ 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.
@@ -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,7 +420,7 @@ 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.
@@ -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,7 +474,7 @@ 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.
@@ -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,7 +530,7 @@ 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.
@@ -738,12 +541,13 @@ def test_api_document_accesses_update_owner_self(via, mock_user_get_teams):
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"
)
@@ -818,7 +622,7 @@ def test_api_document_accesses_delete_authenticated():
@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.
@@ -832,7 +636,7 @@ 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
)
@@ -852,7 +656,7 @@ def test_api_document_accesses_delete_reader_or_editor(via, role, mock_user_get_
@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,7 +694,7 @@ 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.
@@ -906,7 +710,7 @@ 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"
)
@@ -925,7 +729,7 @@ def test_api_document_accesses_delete_administrator_on_owners(via, mock_user_get
@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,7 +762,7 @@ 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
"""
@@ -968,12 +772,13 @@ def test_api_document_accesses_delete_owners_last_owner(via, mock_user_get_teams
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"
)

View File

@@ -0,0 +1,229 @@
"""
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."""
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_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_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_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()
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": 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 "Invitation to join Docs!" 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 "Invitation to join Docs!" 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,92 @@ 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 "Invitation to join Docs!" 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 "Invitation à rejoindre Docs !" 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 "Invitation to join Docs!" 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 +251,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 +279,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 +291,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 +304,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 +340,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 +392,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 +432,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 +445,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 +467,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 +486,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 +494,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()
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():
@@ -451,7 +607,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 +615,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 +632,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()
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,37 +14,29 @@ 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()
@@ -51,7 +44,7 @@ def test_api_document_versions_list_authenticated_unrelated_public():
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(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,7 +95,8 @@ 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 len(content["results"]) == 0
assert content["count"] == 0
# Add a new version to the document
document.content = "new content"
@@ -137,16 +108,17 @@ 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"] == ""
assert content["is_truncated"] is False
assert len(content["results"]) == 1
assert content["count"] == 1
def test_api_document_versions_retrieve_anonymous_public():
@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)
version_id = document.get_versions_slice()["versions"][0]["version_id"]
url = f"/api/v1.0/documents/{document.id!s}/versions/{version_id:s}/"
@@ -158,23 +130,10 @@ 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()
@@ -182,7 +141,7 @@ def test_api_document_versions_retrieve_authenticated_unrelated_public():
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(is_public=True)
document = factories.DocumentFactory(link_reach=reach)
version_id = document.get_versions_slice()["versions"][0]["version_id"]
response = client.get(
@@ -194,28 +153,8 @@ 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.
@@ -231,10 +170,10 @@ def test_api_document_versions_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")
# Versions created before the document was shared should not be available to the user
# 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}/",
)
@@ -266,10 +205,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 +231,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 +245,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(
@@ -330,7 +267,7 @@ def test_api_document_versions_update_anonymous():
{"foo": "bar"},
format="json",
)
assert response.status_code == 401
assert response.status_code == 405
def test_api_document_versions_update_authenticated_unrelated():
@@ -355,7 +292,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.
@@ -371,7 +308,7 @@ def test_api_document_versions_update_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.put(
@@ -396,7 +333,8 @@ 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.
@@ -406,7 +344,7 @@ def test_api_document_versions_delete_authenticated_public():
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(is_public=True)
document = factories.DocumentFactory(link_reach=reach)
version_id = document.get_versions_slice()["versions"][0]["version_id"]
response = client.delete(
@@ -416,30 +354,9 @@ 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.
@@ -453,7 +370,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
)
@@ -483,7 +400,7 @@ def test_api_document_versions_delete_reader_or_editor(via, role, mock_user_get_
@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,7 +414,7 @@ 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
)

View File

@@ -0,0 +1,255 @@
"""
Test file uploads API endpoint for users in impress's core app.
"""
import re
import uuid
from django.core.files.base import ContentFile
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
@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("test_file.jpg", b"Dummy content")
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("test_file.jpg", b"Dummy content")
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/(.*)\.jpg")
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()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach, link_role=role)
file = SimpleUploadedFile("test_file.jpg", b"Dummy content")
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("test_file.jpg", b"Dummy content")
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/(.*)\.jpg")
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()
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("test_file.jpg", b"Dummy content")
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("test_file.jpg", b"Dummy content")
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/(.*)\.jpg")
match = pattern.search(response.json()["file"])
file_id = match.group(1)
# Validate that file_id is a valid UUID
uuid.UUID(file_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
content = b"a" * (1048576 + 1)
file = ContentFile(content, name="test.jpg")
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."]}
def test_api_documents_attachment_upload_type_not_allowed(settings):
"""The uploaded file should be of a whitelisted type."""
settings.DOCUMENT_IMAGE_ALLOWED_MIME_TYPES = ["image/jpeg", "image/png"]
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 with a not allowed type (e.g., text file)
file = ContentFile(b"a" * 1048576, name="test.txt")
response = client.post(url, {"file": file}, format="multipart")
assert response.status_code == 400
assert response.json() == {
"file": [
"File type 'text/plain' is not allowed. Allowed types are: image/jpeg, image/png"
]
}

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,30 +23,31 @@ 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()
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 response.status_code == 403
assert models.Document.objects.count() == 1
@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.
@@ -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
)
@@ -78,7 +78,7 @@ def test_api_documents_delete_authenticated_not_owner(via, role, mock_user_get_t
@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()
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()
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,68 +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.
@@ -72,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
@@ -82,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
@@ -119,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
@@ -135,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
@@ -156,204 +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_created_at_desc():
"""
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)
documents_created = [
document.created_at.isoformat().replace("+00:00", "Z")
for document in factories.DocumentFactory.create_batch(5, is_public=True)
]
factories.DocumentFactory.create_batch(5, users=[user])
documents_created.sort(reverse=True)
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_created = [
document["created_at"] for document in response_data["results"]
]
assert (
response_document_created == documents_created
), "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_created_at_asc():
"""
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_created = [
document.created_at.isoformat().replace("+00:00", "Z")
for document in factories.DocumentFactory.create_batch(5, is_public=True)
]
factories.DocumentFactory.create_batch(5, users=[user])
documents_created.sort()
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 = client.get(
"/api/v1.0/documents/?ordering=created_at",
)
response = client.get(f"/api/v1.0/documents/{querystring:s}")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 5
assert response.status_code == 200
response_data = response.json()
response_document_created = [
document["created_at"] for document in response_data["results"]
]
assert (
response_document_created == documents_created
), "created_at values are not sorted from oldest to newest"
def test_api_documents_order_updated_at_desc():
"""
Test that the 'updated_at' field is sorted in descending order
when the 'ordering' query parameter is set.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
documents_updated = [
document.updated_at.isoformat().replace("+00:00", "Z")
for document in factories.DocumentFactory.create_batch(5, is_public=True)
]
documents_updated.sort(reverse=True)
response = APIClient().get(
"/api/v1.0/documents/?ordering=-updated_at",
)
assert response.status_code == 200
response_data = response.json()
response_document_updated = [
document["updated_at"] for document in response_data["results"]
]
assert (
response_document_updated == documents_updated
), "updated_at values are not sorted from newest to oldest"
def test_api_documents_order_updated_at_asc():
"""
Test that the 'updated_at' field is sorted in ascending order
when the 'ordering' query parameter is set.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
documents_updated = [
document.updated_at.isoformat().replace("+00:00", "Z")
for document in factories.DocumentFactory.create_batch(5, is_public=True)
]
documents_updated.sort()
response = APIClient().get(
"/api/v1.0/documents/?ordering=updated_at",
)
assert response.status_code == 200
response_data = response.json()
response_document_updated = [
document["updated_at"] for document in response_data["results"]
]
assert (
response_document_updated == documents_updated
), "updated_at values are not sorted from oldest to newest"
def test_api_documents_order_title_desc():
"""
Test that the 'title' field is sorted in descending order
when the 'ordering' query parameter is set.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
documents_title = [
factories.DocumentFactory(is_public=True, title=fake.sentence(nb_words=4)).title
for _ in range(5)
]
documents_title.sort(reverse=True)
response = APIClient().get(
"/api/v1.0/documents/?ordering=-title",
)
assert response.status_code == 200
response_data = response.json()
response_documents_title = [
document["title"] for document in response_data["results"]
]
assert (
response_documents_title == documents_title
), "title values are not sorted descending"
def test_api_documents_order_title_asc():
"""
Test that the 'title' field is sorted in ascending order
when the 'ordering' query parameter is set.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
documents_title = [
factories.DocumentFactory(is_public=True, title=fake.sentence(nb_words=4)).title
for _ in range(5)
]
documents_title.sort()
response = APIClient().get(
"/api/v1.0/documents/?ordering=title",
)
assert response.status_code == 200
response_data = response.json()
response_documents_title = [
document["title"] for document in response_data["results"]
]
assert (
response_documents_title == documents_title
), "title values are not sorted ascending"
# 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,35 +21,42 @@ def test_api_documents_retrieve_anonymous_public():
assert response.json() == {
"id": str(document.id),
"abilities": {
"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.
@@ -58,7 +66,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}/",
@@ -67,27 +75,62 @@ def test_api_documents_retrieve_authenticated_unrelated_public():
assert response.json() == {
"id": str(document.id),
"abilities": {
"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
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(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()
@@ -95,13 +138,15 @@ def test_api_documents_retrieve_authenticated_unrelated_not_public():
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(is_public=False)
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():
@@ -149,25 +194,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()
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"
@@ -183,8 +229,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(
@@ -197,20 +245,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"
@@ -228,6 +276,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 = {
@@ -282,7 +332,8 @@ 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"),
}
@@ -297,20 +348,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"
@@ -401,7 +452,8 @@ 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"),
}
@@ -417,20 +469,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"
@@ -524,7 +576,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()
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,16 +52,26 @@ 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()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(is_public=False)
document = factories.DocumentFactory(link_reach=reach, link_role=role)
old_document_values = serializers.DocumentSerializer(instance=document).data
new_document_values = serializers.DocumentSerializer(
@@ -57,18 +83,67 @@ 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()
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()
@@ -76,11 +151,11 @@ def test_api_documents_update_authenticated_reader(via, mock_user_get_teams):
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,7 +184,7 @@ 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()
@@ -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,7 +216,7 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
document = models.Document.objects.get(pk=document.pk)
document_values = serializers.DocumentSerializer(instance=document).data
for key, value in document_values.items():
if key in ["id", "accesses", "created_at"]:
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]
@@ -150,7 +225,7 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
@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()
@@ -161,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"
)
@@ -180,7 +255,7 @@ 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", "created_at"]:
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]
@@ -189,9 +264,7 @@ def test_api_documents_update_authenticated_owners(via, mock_user_get_teams):
@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.
@@ -207,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 @@
"""
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 template accesses API endpoints for users in impress's core app.
"""
import random
from uuid import uuid4
@@ -56,7 +57,7 @@ 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.
@@ -67,6 +68,7 @@ def test_api_template_accesses_list_authenticated_related(via, mock_user_get_tea
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",
@@ -170,11 +172,13 @@ 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.
@@ -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)
@@ -257,7 +261,7 @@ def test_api_template_accesses_create_authenticated_unrelated():
@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
via, role, mock_user_teams
):
"""Editors or readers of a template should not be allowed to create template accesses."""
user = factories.UserFactory()
@@ -269,7 +273,7 @@ def test_api_template_accesses_create_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
)
@@ -292,9 +296,7 @@ def test_api_template_accesses_create_authenticated_editor_or_reader(
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_create_authenticated_administrator(
via, mock_user_get_teams
):
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.
@@ -310,7 +312,7 @@ def test_api_template_accesses_create_authenticated_administrator(
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"
)
@@ -359,7 +361,7 @@ def test_api_template_accesses_create_authenticated_administrator(
@pytest.mark.parametrize("via", VIA)
def test_api_template_accesses_create_authenticated_owner(via, mock_user_get_teams):
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.
"""
@@ -372,7 +374,7 @@ def test_api_template_accesses_create_authenticated_owner(via, mock_user_get_tea
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"
)
@@ -462,7 +464,7 @@ 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()
@@ -474,7 +476,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,9 +504,7 @@ 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.
@@ -520,7 +520,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"
)
@@ -561,9 +561,7 @@ 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.
@@ -579,7 +577,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"
)
@@ -610,7 +608,7 @@ 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.
@@ -626,7 +624,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"
)
@@ -664,7 +662,7 @@ 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.
@@ -678,7 +676,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"
)
@@ -720,7 +718,7 @@ 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.
@@ -731,12 +729,13 @@ def test_api_template_accesses_update_owner_self(via, mock_user_get_teams):
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"
)
@@ -805,7 +804,7 @@ def test_api_template_accesses_delete_authenticated():
@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.
@@ -819,7 +818,7 @@ 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
)
@@ -839,7 +838,7 @@ def test_api_template_accesses_delete_editor_or_reader(via, role, mock_user_get_
@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 +855,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,7 +876,7 @@ 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.
@@ -893,7 +892,7 @@ 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"
)
@@ -912,7 +911,7 @@ def test_api_template_accesses_delete_administrator_on_owners(via, mock_user_get
@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 +925,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,7 +944,7 @@ 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
"""
@@ -955,12 +954,13 @@ def test_api_template_accesses_delete_owners_last_owner(via, mock_user_get_teams
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"
)

View File

@@ -1,6 +1,7 @@
"""
Test users API endpoints in the impress core app.
"""
import pytest
from rest_framework.test import APIClient

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,6 +1,7 @@
"""
Unit tests for the Document model
"""
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
@@ -26,15 +27,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 +57,93 @@ 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 == {
"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 == {
"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 == {
"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 +156,13 @@ def test_models_documents_get_abilities_owner():
access = factories.UserDocumentAccessFactory(role="owner", user=user)
abilities = access.document.get_abilities(access.user)
assert abilities == {
"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 +174,13 @@ def test_models_documents_get_abilities_administrator():
access = factories.UserDocumentAccessFactory(role="administrator")
abilities = access.document.get_abilities(access.user)
assert abilities == {
"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 +195,13 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
abilities = access.document.get_abilities(access.user)
assert abilities == {
"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 +210,21 @@ 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 == {
"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,18 +233,22 @@ 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 == {
"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,
@@ -216,7 +260,7 @@ def test_models_documents_get_versions_slice(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()

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,10 @@
"""
Unit tests for the Template model
"""
import os
from unittest import mock
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
@@ -184,3 +188,30 @@ 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_send_mail):
"""
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"
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,87 @@
"""
Unit tests for the Invitation model
"""
import smtplib
from logging import Logger
from unittest import mock
from django.core import mail
import pytest
from core.utils import email_invitation
pytestmark = pytest.mark.django_db
def test_utils__email_invitation_success():
"""
The email invitation is sent successfully.
"""
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
email_invitation("en", "guest@example.com", "123-456-789")
# 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 "Invitation to join Docs!" in email_content
assert "docs/123-456-789/" in email_content
def test_utils__email_invitation_success_fr():
"""
The email invitation is sent successfully in french.
"""
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
email_invitation("fr-fr", "guest@example.com", "123-456-789")
# 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 "Invitation à rejoindre Docs !" in email_content
assert "docs/123-456-789/" in email_content
@mock.patch(
"core.utils.send_mail",
side_effect=smtplib.SMTPException("Error SMTPException"),
)
@mock.patch.object(Logger, "error")
def test_utils__email_invitation_failed(mock_logger, _mock_send_mail):
"""Check mail behavior when an SMTP error occurs when sent an email invitation."""
# pylint: disable-next=no-member
assert len(mail.outbox) == 0
email_invitation("en", "guest@example.com", "123-456-789")
# 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 == "guest@example.com"
assert isinstance(exception, smtplib.SMTPException)

View File

@@ -1,4 +1,5 @@
"""URL configuration for the core app."""
from django.conf import settings
from django.urls import include, path, re_path

40
src/backend/core/utils.py Normal file
View File

@@ -0,0 +1,40 @@
"""
Utilities for the core app.
"""
import smtplib
from logging import getLogger
from django.conf import settings
from django.contrib.sites.models import Site
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.utils.translation import gettext_lazy as _
from django.utils.translation import override
logger = getLogger(__name__)
def email_invitation(language, email, document_id):
"""Send email invitation."""
try:
with override(language):
title = _("Invitation to join Docs!")
template_vars = {
"title": title,
"site": Site.objects.get_current(),
"document_id": document_id,
}
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)

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

@@ -130,7 +130,9 @@ def create_demo(stdout):
queue.push(
models.Document(
title=fake.sentence(nb_words=4),
is_public=random_true_with_probability(0.5),
link_reach=models.LinkReachChoices.AUTHENTICATED
if random_true_with_probability(0.5)
else random.choice(models.LinkReachChoices.values),
)
)

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

@@ -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,24 @@ 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_IMAGE_ALLOWED_MIME_TYPES = [
"image/bmp",
"image/gif",
"image/jpeg",
"image/png",
"image/svg+xml",
"image/tiff",
"image/webp",
]
# Document versions
DOCUMENT_VERSIONS_PAGE_SIZE = 50
# Internationalization
# https://docs.djangoproject.com/en/3.1/topics/i18n/
@@ -278,6 +296,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 +314,7 @@ class Base(Configuration):
# Easy thumbnails
THUMBNAIL_EXTENSION = "webp"
THUMBNAIL_TRANSPARENCY_EXTENSION = "webp"
THUMBNAIL_DEFAULT_STORAGE_ALIAS = "default"
THUMBNAIL_ALIASES = {}
# Celery

Binary file not shown.

View File

@@ -1,208 +1,563 @@
# 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-08-14 12:43+0000\n"
"PO-Revision-Date: 2024-08-14 12:48\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"
#: build/lib/build/lib/build/lib/build/lib/core/admin.py:31
#: build/lib/build/lib/build/lib/core/admin.py:31
#: build/lib/build/lib/core/admin.py:31 build/lib/core/admin.py:31
#: core/admin.py:31
msgid "Personal info"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/admin.py:33
#: build/lib/build/lib/build/lib/core/admin.py:33
#: build/lib/build/lib/core/admin.py:33 build/lib/core/admin.py:33
#: core/admin.py:33
msgid "Permissions"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/admin.py:45
#: build/lib/build/lib/build/lib/core/admin.py:45
#: build/lib/build/lib/core/admin.py:45 build/lib/core/admin.py:45
#: core/admin.py:45
msgid "Important dates"
msgstr ""
#: core/api/serializers.py:128
msgid "Markdown Body"
#: build/lib/build/lib/build/lib/build/lib/core/api/serializers.py:176
#: build/lib/build/lib/build/lib/core/api/serializers.py:176
#: build/lib/build/lib/core/api/serializers.py:176
#: build/lib/core/api/serializers.py:176 core/api/serializers.py:176
msgid "Body"
msgstr ""
#: core/authentication.py:71
#: build/lib/build/lib/build/lib/build/lib/core/api/serializers.py:179
#: build/lib/build/lib/build/lib/core/api/serializers.py:179
#: build/lib/build/lib/core/api/serializers.py:179
#: build/lib/core/api/serializers.py:179 core/api/serializers.py:179
msgid "Body type"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/authentication/backends.py:71
#: build/lib/build/lib/build/lib/core/authentication/backends.py:71
#: build/lib/build/lib/core/authentication/backends.py:71
#: build/lib/core/authentication/backends.py:71
#: core/authentication/backends.py:71
msgid "User info contained no recognizable user identification"
msgstr ""
#: core/authentication.py:91
#: build/lib/build/lib/build/lib/build/lib/core/authentication/backends.py:91
#: build/lib/build/lib/build/lib/core/authentication/backends.py:91
#: build/lib/build/lib/core/authentication/backends.py:91
#: build/lib/core/authentication/backends.py:91
#: core/authentication/backends.py:91
msgid "Claims contained no recognizable user identification"
msgstr ""
#: core/models.py:27
msgid "Member"
#: build/lib/build/lib/build/lib/build/lib/core/models.py:60
#: build/lib/build/lib/build/lib/core/models.py:60
#: build/lib/build/lib/core/models.py:60 build/lib/core/models.py:60
#: core/models.py:61
msgid "Reader"
msgstr ""
#: core/models.py:28
#: build/lib/build/lib/build/lib/build/lib/core/models.py:61
#: build/lib/build/lib/build/lib/core/models.py:61
#: build/lib/build/lib/core/models.py:61 build/lib/core/models.py:61
#: core/models.py:62
msgid "Editor"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:62
#: build/lib/build/lib/build/lib/core/models.py:62
#: build/lib/build/lib/core/models.py:62 build/lib/core/models.py:62
#: core/models.py:63
msgid "Administrator"
msgstr ""
#: core/models.py:29
#: build/lib/build/lib/build/lib/build/lib/core/models.py:63
#: build/lib/build/lib/build/lib/core/models.py:63
#: build/lib/build/lib/core/models.py:63 build/lib/core/models.py:63
#: core/models.py:64
msgid "Owner"
msgstr ""
#: core/models.py:41
#: build/lib/build/lib/build/lib/build/lib/core/models.py:75
#: build/lib/build/lib/build/lib/core/models.py:75
#: build/lib/build/lib/core/models.py:75 build/lib/core/models.py:75
#: core/models.py:76
msgid "id"
msgstr ""
#: core/models.py:42
#: build/lib/build/lib/build/lib/build/lib/core/models.py:76
#: build/lib/build/lib/build/lib/core/models.py:76
#: build/lib/build/lib/core/models.py:76 build/lib/core/models.py:76
#: core/models.py:77
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:48
#: build/lib/build/lib/build/lib/build/lib/core/models.py:82
#: build/lib/build/lib/build/lib/core/models.py:82
#: build/lib/build/lib/core/models.py:82 build/lib/core/models.py:82
#: core/models.py:83
msgid "created on"
msgstr ""
#: core/models.py:49
#: build/lib/build/lib/build/lib/build/lib/core/models.py:83
#: build/lib/build/lib/build/lib/core/models.py:83
#: build/lib/build/lib/core/models.py:83 build/lib/core/models.py:83
#: core/models.py:84
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:54
#: build/lib/build/lib/build/lib/build/lib/core/models.py:88
#: build/lib/build/lib/build/lib/core/models.py:88
#: build/lib/build/lib/core/models.py:88 build/lib/core/models.py:88
#: core/models.py:89
msgid "updated on"
msgstr ""
#: core/models.py:55
#: build/lib/build/lib/build/lib/build/lib/core/models.py:89
#: build/lib/build/lib/build/lib/core/models.py:89
#: build/lib/build/lib/core/models.py:89 build/lib/core/models.py:89
#: core/models.py:90
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."
#: build/lib/build/lib/build/lib/build/lib/core/models.py:109
#: build/lib/build/lib/build/lib/core/models.py:109
#: build/lib/build/lib/core/models.py:109 build/lib/core/models.py:109
#: core/models.py:110
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
msgstr ""
#: core/models.py:81
#: build/lib/build/lib/build/lib/build/lib/core/models.py:115
#: build/lib/build/lib/build/lib/core/models.py:115
#: build/lib/build/lib/core/models.py:115 build/lib/core/models.py:115
#: core/models.py:116
msgid "sub"
msgstr ""
#: core/models.py:83
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
#: build/lib/build/lib/build/lib/build/lib/core/models.py:117
#: build/lib/build/lib/build/lib/core/models.py:117
#: build/lib/build/lib/core/models.py:117 build/lib/core/models.py:117
#: core/models.py:118
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
#: core/models.py:91
#: build/lib/build/lib/build/lib/build/lib/core/models.py:125
#: build/lib/build/lib/build/lib/core/models.py:125
#: build/lib/build/lib/core/models.py:125 build/lib/core/models.py:125
#: core/models.py:126
msgid "identity email address"
msgstr ""
#: core/models.py:96
#: build/lib/build/lib/build/lib/build/lib/core/models.py:130
#: build/lib/build/lib/build/lib/core/models.py:130
#: build/lib/build/lib/core/models.py:130 build/lib/core/models.py:130
#: core/models.py:131
msgid "admin email address"
msgstr ""
#: core/models.py:103
#: build/lib/build/lib/build/lib/build/lib/core/models.py:137
#: build/lib/build/lib/build/lib/core/models.py:137
#: build/lib/build/lib/core/models.py:137 build/lib/core/models.py:137
#: core/models.py:138
msgid "language"
msgstr ""
#: core/models.py:104
#: build/lib/build/lib/build/lib/build/lib/core/models.py:138
#: build/lib/build/lib/build/lib/core/models.py:138
#: build/lib/build/lib/core/models.py:138 build/lib/core/models.py:138
#: core/models.py:139
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:110
#: build/lib/build/lib/build/lib/build/lib/core/models.py:144
#: build/lib/build/lib/build/lib/core/models.py:144
#: build/lib/build/lib/core/models.py:144 build/lib/core/models.py:144
#: core/models.py:145
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:113
#: build/lib/build/lib/build/lib/build/lib/core/models.py:147
#: build/lib/build/lib/build/lib/core/models.py:147
#: build/lib/build/lib/core/models.py:147 build/lib/core/models.py:147
#: core/models.py:148
msgid "device"
msgstr ""
#: core/models.py:115
#: build/lib/build/lib/build/lib/build/lib/core/models.py:149
#: build/lib/build/lib/build/lib/core/models.py:149
#: build/lib/build/lib/core/models.py:149 build/lib/core/models.py:149
#: core/models.py:150
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:118
#: build/lib/build/lib/build/lib/build/lib/core/models.py:152
#: build/lib/build/lib/build/lib/core/models.py:152
#: build/lib/build/lib/core/models.py:152 build/lib/core/models.py:152
#: core/models.py:153
msgid "staff status"
msgstr ""
#: core/models.py:120
#: build/lib/build/lib/build/lib/build/lib/core/models.py:154
#: build/lib/build/lib/build/lib/core/models.py:154
#: build/lib/build/lib/core/models.py:154 build/lib/core/models.py:154
#: core/models.py:155
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:123
#: build/lib/build/lib/build/lib/build/lib/core/models.py:157
#: build/lib/build/lib/build/lib/core/models.py:157
#: build/lib/build/lib/core/models.py:157 build/lib/core/models.py:157
#: core/models.py:158
msgid "active"
msgstr ""
#: core/models.py:126
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
#: build/lib/build/lib/build/lib/build/lib/core/models.py:160
#: build/lib/build/lib/build/lib/core/models.py:160
#: build/lib/build/lib/core/models.py:160 build/lib/core/models.py:160
#: core/models.py:161
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: core/models.py:138
#: build/lib/build/lib/build/lib/build/lib/core/models.py:172
#: build/lib/build/lib/build/lib/core/models.py:172
#: build/lib/build/lib/core/models.py:172 build/lib/core/models.py:172
#: core/models.py:173
msgid "user"
msgstr ""
#: core/models.py:139
#: build/lib/build/lib/build/lib/build/lib/core/models.py:173
#: build/lib/build/lib/build/lib/core/models.py:173
#: build/lib/build/lib/core/models.py:173 build/lib/core/models.py:173
#: core/models.py:174
msgid "users"
msgstr ""
#: core/models.py:161
#: build/lib/build/lib/build/lib/build/lib/core/models.py:304
#: build/lib/build/lib/build/lib/build/lib/core/models.py:531
#: build/lib/build/lib/build/lib/core/models.py:304
#: build/lib/build/lib/build/lib/core/models.py:531
#: build/lib/build/lib/core/models.py:304
#: build/lib/build/lib/core/models.py:531 build/lib/core/models.py:304
#: build/lib/core/models.py:531 core/models.py:305 core/models.py:532
msgid "title"
msgstr ""
#: core/models.py:162
msgid "description"
msgstr ""
#: core/models.py:163
msgid "code"
msgstr ""
#: core/models.py:164
msgid "css"
msgstr ""
#: core/models.py:166
#: build/lib/build/lib/build/lib/build/lib/core/models.py:306
#: build/lib/build/lib/build/lib/build/lib/core/models.py:536
#: build/lib/build/lib/build/lib/core/models.py:306
#: build/lib/build/lib/build/lib/core/models.py:536
#: build/lib/build/lib/core/models.py:306
#: build/lib/build/lib/core/models.py:536 build/lib/core/models.py:306
#: build/lib/core/models.py:536 core/models.py:307 core/models.py:537
msgid "public"
msgstr ""
#: core/models.py:168
msgid "Whether this template is public for anyone to use."
#: build/lib/build/lib/build/lib/build/lib/core/models.py:308
#: build/lib/build/lib/build/lib/core/models.py:308
#: build/lib/build/lib/core/models.py:308 build/lib/core/models.py:308
#: core/models.py:309
msgid "Whether this document is public for anyone to use."
msgstr ""
#: core/models.py:174
msgid "Template"
#: build/lib/build/lib/build/lib/build/lib/core/models.py:316
#: build/lib/build/lib/build/lib/core/models.py:316
#: build/lib/build/lib/core/models.py:316 build/lib/core/models.py:316
#: core/models.py:317
msgid "Document"
msgstr ""
#: core/models.py:175
msgid "Templates"
#: build/lib/build/lib/build/lib/build/lib/core/models.py:317
#: build/lib/build/lib/build/lib/core/models.py:317
#: build/lib/build/lib/core/models.py:317 build/lib/core/models.py:317
#: core/models.py:318
msgid "Documents"
msgstr ""
#: core/models.py:256
msgid "Template/user relation"
#: build/lib/build/lib/build/lib/build/lib/core/models.py:495
#: build/lib/build/lib/build/lib/core/models.py:495
#: build/lib/build/lib/core/models.py:495 build/lib/core/models.py:495
#: core/models.py:496
msgid "Document/user relation"
msgstr ""
#: core/models.py:257
msgid "Template/user relations"
#: build/lib/build/lib/build/lib/build/lib/core/models.py:496
#: build/lib/build/lib/build/lib/core/models.py:496
#: build/lib/build/lib/core/models.py:496 build/lib/core/models.py:496
#: core/models.py:497
msgid "Document/user relations"
msgstr ""
#: core/models.py:263
msgid "This user is already in this template."
#: build/lib/build/lib/build/lib/build/lib/core/models.py:502
#: build/lib/build/lib/build/lib/core/models.py:502
#: build/lib/build/lib/core/models.py:502 build/lib/core/models.py:502
#: core/models.py:503
msgid "This user is already in this document."
msgstr ""
#: core/models.py:269
msgid "This team is already in this template."
#: build/lib/build/lib/build/lib/build/lib/core/models.py:508
#: build/lib/build/lib/build/lib/core/models.py:508
#: build/lib/build/lib/core/models.py:508 build/lib/core/models.py:508
#: core/models.py:509
msgid "This team is already in this document."
msgstr ""
#: core/models.py:275
#: build/lib/build/lib/build/lib/build/lib/core/models.py:514
#: build/lib/build/lib/build/lib/build/lib/core/models.py:691
#: build/lib/build/lib/build/lib/core/models.py:514
#: build/lib/build/lib/build/lib/core/models.py:691
#: build/lib/build/lib/core/models.py:514
#: build/lib/build/lib/core/models.py:691 build/lib/core/models.py:514
#: build/lib/core/models.py:691 core/models.py:515 core/models.py:704
msgid "Either user or team must be set, not both."
msgstr ""
#: impress/settings.py:134
#: build/lib/build/lib/build/lib/build/lib/core/models.py:532
#: build/lib/build/lib/build/lib/core/models.py:532
#: build/lib/build/lib/core/models.py:532 build/lib/core/models.py:532
#: core/models.py:533
msgid "description"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:533
#: build/lib/build/lib/build/lib/core/models.py:533
#: build/lib/build/lib/core/models.py:533 build/lib/core/models.py:533
#: core/models.py:534
msgid "code"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:534
#: build/lib/build/lib/build/lib/core/models.py:534
#: build/lib/build/lib/core/models.py:534 build/lib/core/models.py:534
#: core/models.py:535
msgid "css"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:538
#: build/lib/build/lib/build/lib/core/models.py:538
#: build/lib/build/lib/core/models.py:538 build/lib/core/models.py:538
#: core/models.py:539
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:544
#: build/lib/build/lib/build/lib/core/models.py:544
#: build/lib/build/lib/core/models.py:544 build/lib/core/models.py:544
#: core/models.py:545
msgid "Template"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:545
#: build/lib/build/lib/build/lib/core/models.py:545
#: build/lib/build/lib/core/models.py:545 build/lib/core/models.py:545
#: core/models.py:546
msgid "Templates"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:672
#: build/lib/build/lib/build/lib/core/models.py:672
#: build/lib/build/lib/core/models.py:672 build/lib/core/models.py:672
#: core/models.py:685
msgid "Template/user relation"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:673
#: build/lib/build/lib/build/lib/core/models.py:673
#: build/lib/build/lib/core/models.py:673 build/lib/core/models.py:673
#: core/models.py:686
msgid "Template/user relations"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:679
#: build/lib/build/lib/build/lib/core/models.py:679
#: build/lib/build/lib/core/models.py:679 build/lib/core/models.py:679
#: core/models.py:692
msgid "This user is already in this template."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:685
#: build/lib/build/lib/build/lib/core/models.py:685
#: build/lib/build/lib/core/models.py:685 build/lib/core/models.py:685
#: core/models.py:698
msgid "This team is already in this template."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:708
#: build/lib/build/lib/build/lib/core/models.py:708
#: build/lib/build/lib/core/models.py:708 build/lib/core/models.py:708
#: core/models.py:721
msgid "email address"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:725
#: build/lib/build/lib/build/lib/core/models.py:725
#: build/lib/build/lib/core/models.py:725 build/lib/core/models.py:725
#: core/models.py:738
msgid "Document invitation"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:726
#: build/lib/build/lib/build/lib/core/models.py:726
#: build/lib/build/lib/core/models.py:726 build/lib/core/models.py:726
#: core/models.py:739
msgid "Document invitations"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:751
#: build/lib/build/lib/build/lib/core/models.py:751
#: build/lib/build/lib/core/models.py:751 build/lib/core/models.py:751
#: core/models.py:764
msgid "This email is already associated to a registered user."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:795
#: build/lib/build/lib/build/lib/core/models.py:795
#: build/lib/build/lib/core/models.py:795 build/lib/core/models.py:795
msgid "Invitation to join Impress!"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/impress/settings.py:158
#: build/lib/build/lib/build/lib/impress/settings.py:158
#: build/lib/build/lib/impress/settings.py:158
#: build/lib/impress/settings.py:158 impress/settings.py:158
msgid "English"
msgstr ""
#: impress/settings.py:135
#: build/lib/build/lib/build/lib/build/lib/impress/settings.py:159
#: build/lib/build/lib/build/lib/impress/settings.py:159
#: build/lib/build/lib/impress/settings.py:159
#: build/lib/impress/settings.py:159 impress/settings.py:159
msgid "French"
msgstr ""
#: build/lib/build/lib/core/api/serializers.py:185
#: build/lib/core/api/serializers.py:185 core/api/serializers.py:185
msgid "Format"
msgstr ""
#: core/models.py:808
msgid "Invitation to join Docs!"
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:160
#: core/templates/mail/text/invitation.txt:3
msgid "La Suite Numérique"
msgstr ""
#: core/templates/mail/html/invitation.html:190
#: core/templates/mail/text/invitation.txt:5
msgid "Invitation to join a document !"
msgstr ""
#: core/templates/mail/html/invitation.html:198
msgid "Welcome to <strong>Docs!</strong>"
msgstr ""
#: core/templates/mail/html/invitation.html:213
#: core/templates/mail/text/invitation.txt:12
msgid "We are delighted to welcome you to our community on Docs, your new companion to collaborate on documents efficiently, intuitively, and securely."
msgstr ""
#: core/templates/mail/html/invitation.html:218
#: core/templates/mail/text/invitation.txt:13
msgid "Our application is designed to help you organize, collaborate, and manage permissions."
msgstr ""
#: core/templates/mail/html/invitation.html:223
#: core/templates/mail/text/invitation.txt:14
msgid "With Docs, you will be able to:"
msgstr ""
#: core/templates/mail/html/invitation.html:224
#: core/templates/mail/text/invitation.txt:15
msgid "Create documents."
msgstr ""
#: core/templates/mail/html/invitation.html:225
#: core/templates/mail/text/invitation.txt:16
msgid "Work offline."
msgstr ""
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:17
msgid "Invite members of your community to your document in just a few clicks."
msgstr ""
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:19
msgid "Visit Docs"
msgstr ""
#: core/templates/mail/html/invitation.html:246
#: core/templates/mail/text/invitation.txt:21
msgid "We are confident that Docs will help you increase efficiency and productivity while strengthening the bond among members."
msgstr ""
#: core/templates/mail/html/invitation.html:251
#: core/templates/mail/text/invitation.txt:22
msgid "Feel free to explore all the features of the application and share your feedback and suggestions with us. Your feedback is valuable to us and will enable us to continually improve our service."
msgstr ""
#: core/templates/mail/html/invitation.html:256
#: core/templates/mail/text/invitation.txt:23
msgid "Once again, welcome aboard! We are eager to accompany you on your collaboration adventure."
msgstr ""
#: core/templates/mail/html/invitation.html:263
#: core/templates/mail/text/invitation.txt:25
msgid "Sincerely,"
msgstr ""
#: core/templates/mail/html/invitation.html:264
#: core/templates/mail/text/invitation.txt:27
msgid "The La Suite Numérique Team"
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 ""
#: core/templates/mail/text/invitation.txt:8
msgid "Welcome to Docs!"
msgstr ""

Binary file not shown.

View File

@@ -1,208 +1,563 @@
# 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-08-14 12:43+0000\n"
"PO-Revision-Date: 2024-08-14 12:48\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"
"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"
#: build/lib/build/lib/build/lib/build/lib/core/admin.py:31
#: build/lib/build/lib/build/lib/core/admin.py:31
#: build/lib/build/lib/core/admin.py:31 build/lib/core/admin.py:31
#: core/admin.py:31
msgid "Personal info"
msgstr ""
msgstr "Infos Personnelles"
#: build/lib/build/lib/build/lib/build/lib/core/admin.py:33
#: build/lib/build/lib/build/lib/core/admin.py:33
#: build/lib/build/lib/core/admin.py:33 build/lib/core/admin.py:33
#: core/admin.py:33
msgid "Permissions"
msgstr ""
msgstr "Permissions"
#: build/lib/build/lib/build/lib/build/lib/core/admin.py:45
#: build/lib/build/lib/build/lib/core/admin.py:45
#: build/lib/build/lib/core/admin.py:45 build/lib/core/admin.py:45
#: core/admin.py:45
msgid "Important dates"
msgstr "Dates importantes"
#: build/lib/build/lib/build/lib/build/lib/core/api/serializers.py:176
#: build/lib/build/lib/build/lib/core/api/serializers.py:176
#: build/lib/build/lib/core/api/serializers.py:176
#: build/lib/core/api/serializers.py:176 core/api/serializers.py:176
msgid "Body"
msgstr ""
#: core/api/serializers.py:128
msgid "Markdown Body"
#: build/lib/build/lib/build/lib/build/lib/core/api/serializers.py:179
#: build/lib/build/lib/build/lib/core/api/serializers.py:179
#: build/lib/build/lib/core/api/serializers.py:179
#: build/lib/core/api/serializers.py:179 core/api/serializers.py:179
msgid "Body type"
msgstr ""
#: core/authentication.py:71
#: build/lib/build/lib/build/lib/build/lib/core/authentication/backends.py:71
#: build/lib/build/lib/build/lib/core/authentication/backends.py:71
#: build/lib/build/lib/core/authentication/backends.py:71
#: build/lib/core/authentication/backends.py:71
#: core/authentication/backends.py:71
msgid "User info contained no recognizable user identification"
msgstr ""
#: core/authentication.py:91
#: build/lib/build/lib/build/lib/build/lib/core/authentication/backends.py:91
#: build/lib/build/lib/build/lib/core/authentication/backends.py:91
#: build/lib/build/lib/core/authentication/backends.py:91
#: build/lib/core/authentication/backends.py:91
#: core/authentication/backends.py:91
msgid "Claims contained no recognizable user identification"
msgstr ""
#: core/models.py:27
msgid "Member"
#: build/lib/build/lib/build/lib/build/lib/core/models.py:60
#: build/lib/build/lib/build/lib/core/models.py:60
#: build/lib/build/lib/core/models.py:60 build/lib/core/models.py:60
#: core/models.py:61
msgid "Reader"
msgstr ""
#: core/models.py:28
#: build/lib/build/lib/build/lib/build/lib/core/models.py:61
#: build/lib/build/lib/build/lib/core/models.py:61
#: build/lib/build/lib/core/models.py:61 build/lib/core/models.py:61
#: core/models.py:62
msgid "Editor"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:62
#: build/lib/build/lib/build/lib/core/models.py:62
#: build/lib/build/lib/core/models.py:62 build/lib/core/models.py:62
#: core/models.py:63
msgid "Administrator"
msgstr ""
#: core/models.py:29
#: build/lib/build/lib/build/lib/build/lib/core/models.py:63
#: build/lib/build/lib/build/lib/core/models.py:63
#: build/lib/build/lib/core/models.py:63 build/lib/core/models.py:63
#: core/models.py:64
msgid "Owner"
msgstr ""
#: core/models.py:41
#: build/lib/build/lib/build/lib/build/lib/core/models.py:75
#: build/lib/build/lib/build/lib/core/models.py:75
#: build/lib/build/lib/core/models.py:75 build/lib/core/models.py:75
#: core/models.py:76
msgid "id"
msgstr ""
#: core/models.py:42
#: build/lib/build/lib/build/lib/build/lib/core/models.py:76
#: build/lib/build/lib/build/lib/core/models.py:76
#: build/lib/build/lib/core/models.py:76 build/lib/core/models.py:76
#: core/models.py:77
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:48
#: build/lib/build/lib/build/lib/build/lib/core/models.py:82
#: build/lib/build/lib/build/lib/core/models.py:82
#: build/lib/build/lib/core/models.py:82 build/lib/core/models.py:82
#: core/models.py:83
msgid "created on"
msgstr ""
#: core/models.py:49
#: build/lib/build/lib/build/lib/build/lib/core/models.py:83
#: build/lib/build/lib/build/lib/core/models.py:83
#: build/lib/build/lib/core/models.py:83 build/lib/core/models.py:83
#: core/models.py:84
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:54
#: build/lib/build/lib/build/lib/build/lib/core/models.py:88
#: build/lib/build/lib/build/lib/core/models.py:88
#: build/lib/build/lib/core/models.py:88 build/lib/core/models.py:88
#: core/models.py:89
msgid "updated on"
msgstr ""
#: core/models.py:55
#: build/lib/build/lib/build/lib/build/lib/core/models.py:89
#: build/lib/build/lib/build/lib/core/models.py:89
#: build/lib/build/lib/core/models.py:89 build/lib/core/models.py:89
#: core/models.py:90
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."
#: build/lib/build/lib/build/lib/build/lib/core/models.py:109
#: build/lib/build/lib/build/lib/core/models.py:109
#: build/lib/build/lib/core/models.py:109 build/lib/core/models.py:109
#: core/models.py:110
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters."
msgstr ""
#: core/models.py:81
#: build/lib/build/lib/build/lib/build/lib/core/models.py:115
#: build/lib/build/lib/build/lib/core/models.py:115
#: build/lib/build/lib/core/models.py:115 build/lib/core/models.py:115
#: core/models.py:116
msgid "sub"
msgstr ""
#: core/models.py:83
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
#: build/lib/build/lib/build/lib/build/lib/core/models.py:117
#: build/lib/build/lib/build/lib/core/models.py:117
#: build/lib/build/lib/core/models.py:117 build/lib/core/models.py:117
#: core/models.py:118
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
#: core/models.py:91
#: build/lib/build/lib/build/lib/build/lib/core/models.py:125
#: build/lib/build/lib/build/lib/core/models.py:125
#: build/lib/build/lib/core/models.py:125 build/lib/core/models.py:125
#: core/models.py:126
msgid "identity email address"
msgstr ""
#: core/models.py:96
#: build/lib/build/lib/build/lib/build/lib/core/models.py:130
#: build/lib/build/lib/build/lib/core/models.py:130
#: build/lib/build/lib/core/models.py:130 build/lib/core/models.py:130
#: core/models.py:131
msgid "admin email address"
msgstr ""
#: core/models.py:103
#: build/lib/build/lib/build/lib/build/lib/core/models.py:137
#: build/lib/build/lib/build/lib/core/models.py:137
#: build/lib/build/lib/core/models.py:137 build/lib/core/models.py:137
#: core/models.py:138
msgid "language"
msgstr ""
#: core/models.py:104
#: build/lib/build/lib/build/lib/build/lib/core/models.py:138
#: build/lib/build/lib/build/lib/core/models.py:138
#: build/lib/build/lib/core/models.py:138 build/lib/core/models.py:138
#: core/models.py:139
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:110
#: build/lib/build/lib/build/lib/build/lib/core/models.py:144
#: build/lib/build/lib/build/lib/core/models.py:144
#: build/lib/build/lib/core/models.py:144 build/lib/core/models.py:144
#: core/models.py:145
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:113
#: build/lib/build/lib/build/lib/build/lib/core/models.py:147
#: build/lib/build/lib/build/lib/core/models.py:147
#: build/lib/build/lib/core/models.py:147 build/lib/core/models.py:147
#: core/models.py:148
msgid "device"
msgstr ""
#: core/models.py:115
#: build/lib/build/lib/build/lib/build/lib/core/models.py:149
#: build/lib/build/lib/build/lib/core/models.py:149
#: build/lib/build/lib/core/models.py:149 build/lib/core/models.py:149
#: core/models.py:150
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:118
#: build/lib/build/lib/build/lib/build/lib/core/models.py:152
#: build/lib/build/lib/build/lib/core/models.py:152
#: build/lib/build/lib/core/models.py:152 build/lib/core/models.py:152
#: core/models.py:153
msgid "staff status"
msgstr ""
#: core/models.py:120
#: build/lib/build/lib/build/lib/build/lib/core/models.py:154
#: build/lib/build/lib/build/lib/core/models.py:154
#: build/lib/build/lib/core/models.py:154 build/lib/core/models.py:154
#: core/models.py:155
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:123
#: build/lib/build/lib/build/lib/build/lib/core/models.py:157
#: build/lib/build/lib/build/lib/core/models.py:157
#: build/lib/build/lib/core/models.py:157 build/lib/core/models.py:157
#: core/models.py:158
msgid "active"
msgstr ""
#: core/models.py:126
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
#: build/lib/build/lib/build/lib/build/lib/core/models.py:160
#: build/lib/build/lib/build/lib/core/models.py:160
#: build/lib/build/lib/core/models.py:160 build/lib/core/models.py:160
#: core/models.py:161
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: core/models.py:138
#: build/lib/build/lib/build/lib/build/lib/core/models.py:172
#: build/lib/build/lib/build/lib/core/models.py:172
#: build/lib/build/lib/core/models.py:172 build/lib/core/models.py:172
#: core/models.py:173
msgid "user"
msgstr ""
#: core/models.py:139
#: build/lib/build/lib/build/lib/build/lib/core/models.py:173
#: build/lib/build/lib/build/lib/core/models.py:173
#: build/lib/build/lib/core/models.py:173 build/lib/core/models.py:173
#: core/models.py:174
msgid "users"
msgstr ""
#: core/models.py:161
#: build/lib/build/lib/build/lib/build/lib/core/models.py:304
#: build/lib/build/lib/build/lib/build/lib/core/models.py:531
#: build/lib/build/lib/build/lib/core/models.py:304
#: build/lib/build/lib/build/lib/core/models.py:531
#: build/lib/build/lib/core/models.py:304
#: build/lib/build/lib/core/models.py:531 build/lib/core/models.py:304
#: build/lib/core/models.py:531 core/models.py:305 core/models.py:532
msgid "title"
msgstr ""
#: core/models.py:162
msgid "description"
msgstr ""
#: core/models.py:163
msgid "code"
msgstr ""
#: core/models.py:164
msgid "css"
msgstr ""
#: core/models.py:166
#: build/lib/build/lib/build/lib/build/lib/core/models.py:306
#: build/lib/build/lib/build/lib/build/lib/core/models.py:536
#: build/lib/build/lib/build/lib/core/models.py:306
#: build/lib/build/lib/build/lib/core/models.py:536
#: build/lib/build/lib/core/models.py:306
#: build/lib/build/lib/core/models.py:536 build/lib/core/models.py:306
#: build/lib/core/models.py:536 core/models.py:307 core/models.py:537
msgid "public"
msgstr ""
#: core/models.py:168
msgid "Whether this template is public for anyone to use."
#: build/lib/build/lib/build/lib/build/lib/core/models.py:308
#: build/lib/build/lib/build/lib/core/models.py:308
#: build/lib/build/lib/core/models.py:308 build/lib/core/models.py:308
#: core/models.py:309
msgid "Whether this document is public for anyone to use."
msgstr ""
#: core/models.py:174
msgid "Template"
#: build/lib/build/lib/build/lib/build/lib/core/models.py:316
#: build/lib/build/lib/build/lib/core/models.py:316
#: build/lib/build/lib/core/models.py:316 build/lib/core/models.py:316
#: core/models.py:317
msgid "Document"
msgstr ""
#: core/models.py:175
msgid "Templates"
#: build/lib/build/lib/build/lib/build/lib/core/models.py:317
#: build/lib/build/lib/build/lib/core/models.py:317
#: build/lib/build/lib/core/models.py:317 build/lib/core/models.py:317
#: core/models.py:318
msgid "Documents"
msgstr ""
#: core/models.py:256
msgid "Template/user relation"
#: build/lib/build/lib/build/lib/build/lib/core/models.py:495
#: build/lib/build/lib/build/lib/core/models.py:495
#: build/lib/build/lib/core/models.py:495 build/lib/core/models.py:495
#: core/models.py:496
msgid "Document/user relation"
msgstr ""
#: core/models.py:257
msgid "Template/user relations"
#: build/lib/build/lib/build/lib/build/lib/core/models.py:496
#: build/lib/build/lib/build/lib/core/models.py:496
#: build/lib/build/lib/core/models.py:496 build/lib/core/models.py:496
#: core/models.py:497
msgid "Document/user relations"
msgstr ""
#: core/models.py:263
msgid "This user is already in this template."
#: build/lib/build/lib/build/lib/build/lib/core/models.py:502
#: build/lib/build/lib/build/lib/core/models.py:502
#: build/lib/build/lib/core/models.py:502 build/lib/core/models.py:502
#: core/models.py:503
msgid "This user is already in this document."
msgstr ""
#: core/models.py:269
msgid "This team is already in this template."
#: build/lib/build/lib/build/lib/build/lib/core/models.py:508
#: build/lib/build/lib/build/lib/core/models.py:508
#: build/lib/build/lib/core/models.py:508 build/lib/core/models.py:508
#: core/models.py:509
msgid "This team is already in this document."
msgstr ""
#: core/models.py:275
#: build/lib/build/lib/build/lib/build/lib/core/models.py:514
#: build/lib/build/lib/build/lib/build/lib/core/models.py:691
#: build/lib/build/lib/build/lib/core/models.py:514
#: build/lib/build/lib/build/lib/core/models.py:691
#: build/lib/build/lib/core/models.py:514
#: build/lib/build/lib/core/models.py:691 build/lib/core/models.py:514
#: build/lib/core/models.py:691 core/models.py:515 core/models.py:704
msgid "Either user or team must be set, not both."
msgstr ""
#: impress/settings.py:134
#: build/lib/build/lib/build/lib/build/lib/core/models.py:532
#: build/lib/build/lib/build/lib/core/models.py:532
#: build/lib/build/lib/core/models.py:532 build/lib/core/models.py:532
#: core/models.py:533
msgid "description"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:533
#: build/lib/build/lib/build/lib/core/models.py:533
#: build/lib/build/lib/core/models.py:533 build/lib/core/models.py:533
#: core/models.py:534
msgid "code"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:534
#: build/lib/build/lib/build/lib/core/models.py:534
#: build/lib/build/lib/core/models.py:534 build/lib/core/models.py:534
#: core/models.py:535
msgid "css"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:538
#: build/lib/build/lib/build/lib/core/models.py:538
#: build/lib/build/lib/core/models.py:538 build/lib/core/models.py:538
#: core/models.py:539
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:544
#: build/lib/build/lib/build/lib/core/models.py:544
#: build/lib/build/lib/core/models.py:544 build/lib/core/models.py:544
#: core/models.py:545
msgid "Template"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:545
#: build/lib/build/lib/build/lib/core/models.py:545
#: build/lib/build/lib/core/models.py:545 build/lib/core/models.py:545
#: core/models.py:546
msgid "Templates"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:672
#: build/lib/build/lib/build/lib/core/models.py:672
#: build/lib/build/lib/core/models.py:672 build/lib/core/models.py:672
#: core/models.py:685
msgid "Template/user relation"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:673
#: build/lib/build/lib/build/lib/core/models.py:673
#: build/lib/build/lib/core/models.py:673 build/lib/core/models.py:673
#: core/models.py:686
msgid "Template/user relations"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:679
#: build/lib/build/lib/build/lib/core/models.py:679
#: build/lib/build/lib/core/models.py:679 build/lib/core/models.py:679
#: core/models.py:692
msgid "This user is already in this template."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:685
#: build/lib/build/lib/build/lib/core/models.py:685
#: build/lib/build/lib/core/models.py:685 build/lib/core/models.py:685
#: core/models.py:698
msgid "This team is already in this template."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:708
#: build/lib/build/lib/build/lib/core/models.py:708
#: build/lib/build/lib/core/models.py:708 build/lib/core/models.py:708
#: core/models.py:721
msgid "email address"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:725
#: build/lib/build/lib/build/lib/core/models.py:725
#: build/lib/build/lib/core/models.py:725 build/lib/core/models.py:725
#: core/models.py:738
msgid "Document invitation"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:726
#: build/lib/build/lib/build/lib/core/models.py:726
#: build/lib/build/lib/core/models.py:726 build/lib/core/models.py:726
#: core/models.py:739
msgid "Document invitations"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:751
#: build/lib/build/lib/build/lib/core/models.py:751
#: build/lib/build/lib/core/models.py:751 build/lib/core/models.py:751
#: core/models.py:764
msgid "This email is already associated to a registered user."
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/core/models.py:795
#: build/lib/build/lib/build/lib/core/models.py:795
#: build/lib/build/lib/core/models.py:795 build/lib/core/models.py:795
msgid "Invitation to join Impress!"
msgstr ""
#: build/lib/build/lib/build/lib/build/lib/impress/settings.py:158
#: build/lib/build/lib/build/lib/impress/settings.py:158
#: build/lib/build/lib/impress/settings.py:158
#: build/lib/impress/settings.py:158 impress/settings.py:158
msgid "English"
msgstr ""
#: impress/settings.py:135
#: build/lib/build/lib/build/lib/build/lib/impress/settings.py:159
#: build/lib/build/lib/build/lib/impress/settings.py:159
#: build/lib/build/lib/impress/settings.py:159
#: build/lib/impress/settings.py:159 impress/settings.py:159
msgid "French"
msgstr ""
#: build/lib/build/lib/core/api/serializers.py:185
#: build/lib/core/api/serializers.py:185 core/api/serializers.py:185
msgid "Format"
msgstr ""
#: core/models.py:808
msgid "Invitation to join Docs!"
msgstr "Invitation à rejoindre Docs !"
#: 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:160
#: core/templates/mail/text/invitation.txt:3
msgid "La Suite Numérique"
msgstr ""
#: core/templates/mail/html/invitation.html:190
#: core/templates/mail/text/invitation.txt:5
msgid "Invitation to join a document !"
msgstr "Invitation à rejoindre un document !"
#: core/templates/mail/html/invitation.html:198
msgid "Welcome to <strong>Docs!</strong>"
msgstr "Bienvenue sur <strong>Docs !</strong>"
#: core/templates/mail/html/invitation.html:213
#: core/templates/mail/text/invitation.txt:12
msgid "We are delighted to welcome you to our community on Docs, your new companion to collaborate on documents efficiently, intuitively, and securely."
msgstr "Nous sommes heureux de vous accueillir dans notre communauté sur Docs, votre nouveau compagnon pour collaborer sur des documents efficacement, intuitivement, et en toute sécurité."
#: core/templates/mail/html/invitation.html:218
#: core/templates/mail/text/invitation.txt:13
msgid "Our application is designed to help you organize, collaborate, and manage permissions."
msgstr "Notre application est conçue pour vous aider à organiser, collaborer et gérer vos permissions."
#: core/templates/mail/html/invitation.html:223
#: core/templates/mail/text/invitation.txt:14
msgid "With Docs, you will be able to:"
msgstr "Avec Docs, vous serez capable de :"
#: core/templates/mail/html/invitation.html:224
#: core/templates/mail/text/invitation.txt:15
msgid "Create documents."
msgstr "Créez des documents."
#: core/templates/mail/html/invitation.html:225
#: core/templates/mail/text/invitation.txt:16
msgid "Work offline."
msgstr "Travailler hors ligne."
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:17
msgid "Invite members of your community to your document in just a few clicks."
msgstr "Invitez des membres de votre communauté sur votre document en quelques clics."
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:19
msgid "Visit Docs"
msgstr "Visitez Docs"
#: core/templates/mail/html/invitation.html:246
#: core/templates/mail/text/invitation.txt:21
msgid "We are confident that Docs will help you increase efficiency and productivity while strengthening the bond among members."
msgstr "Nous sommes persuadés que Docs vous aidera à améliorer votre efficacité et votre productivité tout en renforçant les liens entre vos membres."
#: core/templates/mail/html/invitation.html:251
#: core/templates/mail/text/invitation.txt:22
msgid "Feel free to explore all the features of the application and share your feedback and suggestions with us. Your feedback is valuable to us and will enable us to continually improve our service."
msgstr "N'hésitez pas à explorer toutes les fonctionnalités de l'application et à nous faire part de vos commentaires et suggestions. Vos commentaires nous sont précieux et nous permettront d'améliorer continuellement notre service."
#: core/templates/mail/html/invitation.html:256
#: core/templates/mail/text/invitation.txt:23
msgid "Once again, welcome aboard! We are eager to accompany you on your collaboration adventure."
msgstr "Encore une fois, bienvenue à bord ! Nous sommes impatients de vous accompagner dans votre aventure collaborative."
#: core/templates/mail/html/invitation.html:263
#: core/templates/mail/text/invitation.txt:25
msgid "Sincerely,"
msgstr "Sincèrement,"
#: core/templates/mail/html/invitation.html:264
#: core/templates/mail/text/invitation.txt:27
msgid "The La Suite Numérique Team"
msgstr "L'équipe 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 ""
#: core/templates/mail/text/invitation.txt:8
msgid "Welcome to Docs!"
msgstr "Bienvenue sur Docs !"

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.1.0"
version = "1.3.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,37 +25,38 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.33.6",
"boto3==1.35.10",
"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.4.0",
"django-countries==7.6.1",
"django-parler==2.3",
"redis==5.0.3",
"redis==5.0.8",
"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.7",
"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",
"djangorestframework==3.15.2",
"drf_spectacular==0.27.2",
"dockerflow==2024.4.2",
"easy_thumbnails==2.9",
"factory_boy==3.3.1",
"freezegun==1.5.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",
"psycopg[binary]==3.2.1",
"PyJWT==2.9.0",
"pypandoc==1.13",
"python-frontmatter==1.1.0",
"requests==2.32.3",
"sentry-sdk==2.13.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 +68,20 @@ dependencies = [
[project.optional-dependencies]
dev = [
"django-extensions==3.2.3",
"drf-spectacular-sidecar==2023.12.1",
"drf-spectacular-sidecar==2024.7.1",
"ipdb==0.13.13",
"ipython==8.18.1",
"pyfakefs==5.3.2",
"ipython==8.27.0",
"pyfakefs==5.6.0",
"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",
"pylint==3.2.7",
"pytest-cov==5.0.0",
"pytest-django==4.9.0",
"pytest==8.3.2",
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.3",
"ruff==0.6.3",
"types-requests==2.32.0.20240712",
]
[tool.setuptools]
@@ -99,11 +100,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 +126,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
@@ -61,6 +61,15 @@ 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}
RUN yarn build
# ---- Front-end image ----

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

@@ -55,14 +55,25 @@ export const createDoc = async (
})
.fill(randomDocs[i]);
if (isPublic) {
await page.getByText('Is it public ?').click();
}
await expect(buttonCreate).toBeEnabled();
await buttonCreate.click();
await expect(page.locator('h2').getByText(randomDocs[i])).toBeVisible();
if (isPublic) {
await page.getByRole('button', { name: 'Share' }).click();
await page.getByText('Doc private').click();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
await expect(
page
.getByLabel('It is the card information about the document.')
.getByText('Public'),
).toBeVisible();
}
}
return randomDocs;
@@ -140,3 +151,35 @@ export const goToGridDoc = async (
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=')) {
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();
}
});
};

View File

@@ -21,8 +21,6 @@ test.describe('Doc Create', () => {
).toBeVisible();
await expect(card.getByLabel('Document name')).toBeVisible();
await expect(card.getByText('Is it public ?')).toBeVisible();
await expect(
card.getByRole('button', {
name: 'Create the document',
@@ -46,13 +44,11 @@ test.describe('Doc Create', () => {
await expect(buttonCreateHomepage).toBeVisible();
});
test('create a new public doc', async ({ page, browserName }) => {
const [docTitle] = await createDoc(
page,
'My new doc',
browserName,
1,
true,
test('it creates a doc', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, 'My new doc', browserName, 1);
expect(await page.locator('title').textContent()).toMatch(
/My new doc - Docs/,
);
const header = page.locator('header').first();
@@ -65,11 +61,5 @@ test.describe('Doc Create', () => {
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
await expect(datagrid.getByText(docTitle)).toBeVisible();
const row = datagrid.getByRole('row').filter({
hasText: docTitle,
});
await expect(row.getByRole('cell').nth(0)).toHaveText('Public');
});
});

View File

@@ -1,26 +1,15 @@
import path from 'path';
import { expect, test } from '@playwright/test';
import { createDoc, goToGridDoc } 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 ({
@@ -117,10 +101,6 @@ test.describe('Doc Editor', () => {
nthRow: 2,
});
await expect(
page.getByText(`Your document "${doc}" has been saved.`),
).toBeVisible();
await expect(page.locator('h2').getByText(secondDoc)).toBeVisible();
await goToGridDoc(page, {
@@ -153,42 +133,51 @@ test.describe('Doc Editor', () => {
});
test('it cannot edit if viewer', async ({ page }) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
!request.url().includes('page=')
) {
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,
},
});
} else {
await route.continue();
}
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 don't have the right to update this document.",
),
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/,
);
});
});

View File

@@ -0,0 +1,233 @@
import { expect, test } from '@playwright/test';
import cs from 'convert-stream';
import jsdom from 'jsdom';
import pdf from 'pdf-parse';
import { createDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Export', () => {
test('it converts the doc to pdf with a template integrated', async ({
page,
browserName,
}) => {
const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1);
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
});
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Export',
})
.click();
await page
.getByRole('button', {
name: 'Download',
})
.click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfText = (await pdf(pdfBuffer)).text;
expect(pdfText).toContain('Hello World'); // This is the doc text
});
test('it converts the doc to docx with a template integrated', async ({
page,
browserName,
}) => {
const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1);
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.docx`);
});
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Export',
})
.click();
await page.getByText('Docx').click();
await page
.getByRole('button', {
name: 'Download',
})
.click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDoc}.docx`);
});
test('it converts the blocknote json in correct html for the export', async ({
page,
browserName,
}) => {
test.slow();
const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1);
let body = '';
await page.route('**/templates/*/generate-document/', async (route) => {
const request = route.request();
body = request.postDataJSON().body;
await route.continue();
});
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.locator('.bn-block-outer').last().fill('Hello World');
await page.locator('.bn-block-outer').last().click();
await page.keyboard.press('Enter');
await page.keyboard.press('Enter');
await page.locator('.bn-block-outer').last().fill('Break');
await expect(page.getByText('Break')).toBeVisible();
// Center the text
await page.getByText('Break').dblclick();
await page.locator('button[data-test="alignTextCenter"]').click();
// Change the background color
await page.locator('button[data-test="colors"]').click();
await page.locator('button[data-test="background-color-brown"]').click();
// Change the text color
await page.getByText('Break').dblclick();
await page.locator('button[data-test="colors"]').click();
await page.locator('button[data-test="text-color-orange"]').click();
// Add a list
await page.locator('.bn-block-outer').last().click();
await page.keyboard.press('Enter');
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Bullet List').click();
await page
.locator('.bn-block-content[data-content-type="bulletListItem"] p')
.last()
.fill('Test List 1');
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(300);
await page.keyboard.press('Enter');
await page
.locator('.bn-block-content[data-content-type="bulletListItem"] p')
.last()
.fill('Test List 2');
await page.keyboard.press('Enter');
await page
.locator('.bn-block-content[data-content-type="bulletListItem"] p')
.last()
.fill('Test List 3');
await page.keyboard.press('Enter');
await page.keyboard.press('Backspace');
// Add a number list
await page.locator('.bn-block-outer').last().click();
await page.keyboard.press('Enter');
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Numbered List').click();
await page
.locator('.bn-block-content[data-content-type="numberedListItem"] p')
.last()
.fill('Test Number 1');
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(300);
await page.keyboard.press('Enter');
await page
.locator('.bn-block-content[data-content-type="numberedListItem"] p')
.last()
.fill('Test Number 2');
await page.keyboard.press('Enter');
await page
.locator('.bn-block-content[data-content-type="numberedListItem"] p')
.last()
.fill('Test Number 3');
// Add img
await page.locator('.bn-block-outer').last().click();
await page.keyboard.press('Enter');
await page.locator('.bn-block-outer').last().fill('/');
await page
.getByRole('option', {
name: 'Image',
})
.click();
await page
.getByRole('tab', {
name: 'Embed',
})
.click();
await page
.getByPlaceholder('Enter URL')
.fill('https://example.com/image.jpg');
await page
.getByRole('button', {
name: 'Embed image',
})
.click();
// Download
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Export',
})
.click();
await page
.getByRole('button', {
name: 'Download',
})
.click();
// Empty paragraph should be replaced by a <br/>
expect(body.match(/<br>/g)?.length).toBeGreaterThanOrEqual(2);
expect(body).toContain('style="color: orange;"');
expect(body).toContain('custom-style="center"');
expect(body).toContain('style="background-color: brown;"');
const { JSDOM } = jsdom;
const DOMParser = new JSDOM().window.DOMParser;
const parser = new DOMParser();
const html = parser.parseFromString(body, 'text/html');
const ulLis = html.querySelectorAll('ul li');
expect(ulLis.length).toBe(3);
expect(ulLis[0].textContent).toBe('Test List 1');
expect(ulLis[1].textContent).toBe('Test List 2');
expect(ulLis[2].textContent).toBe('Test List 3');
const olLis = html.querySelectorAll('ol li');
expect(olLis.length).toBe(3);
expect(olLis[0].textContent).toBe('Test Number 1');
expect(olLis[1].textContent).toBe('Test Number 2');
expect(olLis[2].textContent).toBe('Test Number 3');
const img = html.querySelectorAll('img');
expect(img.length).toBe(1);
expect(img[0].src).toBe('https://example.com/image.jpg');
});
});

View File

@@ -17,7 +17,7 @@ test.describe('Documents Grid', () => {
await expect(thead.getByText(/Created at/i)).toBeVisible();
await expect(thead.getByText(/Updated at/i)).toBeVisible();
await expect(thead.getByText(/Your role/i)).toBeVisible();
await expect(thead.getByText(/Users number/i)).toBeVisible();
await expect(thead.getByText(/Members/i)).toBeVisible();
const row1 = datagrid.getByRole('row').nth(1).getByRole('cell');
const docName = await row1.nth(1).textContent();
@@ -49,111 +49,130 @@ test.describe('Documents Grid', () => {
nameColumn: 'Document name',
ordering: 'title',
cellNumber: 1,
orderDefault: '',
orderDesc: '&ordering=-title',
orderAsc: '&ordering=title',
},
{
nameColumn: 'Created at',
ordering: 'created_at',
cellNumber: 2,
orderDefault: '',
orderDesc: '&ordering=-created_at',
orderAsc: '&ordering=created_at',
},
{
nameColumn: 'Updated at',
ordering: 'updated_at',
cellNumber: 3,
orderDefault: '&ordering=-updated_at',
orderDesc: '&ordering=updated_at',
orderAsc: '',
},
].forEach(({ nameColumn, ordering, cellNumber }) => {
test(`checks datagrid ordering ${ordering}`, async ({ page }) => {
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=1`) &&
response.status() === 200,
);
].forEach(
({
nameColumn,
ordering,
cellNumber,
orderDefault,
orderDesc,
orderAsc,
}) => {
test(`checks datagrid ordering ${ordering}`, async ({ page }) => {
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=1${orderDefault}`) &&
response.status() === 200,
);
const responsePromiseOrderingDesc = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=1&ordering=-${ordering}`) &&
response.status() === 200,
);
const responsePromiseOrderingDesc = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=1${orderDesc}`) &&
response.status() === 200,
);
const responsePromiseOrderingAsc = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=1&ordering=${ordering}`) &&
response.status() === 200,
);
const responsePromiseOrderingAsc = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=1${orderAsc}`) &&
response.status() === 200,
);
const datagrid = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
const thead = datagrid.locator('thead');
// Checks the initial state
const datagrid = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
const thead = datagrid.locator('thead');
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
const docNameRow1 = datagrid
.getByRole('row')
.nth(1)
.getByRole('cell')
.nth(cellNumber);
const docNameRow2 = datagrid
.getByRole('row')
.nth(2)
.getByRole('cell')
.nth(cellNumber);
const docNameRow1 = datagrid
.getByRole('row')
.nth(1)
.getByRole('cell')
.nth(cellNumber);
const docNameRow2 = datagrid
.getByRole('row')
.nth(2)
.getByRole('cell')
.nth(cellNumber);
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
// Initial state
await expect(docNameRow1).toHaveText(/.*/);
await expect(docNameRow2).toHaveText(/.*/);
const initialDocNameRow1 = await docNameRow1.textContent();
const initialDocNameRow2 = await docNameRow2.textContent();
// Initial state
await expect(docNameRow1).toHaveText(/.*/);
await expect(docNameRow2).toHaveText(/.*/);
const initialDocNameRow1 = await docNameRow1.textContent();
const initialDocNameRow2 = await docNameRow2.textContent();
expect(initialDocNameRow1).toBeDefined();
expect(initialDocNameRow2).toBeDefined();
expect(initialDocNameRow1).toBeDefined();
expect(initialDocNameRow2).toBeDefined();
// Ordering ASC
await thead.getByText(nameColumn).click();
// Ordering ASC
await thead.getByText(nameColumn).click();
const responseOrderingAsc = await responsePromiseOrderingAsc;
expect(responseOrderingAsc.ok()).toBeTruthy();
const responseOrderingAsc = await responsePromiseOrderingAsc;
expect(responseOrderingAsc.ok()).toBeTruthy();
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
await expect(docNameRow1).toHaveText(/.*/);
await expect(docNameRow2).toHaveText(/.*/);
const textDocNameRow1Asc = await docNameRow1.textContent();
const textDocNameRow2Asc = await docNameRow2.textContent();
expect(
textDocNameRow1Asc &&
textDocNameRow2Asc &&
textDocNameRow1Asc.localeCompare(textDocNameRow2Asc, 'en', {
caseFirst: 'false',
ignorePunctuation: true,
}) <= 0,
).toBeTruthy();
await expect(docNameRow1).toHaveText(/.*/);
await expect(docNameRow2).toHaveText(/.*/);
const textDocNameRow1Asc = await docNameRow1.textContent();
const textDocNameRow2Asc = await docNameRow2.textContent();
expect(
textDocNameRow1Asc &&
textDocNameRow2Asc &&
textDocNameRow1Asc.localeCompare(textDocNameRow2Asc, 'en', {
caseFirst: 'false',
ignorePunctuation: true,
}) <= 0,
).toBeTruthy();
// Ordering Desc
await thead.getByText(nameColumn).click();
// Ordering Desc
await thead.getByText(nameColumn).click();
const responseOrderingDesc = await responsePromiseOrderingDesc;
expect(responseOrderingDesc.ok()).toBeTruthy();
const responseOrderingDesc = await responsePromiseOrderingDesc;
expect(responseOrderingDesc.ok()).toBeTruthy();
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
await expect(docNameRow1).toHaveText(/.*/);
await expect(docNameRow2).toHaveText(/.*/);
const textDocNameRow1Desc = await docNameRow1.textContent();
const textDocNameRow2Desc = await docNameRow2.textContent();
await expect(docNameRow1).toHaveText(/.*/);
await expect(docNameRow2).toHaveText(/.*/);
const textDocNameRow1Desc = await docNameRow1.textContent();
const textDocNameRow2Desc = await docNameRow2.textContent();
expect(
textDocNameRow1Desc &&
textDocNameRow2Desc &&
textDocNameRow1Desc.localeCompare(textDocNameRow2Desc, 'en', {
caseFirst: 'false',
ignorePunctuation: true,
}) >= 0,
).toBeTruthy();
});
});
expect(
textDocNameRow1Desc &&
textDocNameRow2Desc &&
textDocNameRow1Desc.localeCompare(textDocNameRow2Desc, 'en', {
caseFirst: 'false',
ignorePunctuation: true,
}) >= 0,
).toBeTruthy();
});
},
);
test('checks the pagination', async ({ page }) => {
const responsePromisePage1 = page.waitForResponse(

View File

@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { goToGridDoc } from './common';
import { createDoc, goToGridDoc, mockedDocument } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -8,57 +8,43 @@ test.beforeEach(async ({ page }) => {
test.describe('Doc Header', () => {
test('it checks the element are correctly displayed', async ({ page }) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
!request.url().includes('page=')
) {
await route.fulfill({
json: {
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
content: '',
title: 'Mocked document',
accesses: [
{
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
role: 'owner',
user: {
email: 'super@owner.com',
},
},
{
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
role: 'admin',
user: {
email: 'super@admin.com',
},
},
{
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
role: 'owner',
user: {
email: 'super2@owner.com',
},
},
],
abilities: {
destroy: true, // Means owner
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: true,
update: true,
partial_update: true,
retrieve: true,
},
is_public: true,
created_at: '2021-09-01T09:00:00Z',
await mockedDocument(page, {
accesses: [
{
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
role: 'owner',
user: {
email: 'super@owner.com',
},
});
} else {
await route.continue();
}
},
{
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
role: 'admin',
user: {
email: 'super@admin.com',
},
},
{
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
role: 'owner',
user: {
email: 'super2@owner.com',
},
},
],
abilities: {
destroy: true, // Means owner
link_configuration: true,
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: true,
update: true,
partial_update: true,
retrieve: true,
},
link_reach: 'public',
created_at: '2021-09-01T09:00:00Z',
});
await goToGridDoc(page);
@@ -78,4 +64,186 @@ test.describe('Doc Header', () => {
await expect(card.getByText('Your role: Owner')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
});
test('it updates the doc', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-update', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Update document',
})
.click();
await expect(
page.locator('h2').getByText(`Update document "${randomDoc}"`),
).toBeVisible();
await page.getByText('Document name').fill(`${randomDoc}-updated`);
await page
.getByRole('button', {
name: 'Validate the modification',
})
.click();
await expect(
page.getByText('The document has been updated.'),
).toBeVisible();
const docTitle = await goToGridDoc(page, {
title: `${randomDoc}-updated`,
});
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Update document',
})
.click();
await expect(
page.getByRole('textbox', { name: 'Document name' }),
).toHaveValue(`${randomDoc}-updated`);
});
test('it deletes the doc', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-delete', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Delete document',
})
.click();
await expect(
page.locator('h2').getByText(`Deleting the document "${randomDoc}"`),
).toBeVisible();
await page
.getByRole('button', {
name: 'Confirm deletion',
})
.click();
await expect(
page.getByText('The document has been deleted.'),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Create a new document' }),
).toBeVisible();
const row = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table')
.getByRole('row')
.filter({
hasText: randomDoc,
});
expect(await row.count()).toBe(0);
});
test('it checks the options available if administrator', async ({ page }) => {
await mockedDocument(page, {
abilities: {
destroy: false, // Means not owner
link_configuration: true,
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: true, // Means admin
update: true,
partial_update: true,
retrieve: true,
},
});
await goToGridDoc(page);
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Update document' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Delete document' }),
).toBeHidden();
});
test('it checks the options available if editor', async ({ page }) => {
await mockedDocument(page, {
abilities: {
destroy: false, // Means not owner
link_configuration: false,
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
update: true,
partial_update: true, // Means editor
retrieve: true,
},
});
await goToGridDoc(page);
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
await page.getByLabel('Open the document options').click();
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Update document' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Delete document' }),
).toBeHidden();
});
test('it checks the options available if reader', 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.locator('h2').getByText('Mocked document')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
await page.getByLabel('Open the document options').click();
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Update document' }),
).toBeHidden();
await expect(
page.getByRole('button', { name: 'Delete document' }),
).toBeHidden();
});
});

View File

@@ -111,6 +111,9 @@ test.describe('Document create member', () => {
await expect(page.getByText(`Invitation sent to ${email}`)).toBeVisible();
const responseCreateInvitation = await responsePromiseCreateInvitation;
expect(responseCreateInvitation.ok()).toBeTruthy();
expect(
responseCreateInvitation.request().headers()['content-language'],
).toBe('en-us');
// Check user added
await expect(
@@ -118,6 +121,18 @@ test.describe('Document create member', () => {
).toBeVisible();
const responseAddUser = await responsePromiseAddUser;
expect(responseAddUser.ok()).toBeTruthy();
expect(responseAddUser.request().headers()['content-language']).toBe(
'en-us',
);
const listInvitation = page.getByLabel('List invitation card');
await expect(listInvitation.locator('li').getByText(email)).toBeVisible();
await expect(
listInvitation.locator('li').getByText('Invited'),
).toBeVisible();
const listMember = page.getByLabel('List members card');
await expect(listMember.locator('li').getByText(user.email)).toBeVisible();
});
test('it try to add twice the same user', async ({ page, browserName }) => {
@@ -170,7 +185,7 @@ test.describe('Document create member', () => {
const inputSearch = page.getByLabel(/Find a member to add to the document/);
const email = randomName('test@test.fr', browserName, 1)[0];
const [email] = randomName('test@test.fr', browserName, 1);
await inputSearch.fill(email);
await page.getByRole('option', { name: email }).click();
@@ -191,7 +206,105 @@ test.describe('Document create member', () => {
expect(responseCreateInvitation.ok()).toBeTruthy();
await inputSearch.fill(email);
await expect(page.getByText('Loading...')).toBeHidden();
await expect(page.getByRole('option', { name: email })).toBeHidden();
await page.getByRole('option', { name: email }).click();
// Choose a role
await page.getByRole('combobox', { name: /Choose a role/ }).click();
await page.getByRole('option', { name: 'Owner' }).click();
const responsePromiseCreateInvitationFail = page.waitForResponse(
(response) =>
response.url().includes('/invitations/') && response.status() === 400,
);
await page.getByRole('button', { name: 'Validate' }).click();
await expect(
page.getByText(`"${email}" is already invited to the document.`),
).toBeVisible();
const responseCreateInvitationFail =
await responsePromiseCreateInvitationFail;
expect(responseCreateInvitationFail.ok()).toBeFalsy();
});
test('The invitation endpoint get the language of the website', async ({
page,
browserName,
}) => {
await createDoc(page, 'user-invitation', browserName, 1);
const header = page.locator('header').first();
await header.getByRole('combobox').getByText('EN').click();
await header.getByRole('option', { name: 'FR' }).click();
await page.getByRole('button', { name: 'Partager' }).click();
const inputSearch = page.getByLabel(
/Trouver un membre à ajouter au document/,
);
const email = randomName('test@test.fr', browserName, 1)[0];
await inputSearch.fill(email);
await page.getByRole('option', { name: email }).click();
// Choose a role
await page.getByRole('combobox', { name: /Choisissez un rôle/ }).click();
await page.getByRole('option', { name: 'Administrateur' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
response.url().includes('/invitations/') && response.status() === 201,
);
await page.getByRole('button', { name: 'Valider' }).click();
// Check invitation sent
await expect(page.getByText(`Invitation envoyée à ${email}`)).toBeVisible();
const responseCreateInvitation = await responsePromiseCreateInvitation;
expect(responseCreateInvitation.ok()).toBeTruthy();
expect(
responseCreateInvitation.request().headers()['content-language'],
).toBe('fr-fr');
});
test('it manages invitation', async ({ page, browserName }) => {
await createDoc(page, 'user-invitation', browserName, 1);
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByLabel(/Find a member to add to the document/);
const email = randomName('test@test.fr', browserName, 1)[0];
await inputSearch.fill(email);
await page.getByRole('option', { name: email }).click();
// Choose a role
await page.getByRole('combobox', { name: /Choose a role/ }).click();
await page.getByRole('option', { name: 'Administrator' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
response.url().includes('/invitations/') && response.status() === 201,
);
await page.getByRole('button', { name: 'Validate' }).click();
// Check invitation sent
await expect(page.getByText(`Invitation sent to ${email}`)).toBeVisible();
const responseCreateInvitation = await responsePromiseCreateInvitation;
expect(responseCreateInvitation.ok()).toBeTruthy();
const listInvitation = page.getByLabel('List invitation card');
const li = listInvitation.locator('li').filter({
hasText: email,
});
await expect(li.getByText(email)).toBeVisible();
await li.getByRole('combobox', { name: /Role/ }).click();
await li.getByRole('option', { name: 'Reader' }).click();
await expect(page.getByText(`The role has been updated.`)).toBeVisible();
await li.getByText('delete').click();
await expect(
page.getByText(`The invitation has been removed.`),
).toBeVisible();
await expect(listInvitation.locator('li').getByText(email)).toBeHidden();
});
});

View File

@@ -1,10 +1,12 @@
import { expect, test } from '@playwright/test';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
import { keyCloakSignIn } from './common';
test.describe('Doc Routing', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('checks alias docs url with homepage', async ({ page }) => {
await expect(page).toHaveURL('/');
@@ -14,9 +16,9 @@ test.describe('Doc Routing', () => {
await expect(buttonCreateHomepage).toBeVisible();
await page.goto('/docs');
await page.goto('/docs/');
await expect(buttonCreateHomepage).toBeVisible();
await expect(page).toHaveURL(/\/docs$/);
await expect(page).toHaveURL(/\/docs\/$/);
});
test('checks 404 on docs/[id] page', async ({ page }) => {
@@ -33,3 +35,25 @@ test.describe('Doc Routing', () => {
});
});
});
test.describe('Doc Routing: Not loggued', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('checks redirect to a doc after login', async ({
page,
browserName,
}) => {
await page.goto('/docs/mocked-document-id/');
await keyCloakSignIn(page, browserName);
await expect(page).toHaveURL(/\/docs\/mocked-document-id\/$/);
});
test('The homepage redirects to login.', async ({ page }) => {
await page.goto('/');
await expect(
page.getByRole('button', {
name: 'Sign In',
}),
).toBeVisible();
});
});

View File

@@ -0,0 +1,93 @@
import { expect, test } from '@playwright/test';
import { createDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Table Content', () => {
test('it checks the doc table content', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(
page,
'doc-table-content',
browserName,
1,
);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Table of content',
})
.click();
const panel = page.getByLabel('Document panel');
const editor = page.locator('.ProseMirror');
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 1').click();
await page.keyboard.type('Hello World');
await page.locator('.bn-block-outer').last().click();
// Create space to fill the viewport
for (let i = 0; i < 5; i++) {
await page.keyboard.press('Enter');
}
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 2').click();
await page.keyboard.type('Super World');
await page.locator('.bn-block-outer').last().click();
// Create space to fill the viewport
for (let i = 0; i < 5; i++) {
await page.keyboard.press('Enter');
}
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 3').click();
await page.keyboard.type('Another World');
const hello = panel.getByText('Hello World');
const superW = panel.getByText('Super World');
const another = panel.getByText('Another World');
await expect(hello).toBeVisible();
await expect(hello).toHaveCSS('font-size', '19.2px');
await expect(hello).toHaveAttribute('aria-selected', 'true');
await expect(superW).toBeVisible();
await expect(superW).toHaveCSS('font-size', '16px');
await expect(superW).toHaveAttribute('aria-selected', 'false');
await expect(another).toBeVisible();
await expect(another).toHaveCSS('font-size', '12.8px');
await expect(another).toHaveAttribute('aria-selected', 'false');
await hello.click();
await expect(editor.getByText('Hello World')).toBeInViewport();
await expect(hello).toHaveAttribute('aria-selected', 'true');
await expect(superW).toHaveAttribute('aria-selected', 'false');
await another.click();
await expect(editor.getByText('Hello World')).not.toBeInViewport();
await expect(hello).toHaveAttribute('aria-selected', 'false');
await expect(superW).toHaveAttribute('aria-selected', 'true');
await panel.getByText('Back to top').click();
await expect(editor.getByText('Hello World')).toBeInViewport();
await expect(hello).toHaveAttribute('aria-selected', 'true');
await expect(superW).toHaveAttribute('aria-selected', 'false');
await panel.getByText('Go to bottom').click();
await expect(editor.getByText('Hello World')).not.toBeInViewport();
await expect(superW).toHaveAttribute('aria-selected', 'true');
});
});

View File

@@ -1,334 +0,0 @@
import { expect, test } from '@playwright/test';
import cs from 'convert-stream';
import pdf from 'pdf-parse';
import { createDoc, goToGridDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Tools', () => {
test('it converts the doc to pdf with a template integrated', async ({
page,
browserName,
}) => {
const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1);
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
});
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Generate PDF',
})
.click();
await page
.getByRole('button', {
name: 'Download',
})
.click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfText = (await pdf(pdfBuffer)).text;
expect(pdfText).toContain('Hello World'); // This is the doc text
});
test('it converts the blocknote json in correct html for the pdf', async ({
page,
browserName,
}) => {
const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1);
let body = '';
await page.route('**/templates/*/generate-document/', async (route) => {
const request = route.request();
body = request.postDataJSON().body;
await route.continue();
});
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.locator('.bn-block-outer').last().fill('Hello World');
await page.locator('.bn-block-outer').last().click();
await page.keyboard.press('Enter');
await page.keyboard.press('Enter');
await page.locator('.bn-block-outer').last().fill('Break');
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Generate PDF',
})
.click();
await page
.getByRole('button', {
name: 'Download',
})
.click();
// Empty paragraph should be replaced by a <br/>
expect(body).toContain('<br/><br/>');
});
test('it updates the doc', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(
page,
'doc-update',
browserName,
1,
true,
);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Update document',
})
.click();
await expect(
page.locator('h2').getByText(`Update document "${randomDoc}"`),
).toBeVisible();
await expect(
page.getByRole('checkbox', { name: 'Is it public ?' }),
).toBeChecked();
await page.getByText('Document name').fill(`${randomDoc}-updated`);
await page.getByText('Is it public ?').click();
await page
.getByRole('button', {
name: 'Validate the modification',
})
.click();
await expect(
page.getByText('The document has been updated.'),
).toBeVisible();
const docTitle = await goToGridDoc(page, {
title: `${randomDoc}-updated`,
});
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Update document',
})
.click();
await expect(
page.getByRole('checkbox', { name: 'Is it public ?' }),
).not.toBeChecked();
});
test('it deletes the doc', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-delete', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Delete document',
})
.click();
await expect(
page.locator('h2').getByText(`Deleting the document "${randomDoc}"`),
).toBeVisible();
await page
.getByRole('button', {
name: 'Confirm deletion',
})
.click();
await expect(
page.getByText('The document has been deleted.'),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Create a new document' }),
).toBeVisible();
const row = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table')
.getByRole('row')
.filter({
hasText: randomDoc,
});
expect(await row.count()).toBe(0);
});
test('it checks the options available if administrator', async ({ page }) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
!request.url().includes('page=')
) {
await route.fulfill({
json: {
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
content: '',
title: 'Mocked document',
accesses: [],
abilities: {
destroy: false, // Means not owner
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: true, // Means admin
update: true,
partial_update: true,
retrieve: true,
},
is_public: false,
},
});
} else {
await route.continue();
}
});
await goToGridDoc(page);
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('button', { name: 'Generate PDF' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Update document' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Delete document' }),
).toBeHidden();
});
test('it checks the options available if editor', async ({ page }) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
!request.url().includes('page=')
) {
await route.fulfill({
json: {
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
content: '',
title: 'Mocked document',
accesses: [],
abilities: {
destroy: false, // Means not owner
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
update: true,
partial_update: true, // Means editor
retrieve: true,
},
is_public: false,
},
});
} else {
await route.continue();
}
});
await goToGridDoc(page);
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('button', { name: 'Generate PDF' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Update document' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Delete document' }),
).toBeHidden();
});
test('it checks the options available if reader', async ({ page }) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
!request.url().includes('page=')
) {
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,
},
});
} else {
await route.continue();
}
});
await goToGridDoc(page);
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
await page.getByLabel('Open the document options').click();
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
await expect(
page.getByRole('button', { name: 'Generate PDF' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Update document' }),
).toBeHidden();
await expect(
page.getByRole('button', { name: 'Delete document' }),
).toBeHidden();
});
});

View File

@@ -0,0 +1,96 @@
import { expect, test } from '@playwright/test';
import { createDoc, keyCloakSignIn } from './common';
test.describe('Doc Visibility', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('Make a public doc', async ({ page, browserName }) => {
const [docTitle] = await createDoc(
page,
'My new doc',
browserName,
1,
true,
);
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
const datagrid = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
await expect(datagrid.getByText(docTitle)).toBeVisible();
const row = datagrid.getByRole('row').filter({
hasText: docTitle,
});
await expect(row.getByRole('cell').nth(0)).toHaveText('Public');
});
test('It checks the copy link button', async ({ page, browserName }) => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(
browserName === 'webkit',
'navigator.clipboard is not working with webkit and playwright',
);
await createDoc(page, 'My button copy doc', browserName, 1);
await page.getByRole('button', { name: 'Share' }).click();
await page.getByRole('button', { name: 'Copy link' }).click();
await expect(page.getByText('Link Copied !')).toBeVisible();
const handle = await page.evaluateHandle(() =>
navigator.clipboard.readText(),
);
const clipboardContent = await handle.jsonValue();
expect(clipboardContent).toMatch(page.url());
});
});
test.describe('Doc Visibility: Not loggued', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('A public doc is accessible even when not authentified.', async ({
page,
browserName,
}) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docTitle] = await createDoc(
page,
'My new doc',
browserName,
1,
true,
);
await expect(
page.getByText('The document visiblitity has been updated.'),
).toBeVisible();
const urlDoc = page.url();
await page
.getByRole('button', {
name: 'Logout',
})
.click();
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible();
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
});
});

View File

@@ -8,11 +8,7 @@ test.describe('Footer', () => {
test('checks all the elements are visible', async ({ page }) => {
const footer = page.locator('footer').first();
await expect(footer.getByAltText('Marianne Logo')).toBeVisible();
await expect(
footer.getByAltText('Freedom Equality Fraternity Logo'),
).toBeVisible();
await expect(footer.getByAltText('Gouvernement Logo')).toBeVisible();
await expect(
footer.getByRole('link', { name: 'legifrance.gouv.fr' }),

View File

@@ -2,19 +2,15 @@ import { expect, test } from '@playwright/test';
import { keyCloakSignIn } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Header', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('checks all the elements are visible', async ({ page }) => {
const header = page.locator('header').first();
await expect(header.getByAltText('Marianne Logo')).toBeVisible();
await expect(
header.getByAltText('Freedom Equality Fraternity Logo'),
).toBeVisible();
await expect(header.getByAltText('Gouvernement Logo')).toBeVisible();
await expect(header.getByAltText('Docs Logo')).toBeVisible();
await expect(header.locator('h2').getByText('Docs')).toHaveCSS(
@@ -26,6 +22,12 @@ test.describe('Header', () => {
/Marianne/i,
);
await expect(
header.getByRole('button', {
name: 'Logout',
}),
).toBeVisible();
await expect(header.getByAltText('Language Icon')).toBeVisible();
await expect(
@@ -72,12 +74,6 @@ test.describe('Header: Log out', () => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
await page
.getByRole('button', {
name: 'My account',
})
.click();
await page
.getByRole('button', {
name: 'Logout',

View File

@@ -1,15 +1,18 @@
{
"name": "app-e2e",
"version": "1.1.0",
"version": "1.3.0",
"private": true,
"scripts": {
"lint": "eslint . --ext .ts",
"install": "playwright install --with-deps",
"install-playwright": "playwright install --with-deps",
"test": "playwright test",
"test:ui": "yarn test --ui"
"test:ui": "yarn test --ui",
"test:ui::firefox": "yarn test:ui --project=firefox",
"test:ui::webkit": "yarn test:ui --project=webkit",
"test:ui::chromium": "yarn test:ui --project=chromium"
},
"devDependencies": {
"@playwright/test": "1.45.0",
"@playwright/test": "1.47.1",
"@types/node": "*",
"@types/pdf-parse": "1.1.4",
"eslint-config-impress": "*",
@@ -17,6 +20,7 @@
},
"dependencies": {
"convert-stream": "1.0.2",
"pdf-parse": "^1.1.1"
"jsdom": "25.0.0",
"pdf-parse": "1.1.1"
}
}

View File

@@ -32,12 +32,10 @@ export default defineConfig({
},
webServer: {
command: `cd ../.. && yarn app:${
process.env.CI ? 'start -p ' : 'dev --port '
} ${PORT}`,
command: !process.env.CI ? `cd ../.. && yarn app:dev --port ${PORT}` : '',
url: baseURL,
timeout: 120 * 1000,
reuseExistingServer: !process.env.CI,
reuseExistingServer: true,
},
/* Configure projects for major browsers */
@@ -50,6 +48,9 @@ export default defineConfig({
locale: 'en-US',
timezoneId: 'Europe/Paris',
storageState: 'playwright/.auth/user-chromium.json',
contextOptions: {
permissions: ['clipboard-read', 'clipboard-write'],
},
},
dependencies: ['setup'],
},
@@ -63,5 +64,21 @@ export default defineConfig({
},
dependencies: ['setup'],
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
locale: 'en-US',
timezoneId: 'Europe/Paris',
storageState: 'playwright/.auth/user-firefox.json',
launchOptions: {
firefoxUserPrefs: {
'dom.events.asyncClipboard.readText': true,
'dom.events.testing.asyncClipboard': true,
},
},
},
dependencies: ['setup'],
},
],
});

View File

@@ -0,0 +1,4 @@
NEXT_PUBLIC_API_ORIGIN=
NEXT_PUBLIC_Y_PROVIDER_URL=
NEXT_PUBLIC_MEDIA_URL=
NEXT_PUBLIC_THEME=dsfr

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
server {
listen 8080;
listen 3000;
server_name localhost;
root /usr/share/nginx/html;
@@ -8,6 +9,10 @@ server {
try_files $uri index.html $uri/ =404;
}
location ~ ^/docs/(.*)/versions/(.*)/$ {
error_page 404 /docs/[id]/versions/[versionId]/;
}
location /docs/ {
error_page 404 /docs/[id]/;
}

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